40 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
28bc33a96d clients: expand Go, Python, Rust client APIs
- Add TOTP enrollment/confirmation/removal to all clients
- Add password change and admin set-password endpoints
- Add account listing, status update, and tag management
- Add audit log listing with filter support
- Add policy rule CRUD operations
- Expand test coverage for all new endpoints across clients
- Fix .gitignore to exclude built binaries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:29:11 -07:00
98ed858c67 trusted proxy, TOTP replay protection, new tests
- Trusted proxy config option for proxy-aware IP extraction
  used by rate limiting and audit logs; validates proxy IP
  before trusting X-Forwarded-For / X-Real-IP headers
- TOTP replay protection via counter-based validation to
  reject reused codes within the same time step (±30s)
- RateLimit middleware updated to extract client IP from
  proxy headers without IP spoofing risk
- New tests for ClientIP proxy logic (spoofed headers,
  fallback) and extended rate-limit proxy coverage
- HTMX error banner script integrated into web UI base
- .gitignore updated for mciasdb build artifact

Security: resolves CRIT-01 (TOTP replay attack) and
DEF-03 (proxy-unaware rate limiting); gRPC TOTP
enrollment aligned with REST via StorePendingTOTP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:44:01 -07:00
35f27b7c4f UI: password change enforcement + migration recovery
- Web UI admin password reset now enforces admin role
  server-side (was cookie-auth + CSRF only; any logged-in
  user could previously reset any account's password)
- Added self-service password change UI at GET/PUT /profile:
  current_password + new_password + confirm_password;
  server-side equality check; lockout + Argon2id verification;
  revokes all other sessions on success
- password_change_form.html fragment and profile.html page
- Nav bar actor name now links to /profile
- policy: ActionChangePassword + default rule -7 allowing
  human accounts to change their own password
- openapi.yaml: built-in rules count updated to -7

Migration recovery:
- mciasdb schema force --version N: new subcommand to clear
  dirty migration state without running SQL (break-glass)
- schema subcommands bypass auto-migration on open so the
  tool stays usable when the database is dirty
- Migrate(): shim no longer overrides schema_migrations
  when it already has an entry; duplicate-column error on
  the latest migration is force-cleaned and treated as
  success (handles columns added outside the runner)

Security:
- Admin role is now validated in handleAdminResetPassword
  before any DB access; non-admin receives 403
- handleSelfChangePassword follows identical lockout +
  constant-time Argon2id path as the REST self-service
  handler; current password required to prevent
  token-theft account takeover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:33:19 -07:00
25d550a066 ignore env file 2026-03-12 14:39:14 -07:00
5d7d2cfc08 Checkpoint: password reset, rule expiry, migrations
- Self-service and admin password-change endpoints
  (PUT /v1/auth/password, PUT /v1/accounts/{id}/password)
- Policy rule time-scoped expiry (not_before / expires_at)
  with migration 000006 and engine filtering
- golang-migrate integration; embedded SQL migrations
- PolicyRecord fieldalignment lint fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 14:38:38 -07:00
833775de83 db: integrate golang-migrate for schema migrations
- internal/db/migrations/: five embedded SQL files containing
  the migration SQL previously held as Go string literals.
  Files follow the NNN_description.up.sql naming convention
  required by golang-migrate's iofs source.
- internal/db/migrate.go: rewritten to use
  github.com/golang-migrate/migrate/v4 with the
  database/sqlite driver (modernc.org/sqlite, pure Go) and
  source/iofs for compile-time embedded SQL.
  - newMigrate() opens a dedicated *sql.DB so m.Close() does
    not affect the caller's shared connection.
  - Migrate() includes a compatibility shim: reads the legacy
    schema_version table and calls m.Force(v) before m.Up()
    so existing databases are not re-migrated.
  - LatestSchemaVersion promoted from var to const.
- internal/db/db.go: added path field to DB struct; Open()
  translates ':memory:' to a named shared-cache URI
  (file:mcias_N?mode=memory&cache=shared) so the migration
  runner can open a second connection to the same in-memory
  database without sharing the handle that golang-migrate
  will close on teardown.
- go.mod: added golang-migrate/migrate/v4 v4.19.1 (direct).
All callers unchanged. All tests pass; golangci-lint clean.
2026-03-12 11:52:39 -07:00
562aad908e UI: pgcreds create button; show logged-in user
* web/templates/pgcreds.html: New Credentials card is now always
  rendered; Add Credentials toggle button reveals the create form
  (hidden by default). Shows a message when all system accounts
  already have credentials. Previously the card was hidden when
  UncredentialedAccounts was empty.
* internal/ui/ui.go: added ActorName string field to PageData;
  added actorName(r) helper resolving username from JWT claims
  via DB lookup, returns empty string if unauthenticated.
* internal/ui/handlers_*.go: all full-page PageData constructors
  now pass ActorName: u.actorName(r).
* web/templates/base.html: nav bar renders actor username as a
  muted label before the Logout button when logged in.
* web/static/style.css: added .nav-actor rule (muted grey, 0.85rem).
2026-03-12 11:38:57 -07:00
118 changed files with 10355 additions and 1157 deletions

View File

@@ -1 +1,31 @@
{}
{
"permissions": {
"allow": [
"Bash(go test:*)",
"Bash(golangci-lint run:*)",
"Bash(git restore:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(grep -n \"handleAdminResetPassword\\\\|handleChangePassword\" /Users/kyle/src/mcias/internal/ui/*.go)",
"Bash(go build:*)",
"Bash(sqlite3 /Users/kyle/src/mcias/run/mcias.db \"PRAGMA table_info\\(policy_rules\\);\" 2>&1)",
"Bash(sqlite3 /Users/kyle/src/mcias/run/mcias.db \"SELECT * FROM schema_version;\" 2>&1; sqlite3 /Users/kyle/src/mcias/run/mcias.db \"SELECT * FROM schema_migrations;\" 2>&1)",
"Bash(go run:*)",
"Bash(go list:*)",
"Bash(go vet:*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "go build ./... 2>&1 | head -20"
}
]
}
]
}
}

Binary file not shown.

View File

@@ -0,0 +1,8 @@
# Checkpoint Skill
1. Run `go build ./...` abort if errors
2. Run `go test ./...` abort if failures
3. Run `go vet ./...`
4. Run `git add -A && git status` show user what will be committed
5. Generate an appropriate commit message based on your instructions.
6. Run `git commit -m "<message>"` and verify with `git log -1`

View File

@@ -0,0 +1,8 @@
Run a full security audit of this Go codebase. For each finding rated
HIGH or CRITICAL: spawn a sub-agent using Task to implement the fix
across all affected files (models, handlers, migrations, templates,
tests). Each sub-agent must: 1) write a failing test that reproduces the
vulnerability, 2) implement the fix, 3) run `go test ./...` and `go vet
./...` in a loop until all pass, 4) commit with a message referencing
the finding ID. After all sub-agents complete, generate a summary of
what was fixed and what needs manual review.

8
.gitignore vendored
View File

@@ -34,4 +34,10 @@ clients/python/*.egg-info/
clients/lisp/**/*.fasl
# manual testing
/run/
run/
.env
/cmd/mciasctl/mciasctl
/cmd/mciasdb/mciasdb
/cmd/mciasgrpcctl/mciasgrpcctl
/cmd/mciassrv/mciassrv

0
.junie/memory/errors.md Normal file
View File

View File

View File

@@ -0,0 +1 @@
[{"lang":"en","usageCount":1}]

View File

@@ -0,0 +1 @@
1.0

0
.junie/memory/tasks.md Normal file
View File

83
AGENTS.md Normal file
View File

@@ -0,0 +1,83 @@
# CLAUDE.md
## Project Overview
MCIAS (Metacircular Identity and Access System) is a single-sign-on (SSO) and Identity & Access Management (IAM) system for personal projects. The target audience is a single developer building personal apps, with support for onboarding friends onto those apps.
**Priorities (in order):** security, robustness, correctness. Performance is secondary.
## Tech Stack
- **Language:** Go
- **Database:** SQLite
- **Logging/Utilities:** git.wntrmute.dev/kyle/goutils
- **Crypto:** Ed25519 (signatures), Argon2 (password hashing)
- **Tokens:** JWT signed with Ed25519 (algorithm: EdDSA); always validate the `alg` header on receipt — never accept `none` or symmetric algorithms
- **Auth:** Username/password + optional TOTP; future FIDO/Yubikey support
## Binaries
- `mciassrv` — authentication server (REST + gRPC over HTTPS/TLS, with HTMX web UI)
- `mciasctl` — admin CLI for account/token/credential/policy management (REST)
- `mciasdb` — offline SQLite maintenance tool (schema, accounts, tokens, audit, pgcreds)
- `mciasgrpcctl` — admin CLI for gRPC interface
## Development Workflow
If PROGRESS.md does not yet exist, create it before proceeding. It is the source of truth for current state.
1. Check PROGRESS.md for current state and next steps
2. Define discrete next steps with actionable acceptance criteria
3. Implement, adversarially verify correctness, write tests
4. Commit to git, update PROGRESS.md
5. Repeat
When instructed to checkpoint:
- Verify that the project lints cleanly.
- Verify that the project unit tests complete successfully.
- Ensure that all integration and end-to-end tests complete successfully.
- Commit to git and update PROGRESS.md.
## Security Constraints
This is a security-critical project. The following rules are non-negotiable:
- Never implement custom crypto. Use standard library (`crypto/...`) or well-audited packages only.
- Always validate the `alg` header in JWTs before processing; reject `none` and any non-EdDSA algorithm.
- Argon2id parameters must meet current OWASP recommendations; never reduce them for convenience.
- Credential storage (passwords, tokens, secrets) must never appear in logs, error messages, or API responses.
- Any code touching authentication flows, token issuance/validation, or credential storage must include a comment citing the rationale for each security decision.
- When in doubt about a crypto or auth decision, halt and ask rather than guess.
- Review all crypto primitives against current best practices before use; flag any deviation in the commit body.
## Testing Requirements
- Tests live alongside source in the same package, using the `_test.go` suffix
- Run with `go test ./...`; CI must pass with zero failures
- Unit tests for all exported functions and security-critical internal functions
- Integration tests for all subsystems (database layer, token issuance, auth flows)
- End-to-end tests for complete login, token renewal, and revocation flows
- Adversarially verify all outputs: test invalid inputs, boundary conditions, and known attack patterns (e.g., JWT `alg` confusion, timing attacks on credential comparison)
- Use `crypto/subtle.ConstantTimeCompare` wherever token or credential equality is checked
## Git Commit Style
- First line: single line, max 55 characters
- Body (optional): bullet points describing work done
- Security-sensitive changes (crypto primitives, auth flows, token handling, credential storage, session management) must be explicitly flagged in the commit body with a `Security:` line describing what changed and why it is safe
## Go Conventions
- Format all code with `goimports` before committing
- Lint with `golangci-lint`; resolve all warnings unless explicitly justified. This must be done after every step.
- Wrap errors with `fmt.Errorf("context: %w", err)` to preserve stack context
- Prefer explicit error handling over panics; never silently discard errors
- Use `log/slog` (or goutils equivalents) for structured logging; never `fmt.Println` in production paths
## Key Documents
- `PROJECT.md` — Project specifications and requirements
- `ARCHITECTURE.md`**Required before any implementation.** Covers token lifecycle, session management, multi-app trust boundaries, and database schema. Do not generate code until this document exists.
- `PROJECT_PLAN.md` — Discrete implementation steps (to be written)
- `PROGRESS.md` — Development progress tracking (to be written)
- `openapi.yaml` - Must be kept in sync with any API changes.

View File

@@ -15,36 +15,46 @@ parties that delegate authentication decisions to it.
### Components
```
┌────────────────────────────────────────────────────┐
│ MCIAS Server (mciassrv) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Auth │ │ Token │ │ Account / Role │ │
│ │ Handler │ │ Manager │ │ Manager │ │
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │
│ └─────────────┴─────────────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ SQLite Database │ │
│ └────────────────────┘ │
└────────────────────────────────────────────────────┘
▲ ▲
│ HTTPS/REST │ HTTPS/REST │ direct file I/O
┌──────┴──────┐ ┌────┴─────┐ ┌──────┴──────┐
Personal │ │ mciasctl │ │ mciasdb
│ Apps │ │ (admin │ │ (DB tool) │
└─────────────┘ │ CLI) └─────────────┘
└──────────┘
┌──────────────────────────────────────────────────────────
MCIAS Server (mciassrv)
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐
│ │ Auth │ │ Token │ │ Account / Role │
│ │ Handler │ │ Manager │ │ Manager │
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘
│ └─────────────┴─────────────────┘
│ │
│ ┌─────────▼──────────┐
│ │ SQLite Database │
│ └────────────────────┘
│ │
┌──────────────────┐ ┌──────────────────────┐
REST listener │ │ gRPC listener │ │
│ │ (net/http) (google.golang.org/ │
│ │ :8443 │ │ grpc) :9443 │ │
└──────────────────┘ └──────────────────────┘
└──────────────────────────────────────────────────────────┘
▲ ▲ ▲
│ 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.
Handles login, token issuance, token validation, token renewal, and token
revocation.
**mciassrv** — The authentication server. Exposes a REST API and gRPC API over
HTTPS/TLS (dual-stack; see §17). Handles login, token issuance, token
validation, token renewal, and token revocation.
**mciasctl** — The administrator CLI. Communicates with mciassrv's REST API
using an admin JWT. Creates/manages human accounts, system accounts, roles,
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
file, bypassing the server API. Intended for break-glass recovery, offline
inspection, schema verification, and maintenance tasks that cannot be
@@ -127,13 +137,21 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
### 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
- `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
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.
@@ -245,6 +263,61 @@ Key properties:
- Admin can revoke all tokens for a user (e.g., on account suspension)
- Token expiry is enforced at validation time, regardless of revocation table
### Password Change Flows
Two distinct flows exist for changing a password, with different trust assumptions:
#### Self-Service Password Change (`PUT /v1/auth/password`)
Used by a human account holder to change their own password.
1. Caller presents a valid JWT and supplies both `current_password` and
`new_password` in the request body.
2. The server looks up the account by the JWT subject.
3. **Lockout check** — same policy as login (10 failures in 15 min → 15 min
lockout). An attacker with a stolen token cannot use this endpoint to
brute-force the current password without hitting the lockout.
4. **Current password verified** with `auth.VerifyPassword` (Argon2id,
constant-time via `crypto/subtle.ConstantTimeCompare`). On failure a login
failure is recorded and HTTP 401 is returned.
5. New password is validated (minimum 12 characters) and hashed with Argon2id
using the server's configured parameters.
6. The new hash is written atomically to the `accounts` table.
7. **All tokens except the caller's current JTI are revoked** (reason:
`password_changed`). The caller keeps their active session; all other
concurrent sessions are invalidated. This limits the blast radius of a
credential compromise without logging the user out mid-operation.
8. Login failure counter is cleared (successful proof of knowledge).
9. Audit event `password_changed` is written with `{"via":"self_service"}`.
#### Admin Password Reset (`PUT /v1/accounts/{id}/password`)
Used by an administrator to reset a human account's password for recovery
purposes (e.g. user forgot their password, account handover).
1. Caller presents an admin JWT.
2. Only `new_password` is required; no `current_password` verification is
performed. The admin role represents a higher trust level.
3. New password is validated (minimum 12 characters) and hashed with Argon2id.
4. The new hash is written to the `accounts` table.
5. **All active tokens for the target account are revoked** (reason:
`password_reset`). Unlike the self-service flow, the admin cannot preserve
the user's session because the reset is typically done during an outage of
the user's access.
6. Audit event `password_changed` is written with `{"via":"admin_reset"}`.
#### Security Notes
- The current password requirement on the self-service path prevents an
attacker who steals a JWT from changing credentials. A stolen token grants
access to resources for its remaining lifetime but cannot be used to
permanently take over the account.
- Admin resets are always audited with both actor and target IDs so the log
shows which admin performed the reset.
- Plaintext passwords are never logged, stored, or included in any response.
- Both flows use the same Argon2id parameters (OWASP 2023: time=3, memory=64 MB,
threads=4, hash length=32 bytes).
---
## 7. Multi-App Trust Boundaries
@@ -304,12 +377,21 @@ All endpoints use JSON request/response bodies. All responses include a
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
### Password Endpoints
| Method | Path | Auth required | Description |
|---|---|---|---|
| PUT | `/v1/auth/password` | bearer JWT | Self-service: change own password (current password required) |
| PUT | `/v1/accounts/{id}/password` | admin JWT | Admin reset: set any human account's password |
### Role Endpoints (admin only)
| Method | Path | Auth required | Description |
|---|---|---|---|
| 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
@@ -356,6 +438,39 @@ All endpoints use JSON request/response bodies. All responses include a
| GET | `/v1/health` | none | Health check |
| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) |
### Web Management UI
mciassrv embeds an HTMX-based web management interface served alongside the
REST API. The UI is an admin-only interface providing a visual alternative to
`mciasctl` for day-to-day management.
**Package:** `internal/ui/` — UI handlers call internal Go functions directly;
no internal HTTP round-trips to the REST API.
**Template engine:** Go `html/template` with templates embedded at compile time
via `web/` (`embed.FS`). Templates are parsed once at startup.
**Session management:** JWT stored as `HttpOnly; Secure; SameSite=Strict`
cookie (`mcias_session`). CSRF protection uses HMAC-signed double-submit
cookie pattern (`mcias_csrf`).
**Pages and features:**
| Path | Description |
|---|---|
| `/login` | Username/password login with optional TOTP step |
| `/` | Dashboard (account summary) |
| `/accounts` | Account list |
| `/accounts/{id}` | Account detail — status, roles, tags, PG credentials (system accounts) |
| `/pgcreds` | Postgres credentials list (owned + granted) with create form |
| `/policies` | Policy rules management — create, enable/disable, delete |
| `/audit` | Audit log viewer |
| `/profile` | User profile — self-service password change (any authenticated user) |
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
saves, policy toggles, access grants) use HTMX partial-page updates for a
responsive experience without full-page reloads.
---
## 9. Database Schema
@@ -395,6 +510,9 @@ CREATE TABLE accounts (
-- AES-256-GCM encrypted TOTP secret; NULL if not enrolled
totp_secret_enc 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')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
deleted_at TEXT
@@ -445,10 +563,22 @@ CREATE TABLE system_tokens (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
-- Per-account failed login attempts for brute-force lockout enforcement.
-- One row per account; window_start resets when the window expires or on
-- a successful login.
CREATE TABLE failed_logins (
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
window_start TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 1
);
-- Postgres credentials for system accounts, encrypted at rest.
CREATE TABLE pg_credentials (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
-- owner_id: account that administers the credentials and may grant/revoke
-- access. Nullable for backwards compatibility with pre-migration-5 rows.
owner_id INTEGER REFERENCES accounts(id),
pg_host TEXT NOT NULL,
pg_port INTEGER NOT NULL DEFAULT 5432,
pg_database TEXT NOT NULL,
@@ -459,6 +589,21 @@ CREATE TABLE pg_credentials (
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
-- Explicit read-access grants from a credential owner to another account.
-- Grantees may view connection metadata but the password is never decrypted
-- for them in the UI. Only the owner may update or delete the credential set.
CREATE TABLE pg_credential_access (
id INTEGER PRIMARY KEY,
credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE,
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (credential_id, grantee_id)
);
CREATE INDEX idx_pgcred_access_cred ON pg_credential_access (credential_id);
CREATE INDEX idx_pgcred_access_grantee ON pg_credential_access (grantee_id);
-- Audit log — append-only. Never contains credentials or secret material.
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
@@ -496,7 +641,9 @@ CREATE TABLE policy_rules (
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339)
);
```
@@ -537,17 +684,20 @@ or a keyfile path — never inline in the config file.
```toml
[server]
listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
tls_cert = "/etc/mcias/server.crt"
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]
path = "/var/lib/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
default_expiry = "720h" # 30 days
default_expiry = "168h" # 7 days
admin_expiry = "8h"
service_expiry = "8760h" # 365 days
@@ -587,7 +737,8 @@ mcias/
│ ├── policy/ # in-process authorization policy engine (§20)
│ ├── server/ # HTTP handlers, router setup
│ ├── 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/
│ ├── static/ # CSS and static assets
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
@@ -637,6 +788,9 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
| `totp_removed` | TOTP removed from account |
| `pgcred_accessed` | Postgres credentials retrieved |
| `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_removed` | Tag removed from account |
| `policy_rule_created` | Policy rule created |
@@ -714,6 +868,7 @@ mciasdb --config PATH <subcommand> [flags]
|---|---|
| `mciasdb schema verify` | Open DB, run migrations in dry-run mode, report version |
| `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` |
**Account management (offline):**
@@ -819,7 +974,7 @@ in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.
|---|---|
| `AuthService` | `Login`, `Logout`, `RenewToken`, `EnrollTOTP`, `ConfirmTOTP`, `RemoveTOTP` |
| `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` |
| `AdminService` | `Health`, `GetPublicKey` |
@@ -1250,9 +1405,10 @@ const (
ActionReadAudit Action = "audit:read"
ActionEnrollTOTP Action = "totp:enroll" // self-service
ActionRemoveTOTP Action = "totp:remove" // admin
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionListRules Action = "policy:list"
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionChangePassword Action = "auth:change_password" // self-service
ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage"
// Resource types
@@ -1352,8 +1508,10 @@ at the same priority level.
```
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: accountTypes=[human], actions=[auth:change_password]
— self-service password change
Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read],
resourceType=pgcreds, ownerMatchesSubject=true
— system account reads own creds
@@ -1440,6 +1598,26 @@ For belt-and-suspenders, an explicit deny for production tags:
No `ServiceNames` or `RequiredTags` field means this matches any service account.
**Scenario D — Time-scoped access:**
The `deploy-agent` needs temporary access to production pgcreds for a 4-hour
maintenance window. Instead of creating a rule and remembering to delete it,
the operator sets `not_before` and `expires_at`:
```
mciasctl policy create \
-description "deploy-agent: temp production access" \
-json rule.json \
-not-before 2026-03-12T02:00:00Z \
-expires-at 2026-03-12T06:00:00Z
```
The policy engine filters rules at cache-load time (`Engine.SetRules`): rules
where `not_before > now()` or `expires_at <= now()` are excluded from the
cached rule set. No changes to the `Evaluate()` or `matches()` functions are
needed. Both fields are optional and nullable; `NULL` means no constraint
(always active / never expires).
### Middleware Integration
`internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in

282
AUDIT.md
View File

@@ -1,258 +1,194 @@
# MCIAS Security Audit Report
**Scope:** Full codebase review of `git.wntrmute.dev/kyle/mcias` (commit `4596ea0`) aka mcias.
**Auditor:** Comprehensive source review of all Go source files, protobuf definitions, Dockerfile, systemd unit, and client libraries
**Classification:** Findings rated as **CRITICAL**, **HIGH**, **MEDIUM**, **LOW**, or **INFORMATIONAL**
**Date:** 2026-03-14 (updated — all findings remediated)
**Original audit date:** 2026-03-13
**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
MCIAS is well-engineered for a security-critical system. The code demonstrates strong awareness of common vulnerability classes: JWT algorithm confusion is properly mitigated, constant-time comparisons are used throughout, timing-uniform dummy operations prevent user enumeration, and credential material is systematically excluded from logs and API responses. The cryptographic choices are sound and current.
MCIAS has a strong security posture. All findings from three audit rounds (CRIT-01/CRIT-02, DEF-01 through DEF-10, and SEC-01 through SEC-12) have been remediated. The cryptographic foundations are sound, JWT validation is correct, SQL injection is not possible, XSS is prevented by Go's html/template auto-escaping, and CSRF protection is well-implemented.
That said, I identified **16 findings** ranging from medium-severity design issues to low-severity hardening opportunities. There are no critical vulnerabilities that would allow immediate remote compromise, but several medium-severity items warrant remediation before production deployment.
**All findings from this audit have been remediated.** See the remediation table below for details.
---
## FINDINGS
## Remediated Findings (SEC-01 through SEC-12)
### F-01: TOTP Enrollment Sets `totp_required=1` Before Confirmation (MEDIUM)
All findings from this audit have been remediated. The original descriptions are preserved below for reference.
**Location:** `internal/db/accounts.go:131-141`, `internal/server/server.go:651-658`
| 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 |
`SetTOTP` unconditionally sets `totp_required = 1`. This means during the enrollment phase (before the user has confirmed), the TOTP requirement flag is already true. If the user abandons enrollment after calling `/v1/auth/totp/enroll` but before calling `/confirm`, the account is now locked: TOTP is "required" but the user was never shown a QR code they can use to generate valid codes.
<details>
<summary>Original finding descriptions (click to expand)</summary>
**Recommendation:** Add a separate `StorePendingTOTP(accountID, secretEnc, secretNonce)` that writes the encrypted secret but leaves `totp_required = 0`. Only set `totp_required = 1` in the confirm handler via the existing `SetTOTP`. Alternatively, add a `ClearTOTP` recovery step to the enrollment flow on timeout/failure.
### SEC-01 — TOTP Enrollment Does Not Require Password Re-authentication (Medium)
**Files:** `internal/server/server.go`, `internal/grpcserver/auth.go`
`POST /v1/auth/totp/enroll` and the gRPC `EnrollTOTP` RPC originally required only a valid JWT — no password confirmation. If an attacker stole a session token, they could enroll TOTP on the victim's account.
**Fix:** Both endpoints now require the current password, with lockout counter incremented on failure.
---
### F-02: Password Embedded in HTML Hidden Fields During TOTP Step (MEDIUM)
### SEC-02 — Account Lockout Response Leaks Account Existence (Medium)
**Location:** `internal/ui/handlers_auth.go:74-84`
Locked accounts originally returned HTTP 429 / gRPC `ResourceExhausted` with `"account temporarily locked"`, distinguishable from the HTTP 401 `"invalid credentials"` returned for wrong passwords.
During the TOTP step of UI login, the plaintext password is embedded as a hidden form field so it can be re-verified on the second POST. This means:
1. The password exists in the DOM and is accessible to any browser extension or XSS-via-extension vector.
2. The password is sent over the wire a second time (TLS protects transit, but it doubles the exposure window).
3. Browser form autofill or "view source" can reveal it.
**Recommendation:** On successful password verification in the first step, issue a short-lived (e.g., 60-second), single-use, server-side nonce that represents "password verified for user X". Store this nonce in the DB or an in-memory cache. The TOTP confirmation step presents this nonce instead of the password. The server validates the nonce + TOTP code and issues the session token.
**Fix:** All login paths now return the same `"invalid credentials"` response for locked accounts, with dummy Argon2 to maintain timing uniformity.
---
### F-03: Token Renewal Is Not Atomic — Race Window Between Revoke and Track (MEDIUM)
### SEC-03 Token Renewal Has No Proximity or Re-auth Check (Medium)
**Location:** `internal/server/server.go:281-289`, `internal/grpcserver/auth.go:148-155`
`POST /v1/auth/renew` originally accepted any valid token regardless of remaining lifetime.
The token renewal flow revokes the old token and tracks the new one as separate operations. The code comments acknowledge "atomically is not possible in SQLite without a transaction." However, SQLite does support transactions, and both operations use the same `*db.DB` instance with `MaxOpenConns(1)`. If the revoke succeeds but `TrackToken` fails, the user's old token is revoked but no new token is tracked, leaving them in a broken state.
**Recommendation:** Wrap the revoke-old + track-new pair in a single SQLite transaction. Add a method like `db.RenewToken(oldJTI, reason, newJTI, accountID, issuedAt, expiresAt)` that performs both in one `tx`.
**Fix:** Renewal now requires the token to have consumed ≥50% of its lifetime before it can be renewed.
---
### F-04: Rate Limiter Not Applied to REST Login Endpoint (MEDIUM)
### SEC-04 — REST API Responses Lack Security Headers (Low-Medium)
**Location:** `internal/server/server.go:96-100`
API endpoints originally returned only `Content-Type` — no `Cache-Control`, `X-Content-Type-Options`, or HSTS.
Despite the comment saying "login-path rate limiting," the REST server applies `RequestLogger` as global middleware but **does not apply the `RateLimit` middleware at all**. The rate limiter is imported but never wired into the handler chain for the REST server. The `/v1/auth/login` endpoint has no rate limiting on the REST side.
In contrast, the gRPC server correctly applies `rateLimitInterceptor` in its interceptor chain (applied to all RPCs).
**Recommendation:** Apply `middleware.RateLimit(...)` to at minimum the `/v1/auth/login` and `/v1/token/validate` routes in the REST server. Consider a more restrictive rate for login (e.g., 5/min) versus general API endpoints.
**Fix:** `globalSecurityHeaders` middleware applies these headers to all routes (API and UI).
---
### F-05: No `nbf` (Not Before) Claim in Issued JWTs (LOW)
### SEC-05 No Request Body Size Limit on REST API Endpoints (Low)
**Location:** `internal/token/token.go:68-99`
`decodeJSON` originally read from `r.Body` without any size limit.
Tokens are issued with `iss`, `sub`, `iat`, `exp`, and `jti` but not `nbf` (Not Before). While the architecture document states `nbf` is validated "if present," it is never set during issuance. Setting `nbf = iat` is a defense-in-depth measure that prevents premature token use if there is any clock skew between systems, and ensures relying parties that validate `nbf` don't reject MCIAS tokens.
**Recommendation:** Set `NotBefore: jwt.NewNumericDate(now)` in the `jwtClaims.RegisteredClaims`.
**Fix:** `http.MaxBytesReader` with 1 MiB limit added to `decodeJSON`. Maximum password length also enforced.
---
### F-06: `HasRole` Uses Non-Constant-Time String Comparison (LOW)
### SEC-06 — gRPC Rate Limiter Ignores TrustedProxy (Low)
**Location:** `internal/token/token.go:174-181`
The gRPC rate limiter originally used `peer.FromContext` directly, always getting the proxy IP behind a reverse proxy.
`HasRole` uses plain `==` string comparison for role names. Role names are not secret material, and this is authorization (not authentication), so this is low severity. However, if role names ever contained sensitive information, this could leak information via timing. Given the project's stated principle of using constant-time comparisons "wherever token or credential equality is checked," this is a minor inconsistency.
**Recommendation:** Acceptable as-is since role names are public knowledge. Document the decision.
**Fix:** `grpcClientIP` now reads from gRPC metadata headers when the peer matches the trusted proxy.
---
### F-07: Dummy Argon2 Hash Uses Hardcoded Invalid PHC String (LOW)
### SEC-07 — Static File Directory Listing Enabled (Low)
**Location:** `internal/server/server.go:154`
`http.FileServerFS` served directory listings by default.
The dummy Argon2 hash `"$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g"` uses m=65536 but the actual default config uses m=65536 too. The timing should be close. However, the dummy hash uses a 6-byte salt ("testsalt" base64) and a 6-byte hash ("testhash" base64), while real hashes use 16-byte salt and 32-byte hash. This produces a slightly different (faster) Argon2 computation than a real password verification.
**Recommendation:** Pre-compute a real dummy hash at server startup using `auth.HashPassword("dummy-password", actualArgonParams)` and store it as a `sync.Once` variable. This guarantees identical timing regardless of configuration.
**Fix:** `noDirListing` wrapper returns 404 for directory requests.
---
### F-08: No Account Lockout After Repeated Failed Login Attempts (LOW)
### SEC-08 — System Token Issuance Is Not Atomic (Low)
**Location:** `internal/server/server.go:138-176`
`handleTokenIssue` originally performed three sequential non-transactional operations.
There is no mechanism to lock an account after N failed login attempts. The system relies solely on rate limiting (which, per F-04, isn't applied on the REST side). An attacker with distributed IPs could attempt brute-force attacks against accounts without triggering any lockout.
**Recommendation:** Implement a configurable per-account failed login counter (e.g., 10 failures in 15 minutes triggers a 15-minute lockout). The counter should be stored in the DB or in memory with per-account tracking. Audit events for `login_fail` already exist and can be queried, but proactive lockout would be more effective.
**Fix:** `IssueSystemToken` wraps all operations in a single SQLite transaction.
---
### F-09: `PRAGMA synchronous=NORMAL` Risks Data Loss on Power Failure (LOW)
### SEC-09 — Navigation Bar Exposes Admin UI Structure to Non-Admin Users (Informational)
**Location:** `internal/db/db.go:50`
Nav links were rendered for all authenticated users.
`PRAGMA synchronous=NORMAL` combined with WAL mode means a power failure could lose the most recent committed transactions. For a security-critical system where audit log integrity and token revocation records matter, `synchronous=FULL` is safer.
**Recommendation:** Change to `PRAGMA synchronous=FULL` for production deployments. The performance impact on a personal SSO system is negligible. Alternatively, document this trade-off and leave `NORMAL` as a conscious choice.
**Fix:** Admin nav links wrapped in `{{if .IsAdmin}}` conditional.
---
### F-10: No Maximum Token Expiry Validation (LOW)
### SEC-10 No `Permissions-Policy` Header (Informational)
**Location:** `internal/config/config.go:150-159`
The security headers middleware did not include `Permissions-Policy`.
Token expiry durations are validated to be positive but have no maximum. An operator could accidentally configure `default_expiry = "876000h"` (100 years). The config validation should enforce reasonable ceilings.
**Recommendation:** Add maximum expiry validation: e.g., `default_expiry <= 8760h` (1 year), `admin_expiry <= 168h` (1 week), `service_expiry <= 87600h` (10 years). These can be generous ceilings that prevent obvious misconfiguration.
**Fix:** `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` added.
---
### F-11: Missing `Content-Security-Policy` and Other Security Headers on UI Responses (MEDIUM)
### SEC-11 — Audit Log Details Use `fmt.Sprintf` Instead of `json.Marshal` (Informational)
**Location:** `internal/ui/ui.go:318-333`
Audit details were constructed with `fmt.Sprintf` and `%q`, which is fragile for JSON.
The UI serves HTML pages but sets no security headers: no `Content-Security-Policy`, no `X-Content-Type-Options`, no `X-Frame-Options`, no `Strict-Transport-Security`. Since this is an admin panel for an authentication system:
- Without CSP, any XSS vector (e.g., via a malicious username stored in the DB) could execute arbitrary JavaScript in the admin's browser.
- Without `X-Frame-Options: DENY`, the admin panel could be framed for clickjacking.
- Without HSTS, a MITM could strip TLS on the first connection.
**Recommendation:** Add a middleware that sets:
```
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Strict-Transport-Security: max-age=63072000; includeSubDomains
Referrer-Policy: no-referrer
```
**Fix:** `audit.JSON` and `audit.JSONWithRoles` helpers use `json.Marshal`.
---
### F-12: No Input Validation on Username Length or Character Set (LOW)
### SEC-12 — Default Token Expiry Is 30 Days (Informational / Configuration)
**Location:** `internal/server/server.go:465-507`
Default expiry was 720h (30 days).
`handleCreateAccount` checks that username is non-empty but does not validate length or character set. A username containing control characters, null bytes, or extremely long strings (up to SQLite's TEXT limit) could cause rendering issues in the UI, log injection, or storage abuse.
**Fix:** Reduced to 168h (7 days). Combined with SEC-03's renewal proximity check, exposure window is significantly reduced.
**Recommendation:** Validate: length 1-255, alphanumeric + limited symbols (e.g., `^[a-zA-Z0-9._@-]{1,255}$`). Reject control characters, embedded NULs, and newlines.
</details>
---
### F-13: No Password Complexity or Minimum Length Enforcement (LOW)
## Previously Remediated Findings (CRIT/DEF series)
**Location:** `internal/auth/auth.go:63-66`
The following findings from the initial audit (2026-03-12) were confirmed fixed in the 2026-03-13 audit:
`HashPassword` only checks that the password is non-empty. A 1-character password is accepted and hashed. While Argon2id makes brute-force expensive, a minimum password length of 8-12 characters (per NIST SP 800-63B) would prevent trivially weak passwords.
**Recommendation:** Enforce a minimum password length (e.g., 12 characters) at the server/handler level before passing to `HashPassword`. Optionally check against a breached-password list.
| 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 |
---
### F-14: Passphrase Not Zeroed After Use in `loadMasterKey` (LOW)
## Positive Findings (Preserved)
**Location:** `cmd/mciassrv/main.go:246-272`
These implementation details are exemplary and should be maintained:
The passphrase is read from the environment variable and passed to `crypto.DeriveKey`, but the Go `string` holding the passphrase is not zeroed afterward. The environment variable is correctly unset, and the master key is zeroed on shutdown, but the passphrase string remains in the Go heap until GC'd. Go strings are immutable, so zeroing is not straightforward, but converting to `[]byte` first and zeroing after KDF would reduce the exposure window.
**Recommendation:** Read the environment variable into a `[]byte` (via `os.Getenv` then `[]byte` copy), pass it to a modified `DeriveKey` that accepts `[]byte`, then zero the `[]byte` immediately after. Alternatively, accept this as a Go language limitation and document it.
| Area | Detail |
|------|--------|
| JWT alg confusion | `ValidateToken` enforces `alg=EdDSA` in the key function before signature verification |
| Constant-time operations | `crypto/subtle.ConstantTimeCompare` for password hashes, CSRF tokens; all three TOTP windows evaluated without early exit |
| Timing uniformity | Dummy Argon2 via `sync.Once` for unknown/inactive users on all login paths |
| Token revocation | Fail-closed: untracked tokens are rejected, not silently accepted |
| Token renewal atomicity | `RenewToken` wraps revoke+track in a single SQLite transaction |
| TOTP replay prevention | Counter-based replay detection with atomic SQL UPDATE/WHERE |
| TOTP nonce design | 128-bit single-use server-side nonce; password never retransmitted in step 2 |
| CSRF protection | HMAC-SHA256 double-submit cookie, domain-separated key derivation, SameSite=Strict, constant-time validation |
| Credential exclusion | `json:"-"` on all credential fields; password hash never in API responses |
| Security headers (UI) | CSP (no unsafe-inline), X-Content-Type-Options, X-Frame-Options DENY, HSTS 2yr, Referrer-Policy no-referrer |
| Cookie hardening | HttpOnly + Secure + SameSite=Strict on session cookie |
| Account lockout | 10-attempt rolling window, checked before Argon2, with timing-safe dummy hash |
| Argon2id parameters | Config validator enforces OWASP 2023 minimums; rejects weakening |
| SQL injection | Zero string concatenation — all queries parameterized |
| Input validation | Username regex + length, password min length, account type enum, role allowlist, JSON strict decoder |
| Audit logging | Append-only, no delete path, credentials never logged, actor/target/IP captured |
| Master key hygiene | Env var cleared after read, key zeroed on shutdown, AES-256-GCM at rest |
| TLS | MinVersion TLS 1.2, X25519 preferred, no plaintext listener, read/write/idle timeouts set |
---
### F-15: `extractBearerFromRequest` Does Not Verify "Bearer" Prefix Case-Insensitively (INFORMATIONAL)
## Remediation Status
**Location:** `internal/server/server.go:932-942`
The REST `extractBearerFromRequest` (used by `handleTokenValidate`) does a substring check with `auth[len("Bearer ")]` without verifying the prefix actually says "Bearer". It trusts that if the header is long enough, the prefix is correct. Meanwhile, the middleware's `extractBearerToken` correctly uses `strings.EqualFold`. The gRPC `extractBearerFromMD` also correctly uses `strings.EqualFold`.
**Recommendation:** Use `strings.EqualFold` for the prefix check in `extractBearerFromRequest` for consistency.
---
### F-16: UI System Token Issuance Does Not Revoke Previous System Token (LOW)
**Location:** `internal/ui/handlers_accounts.go:334-403`
The REST `handleTokenIssue` and gRPC `IssueServiceToken` both revoke the existing system token before issuing a new one. However, `handleIssueSystemToken` in the UI handler does not revoke the old system token — it calls `SetSystemToken` (which updates the system_tokens table via UPSERT) but never revokes the old token's entry in the token_revocation table. The old token remains valid until it naturally expires.
**Recommendation:** Before issuing a new token in `handleIssueSystemToken`, replicate the pattern from the REST handler: look up `GetSystemToken`, and if found, call `RevokeToken(existing.JTI, "rotated")`.
---
## Positive Findings (Things Done Well)
1. **JWT algorithm confusion defense** is correctly implemented. The `alg` header is validated inside the key function before signature verification, and only `EdDSA` is accepted. This is the correct implementation pattern.
2. **Constant-time comparisons** are consistently used for password verification, TOTP validation, and CSRF token validation via `crypto/subtle.ConstantTimeCompare`.
3. **Timing uniformity** for failed logins: dummy Argon2 operations run for unknown users and inactive accounts, preventing username enumeration via timing differences.
4. **Credential material exclusion** is thorough: `json:"-"` tags on `PasswordHash`, `TOTPSecretEnc`, `TOTPSecretNonce`, `PGPasswordEnc`, `PGPasswordNonce` in model types, plus deliberate omission from API responses and log statements.
5. **Parameterized SQL** is used consistently throughout. No string concatenation in queries. The dynamic query builder in `ListAuditEvents`/`ListAuditEventsPaged` correctly uses parameter placeholders.
6. **TLS configuration** is solid: TLS 1.2 minimum, X25519/P256 curves, enforced at the listener level with no plaintext fallback.
7. **Master key handling** is well-designed: passphrase derived via Argon2id with strong parameters (128 MiB memory), env var cleared after reading, key zeroed on shutdown.
8. **Systemd hardening** is comprehensive: `ProtectSystem=strict`, `NoNewPrivileges`, `MemoryDenyWriteExecute`, empty `CapabilityBoundingSet`, and `PrivateDevices`.
9. **AES-GCM usage** is correct: fresh random nonces per encryption, key size validated, error details not exposed on decryption failure.
10. **CSRF protection** is well-implemented with HMAC-signed double-submit cookies and `SameSite=Strict`.
---
## Summary Table
| Fixed? | ID | Severity | Title | Effort |
|--------|----|----------|-------|--------|
| Yes | F-01 | MEDIUM | TOTP enrollment sets required=1 before confirmation | Small |
| Yes | F-02 | MEDIUM | Password in HTML hidden fields during TOTP step | Medium |
| Yes | F-03 | MEDIUM | Token renewal not atomic (race window) | Small |
| Yes | F-04 | MEDIUM | Rate limiter not applied to REST login endpoint | Small |
| Yes | F-11 | MEDIUM | Missing security headers on UI responses | Small |
| No | F-05 | LOW | No `nbf` claim in issued JWTs | Trivial |
| No | F-06 | LOW | `HasRole` uses non-constant-time comparison | Trivial |
| Yes | F-07 | LOW | Dummy Argon2 hash timing mismatch | Small |
| Yes | F-08 | LOW | No account lockout after repeated failures | Medium |
| No | F-09 | LOW | `synchronous=NORMAL` risks audit data loss | Trivial |
| No | F-10 | LOW | No maximum token expiry validation | Small |
| Yes | F-12 | LOW | No username length/charset validation | Small |
| Yes | F-13 | LOW | No minimum password length enforcement | Small |
| No | F-14 | LOW | Passphrase string not zeroed after KDF | Small |
| Yes | F-16 | LOW | UI system token issuance skips old token revocation | Small |
| No | F-15 | INFO | Bearer prefix check inconsistency | Trivial |
---
## Recommended Remediation Priority
**Immediate (before production deployment):**
1. F-04 — Wire the rate limiter into the REST server. This is the most impactful gap.
2. F-11 — Add security headers to UI responses.
3. F-01 — Fix TOTP enrollment to not lock accounts prematurely.
**Short-term:**
4. F-03 — Make token renewal atomic.
5. F-02 — Replace password-in-hidden-field with a server-side nonce.
6. F-16 — Fix UI system token issuance to revoke old tokens.
7. F-07 — Use a real dummy hash with matching parameters.
**Medium-term:**
8. F-08 — Implement account lockout.
9. F-12, F-13 — Input validation for usernames and passwords.
10. Remaining LOW/INFO items at maintainer discretion.
**All findings remediated.** No open items remain. Next audit should focus on:
- Any new features added since 2026-03-14
- Dependency updates and CVE review
- Live penetration testing of remediated endpoints

View File

@@ -17,8 +17,10 @@ MCIAS (Metacircular Identity and Access System) is a single-sign-on (SSO) and Id
## Binaries
- `mciassrv` — authentication server (REST API over HTTPS/TLS)
- `mciasctl` — admin CLI for account/token/credential management
- `mciassrv` — authentication server (REST + gRPC over HTTPS/TLS, with HTMX web UI)
- `mciasctl` — admin CLI for account/token/credential/policy management (REST)
- `mciasdb` — offline SQLite maintenance tool (schema, accounts, tokens, audit, pgcreds)
- `mciasgrpcctl` — admin CLI for gRPC interface
## Development Workflow
@@ -72,6 +74,26 @@ This is a security-critical project. The following rules are non-negotiable:
- Prefer explicit error handling over panics; never silently discard errors
- Use `log/slog` (or goutils equivalents) for structured logging; never `fmt.Println` in production paths
## Verification
After any code edit, always verify the fix by running `go build ./...` and `go test ./...` before claiming the issue is resolved. Never claim lint/tests pass without actually running them.
## Database
When working with migrations (golang-migrate or SQLite), always test migrations against a fresh database AND an existing database to catch duplicate column/table errors. SQLite does not support IF NOT EXISTS for ALTER TABLE.
## File Editing
Before editing files, re-read the current on-disk version to confirm it matches expectations. If files seem inconsistent, stop and flag this to the user before proceeding.
## Project Context
For this project (MCIAS): Go codebase, uses golang-migrate, SQLite (with shared-cache for in-memory), htmx frontend with Go html/template, golangci-lint (use `go vet` if version incompatible), and cert tool for TLS certificates. Check `docs/` for tool-specific usage before guessing CLI flags.
## UI Development
When implementing UI features, ensure they work for the empty-state case (e.g., no credentials exist yet, no accounts created). Always test with zero records.
## Key Documents
- `PROJECT.md` — Project specifications and requirements

View File

@@ -134,6 +134,10 @@ dist: man
docker:
docker build -t mcias:$(VERSION) -t mcias:latest .
.PHONY: install-local
install-local: build
cp bin/* $(HOME)/.local/bin/
# ---------------------------------------------------------------------------
# Help
# ---------------------------------------------------------------------------

View File

@@ -4,6 +4,416 @@ Source of truth for current development state.
---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
### 2026-03-13 — Make pgcreds discoverable via CLI and UI
**Problem:** Users had no way to discover which pgcreds were available to them or what their credential IDs were, making it functionally impossible to use the system without manual database inspection.
**Solution:** Added two complementary discovery paths:
**REST API:**
- New `GET /v1/pgcreds` endpoint (requires authentication) returns all accessible credentials (owned + explicitly granted) with their IDs, host, port, database, username, and timestamps
- Response includes `id` field so users can then fetch full credentials via `GET /v1/accounts/{id}/pgcreds`
**CLI (`cmd/mciasctl/main.go`):**
- New `pgcreds list` subcommand calls `GET /v1/pgcreds` and displays accessible credentials with IDs
- Updated usage documentation to include `pgcreds list`
**Web UI (`web/templates/pgcreds.html`):**
- Credential ID now displayed in a `<code>` element at the top of each credential's metadata block
- Styled with monospace font for easy copying and reference
**Files modified:**
- `internal/server/server.go`: Added route `GET /v1/pgcreds` (requires auth, not admin) + handler `handleListAccessiblePGCreds`
- `cmd/mciasctl/main.go`: Added `pgCredsList` function and switch case
- `web/templates/pgcreds.html`: Display credential ID in the credentials list
- Struct field alignment fixed in `pgCredResponse` to pass `go vet`
All tests pass; `go vet ./...` clean.
### 2026-03-12 — Update web UI and model for all compile-time roles
- `internal/model/model.go`: added `RoleGuest`, `RoleViewer`, `RoleEditor`, and
`RoleCommenter` constants; updated `allowedRoles` map and `ValidateRole` error
message to include the full set of recognised roles.
- `internal/ui/`: updated `knownRoles` to include guest, viewer, editor, and
commenter; replaced hardcoded role strings with model constants; removed
obsolete "service" role from UI dropdowns.
- All tests pass; build verified.
### 2026-03-12 — Fix UI privilege escalation vulnerability
**internal/ui/ui.go**
- Added `requireAdminRole` middleware that checks `claims.HasRole("admin")`
and returns 403 if absent
- Updated `admin` and `adminGet` middleware wrappers to include
`requireAdminRole` in the chain — previously only `requireCookieAuth`
was applied, allowing any authenticated user to access admin endpoints
- Profile routes correctly use only `requireCookieAuth` (not admin-gated)
**internal/ui/handlers_accounts.go**
- Removed redundant inline admin check from `handleAdminResetPassword`
(now handled by route-level middleware)
**Full audit performed across all three API surfaces:**
- REST (`internal/server/server.go`): all admin routes use
`requireAuth → RequireRole("admin")` — correct
- gRPC (all service files): every admin RPC calls `requireAdmin(ctx)` as
first statement — correct
- UI: was vulnerable, now fixed with `requireAdminRole` middleware
All tests pass; `go vet ./...` clean.
### 2026-03-12 — Checkpoint: password change UI enforcement + migration recovery
**internal/ui/handlers_accounts.go**
- `handleAdminResetPassword`: added server-side admin role check at the top of
the handler; any authenticated non-admin calling this route now receives 403.
Previously only cookie validity + CSRF were checked.
**internal/ui/handlers_auth.go**
- Added `handleProfilePage`: renders the new `/profile` page for any
authenticated user.
- Added `handleSelfChangePassword`: self-service password change for non-admin
users; validates current password (Argon2id, lockout-checked), enforces
server-side confirmation equality check, hashes new password, revokes all
other sessions, audits as `{"via":"ui_self_service"}`.
**internal/ui/ui.go**
- Added `ProfileData` view model.
- Registered `GET /profile` and `PUT /profile/password` routes (cookie auth +
CSRF; no admin role required).
- Added `password_change_form.html` to shared template list; added `profile`
page template.
- Nav bar actor-name span changed to a link pointing to `/profile`.
**web/templates/fragments/password_change_form.html** (new)
- HTMX form with `current_password`, `new_password`, `confirm_password` fields.
- Client-side JS confirmation guard; server-side equality check in handler.
**web/templates/profile.html** (new)
- Profile page hosting the self-service password change form.
**internal/db/migrate.go**
- Compatibility shim now only calls `m.Force(legacyVersion)` when
`schema_migrations` is completely empty (`ErrNilVersion`); leaves existing
version entries (including dirty ones) alone to prevent re-running already-
attempted migrations.
- Added duplicate-column-name recovery: when `m.Up()` fails with "duplicate
column name" and the dirty version equals `LatestSchemaVersion`, the migrator
is force-cleaned and returns nil (handles databases where columns were added
outside the runner before migration 006 existed).
- Added `ForceSchemaVersion(database *DB, version int) error`: break-glass
exported function; forces golang-migrate version without running SQL.
**cmd/mciasdb/schema.go**
- Added `schema force --version N` subcommand backed by `db.ForceSchemaVersion`.
**cmd/mciasdb/main.go**
- `schema` commands now open the database via `openDBRaw` (no auto-migration)
so the tool stays usable when the database is in a dirty migration state.
- `openDB` refactored to call `openDBRaw` then `db.Migrate`.
- Updated usage text.
All tests pass; `golangci-lint run ./...` clean.
### 2026-03-12 — Password change: self-service and admin reset
Added the ability for users to change their own password and for admins to
reset any human account's password.
**Two new REST endpoints:**
- `PUT /v1/auth/password` — self-service: authenticated user changes their own
password; requires `current_password` for verification; revokes all tokens
except the caller's current session on success.
- `PUT /v1/accounts/{id}/password` — admin reset: no current password needed;
revokes all active sessions for the target account.
**internal/model/model.go**
- Added `EventPasswordChanged = "password_changed"` audit event constant.
**internal/db/accounts.go**
- Added `RevokeAllUserTokensExcept(accountID, exceptJTI, reason)`: revokes all
non-expired tokens for an account except one specific JTI (used by the
self-service flow to preserve the caller's session).
**internal/server/server.go**
- `handleAdminSetPassword`: admin password reset handler; validates new
password, hashes with Argon2id, revokes all target tokens, writes audit event.
- `handleChangePassword`: self-service handler; verifies current password with
Argon2id (same lockout/timing path as login), hashes new password, revokes
all other tokens, clears failure counter.
- Both routes registered in `Handler()`.
**internal/ui/handlers_accounts.go**
- `handleAdminResetPassword`: web UI counterpart to the admin REST handler;
renders `password_reset_result` fragment on success.
**internal/ui/ui.go**
- `PUT /accounts/{id}/password` route registered with admin+CSRF middleware.
- `templates/fragments/password_reset_form.html` added to shared template list.
**web/templates/fragments/password_reset_form.html** (new)
- HTMX form fragment for the admin password reset UI.
- `password_reset_result` template shows a success flash message followed by
the reset form.
**web/templates/account_detail.html**
- Added "Reset Password" card (human accounts only) using the new fragment.
**cmd/mciasctl/main.go**
- `auth change-password`: self-service password change; both passwords always
prompted interactively (no flag form — prevents shell-history exposure).
- `account set-password -id UUID`: admin reset; new password always prompted
interactively (no flag form).
- `auth login`: `-password` flag removed; password always prompted.
- `account create`: `-password` flag removed; password always prompted for
human accounts.
- All passwords read via `term.ReadPassword` (terminal echo disabled); raw
byte slices zeroed after use.
**openapi.yaml + web/static/openapi.yaml**
- `PUT /v1/auth/password`: self-service endpoint documented (Auth tag).
- `PUT /v1/accounts/{id}/password`: admin reset documented (Admin — Accounts
tag).
**ARCHITECTURE.md**
- API endpoint tables updated with both new endpoints.
- New "Password Change Flows" section in §6 (Session Management) documents the
self-service and admin flows, their security properties, and differences.
All tests pass; golangci-lint clean.
### 2026-03-12 — Checkpoint: fix fieldalignment lint warning
**internal/policy/engine_wrapper.go**
- Reordered `PolicyRecord` fields: `*time.Time` pointer fields moved before
string fields, shrinking the GC pointer-scan bitmap from 56 to 40 bytes
(govet fieldalignment)
All tests pass; `golangci-lint run ./...` clean.
### 2026-03-12 — Add time-scoped policy rule expiry
Policy rules now support optional `not_before` and `expires_at` fields for
time-limited validity windows. Rules outside their validity window are
automatically excluded at cache-load time (`Engine.SetRules`).
**internal/db/migrations/000006_policy_rule_expiry.up.sql** (new)
- `ALTER TABLE policy_rules ADD COLUMN not_before TEXT DEFAULT NULL`
- `ALTER TABLE policy_rules ADD COLUMN expires_at TEXT DEFAULT NULL`
**internal/db/migrate.go**
- `LatestSchemaVersion` bumped from 5 to 6
**internal/model/model.go**
- Added `NotBefore *time.Time` and `ExpiresAt *time.Time` to `PolicyRuleRecord`
**internal/db/policy.go**
- `policyRuleCols` updated with `not_before, expires_at`
- `CreatePolicyRule`: new params `notBefore, expiresAt *time.Time`
- `UpdatePolicyRule`: new params `notBefore, expiresAt **time.Time` (double-pointer
for three-state semantics: nil=no change, non-nil→nil=clear, non-nil→value=set)
- `finishPolicyRuleScan`: extended to populate `NotBefore`/`ExpiresAt` via
`nullableTime()`
- Added `formatNullableTime(*time.Time) *string` helper
**internal/policy/engine_wrapper.go**
- Added `NotBefore *time.Time` and `ExpiresAt *time.Time` to `PolicyRecord`
- `SetRules`: filters out rules where `not_before > now()` or `expires_at <= now()`
after the existing `Enabled` check
**internal/server/handlers_policy.go**
- `policyRuleResponse`: added `not_before` and `expires_at` (RFC3339, omitempty)
- `createPolicyRuleRequest`: added `not_before` and `expires_at`
- `updatePolicyRuleRequest`: added `not_before`, `expires_at`,
`clear_not_before`, `clear_expires_at`
- `handleCreatePolicyRule`: parses/validates RFC3339 times; rejects
`expires_at <= not_before`
- `handleUpdatePolicyRule`: parses times, handles clear booleans via
double-pointer pattern
**internal/ui/**
- `PolicyRuleView`: added `NotBefore`, `ExpiresAt`, `IsExpired`, `IsPending`
- `policyRuleToView`: populates time fields and computes expired/pending status
- `handleCreatePolicyRule`: parses `datetime-local` form inputs for time fields
**web/templates/fragments/**
- `policy_form.html`: added `datetime-local` inputs for not_before and expires_at
- `policy_row.html`: shows time info and expired/scheduled badges
**cmd/mciasctl/main.go**
- `policyCreate`: added `-not-before` and `-expires-at` flags (RFC3339)
- `policyUpdate`: added `-not-before`, `-expires-at`, `-clear-not-before`,
`-clear-expires-at` flags
**openapi.yaml**
- `PolicyRule` schema: added `not_before` and `expires_at` (nullable date-time)
- Create request: added `not_before` and `expires_at`
- Update request: added `not_before`, `expires_at`, `clear_not_before`,
`clear_expires_at`
**Tests**
- `internal/db/policy_test.go`: 5 new tests — `WithExpiresAt`, `WithNotBefore`,
`WithBothTimes`, `SetExpiresAt`, `ClearExpiresAt`; all existing tests updated
with new `CreatePolicyRule`/`UpdatePolicyRule` signatures
- `internal/policy/engine_test.go`: 4 new tests — `SkipsExpiredRule`,
`SkipsNotYetActiveRule`, `IncludesActiveWindowRule`, `NilTimesAlwaysActive`
**ARCHITECTURE.md**
- Schema: added `not_before` and `expires_at` columns to `policy_rules` DDL
- Added Scenario D (time-scoped access) to §20
All new and existing policy tests pass; no new lint warnings.
### 2026-03-12 — Integrate golang-migrate for database migrations
**internal/db/migrations/** (new directory — 5 embedded SQL files)
- `000001_initial_schema.up.sql` — full initial schema (verbatim from migration 1)
- `000002_master_key_salt.up.sql` — adds `master_key_salt` to server_config
- `000003_failed_logins.up.sql``failed_logins` table for brute-force lockout
- `000004_tags_and_policy.up.sql``account_tags` and `policy_rules` tables
- `000005_pgcred_access.up.sql``owner_id` column + `pg_credential_access` table
- Files are embedded at compile time via `//go:embed migrations/*.sql`; no
runtime filesystem access is needed
**internal/db/migrate.go** (rewritten)
- Removed hand-rolled `migration` struct and `migrations []migration` slice
- Uses `github.com/golang-migrate/migrate/v4` with the `database/sqlite`
driver (modernc.org/sqlite, pure Go, no CGO) and `source/iofs` for embedded
SQL files
- `LatestSchemaVersion` changed from `var` to `const = 5`
- `Migrate(db *DB) error`: compatibility shim reads legacy `schema_version`
table; if version > 0, calls `m.Force(legacyVersion)` before `m.Up()` so
existing databases are not re-migrated. Returns nil on ErrNoChange.
- `SchemaVersion(db *DB) (int, error)`: delegates to `m.Version()`; returns 0
on ErrNilVersion
- `newMigrate(*DB)`: opens a **dedicated** `*sql.DB` for the migrator so that
`m.Close()` (which closes the underlying connection) does not affect the
caller's shared connection
- `legacySchemaVersion(*DB)`: reads old schema_version table; returns 0 if
absent (fresh DB or already on golang-migrate only)
**internal/db/db.go**
- Added `path string` field to `DB` struct for the migrator's dedicated
connection
- `Open(":memory:")` now translates to a named shared-cache URI
`file:mcias_N?mode=memory&cache=shared` (N is atomic counter) so the
migration runner can open a second connection to the same in-memory database
without sharing the `*sql.DB` handle that golang-migrate will close
**go.mod / go.sum**
- Added `github.com/golang-migrate/migrate/v4 v4.19.1` (direct)
- Transitive: `hashicorp/errwrap`, `hashicorp/go-multierror`,
`go.uber.org/atomic`
All callers (`cmd/mciassrv`, `cmd/mciasdb`, all test helpers) continue to call
`db.Open(path)` and `db.Migrate(database)` unchanged.
All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
### 2026-03-12 — UI: pgcreds create button; show logged-in user
**web/templates/pgcreds.html**
- "New Credentials" card is now always rendered; an "Add Credentials" toggle
button reveals the create form (hidden by default). When all system accounts
already have credentials, a message is shown instead of the form. Previously
the entire card was hidden when `UncredentialedAccounts` was empty.
**internal/ui/ui.go**
- Added `ActorName string` field to `PageData` (embedded in every page view struct)
- Added `actorName(r *http.Request) string` helper — resolves username from JWT
claims via a DB lookup; returns `""` if unauthenticated
**internal/ui/handlers_{accounts,audit,dashboard,policy}.go**
- All full-page `PageData` constructors now pass `ActorName: u.actorName(r)`
**web/templates/base.html**
- Nav bar renders the actor's username as a muted label immediately before the
Logout button when logged in
**web/static/style.css**
- Added `.nav-actor` rule (muted grey, 0.85rem) for the username label
All tests pass (`go test ./...`); `golangci-lint run ./...` clean.
### 2026-03-12 — PG credentials create form on /pgcreds page
**internal/ui/handlers_accounts.go**
- `handlePGCredsList`: extended to build `UncredentialedAccounts` — system
accounts that have no credentials yet, passed to the template for the create
form; filters from `ListAccounts()` by type and excludes accounts already in
the accessible-credentials set
- `handleCreatePGCreds`: `POST /pgcreds` — validates selected account UUID
(must be a system account), host, port, database, username, password;
encrypts password with AES-256-GCM; calls `WritePGCredentials` then
`SetPGCredentialOwner`; writes `EventPGCredUpdated` audit event; redirects
to `GET /pgcreds` on success
**internal/ui/ui.go**
- Registered `POST /pgcreds` route
- Added `UncredentialedAccounts []*model.Account` field to `PGCredsData`
**web/templates/pgcreds.html**
- New "New Credentials" card shown when `UncredentialedAccounts` is non-empty;
contains a plain POST form (no HTMX, redirect on success) with:
- Service Account dropdown populated from `UncredentialedAccounts`
- Host / Port / Database / Username / Password inputs
- CSRF token hidden field
All tests pass (`go test ./...`); `golangci-lint run ./...` clean.
### 2026-03-12 — PG credentials access grants UI
**internal/ui/handlers_accounts.go**
- `handleGrantPGCredAccess`: `POST /accounts/{id}/pgcreds/access` — grants a
nominated account read access to the credential set; ownership enforced
server-side by comparing stored `owner_id` with the logged-in actor;
grantee resolved via UUID lookup (not raw ID); writes
`EventPGCredAccessGranted` audit event; re-renders `pgcreds_form` fragment
- `handleRevokePGCredAccess`: `DELETE /accounts/{id}/pgcreds/access/{grantee}`
— removes a specific grantee's read access; same ownership check as grant;
writes `EventPGCredAccessRevoked` audit event; re-renders fragment
- `handlePGCredsList`: `GET /pgcreds` — lists all pg_credentials accessible to
the currently logged-in user (owned + explicitly granted)
**internal/ui/ui.go**
- Registered three new routes: `POST /accounts/{id}/pgcreds/access`,
`DELETE /accounts/{id}/pgcreds/access/{grantee}`, `GET /pgcreds`
- Added `pgcreds` to the page template map (renders `pgcreds.html`)
- Added `isPGCredOwner(*int64, *model.PGCredential) bool` template function
— nil-safe ownership check used in `pgcreds_form` to gate owner-only controls
- Added `derefInt64(*int64) int64` template function (nil-safe dereference)
**internal/model/model.go**
- Added `ServiceAccountUUID string` field to `PGCredential` — populated by
list queries so the PG creds list page can link to the account detail page
**internal/db/pgcred_access.go**
- `ListAccessiblePGCreds`: extended SELECT to also fetch `a.uuid`; updated
`scanPGCredWithUsername` to populate `ServiceAccountUUID`
**web/templates/fragments/pgcreds_form.html**
- Owner sees a collapsible "Update credentials" `<details>` block; non-owners
and grantees see metadata read-only
- Non-owners who haven't yet created a credential see the full create form
(first save sets them as owner)
- New "Access Grants" section below the credential metadata:
- Table listing all grantees with username and grant timestamp
- Revoke button (DELETE HTMX, `hx-confirm`) — owner only
- "Grant Access" dropdown form (POST HTMX) — owner only, populated with
all accounts
**web/templates/pgcreds.html** (new page)
- Lists all accessible credentials in a table: service account, host:port,
database, username, updated-at, link to account detail page
- Falls back to "No Postgres credentials accessible" when list is empty
**web/templates/base.html**
- Added "PG Creds" nav link pointing to `/pgcreds`
All tests pass (`go test ./...`); `golangci-lint run ./...` clean.
### 2026-03-11 — Postgres Credentials UI + Policy/Tags UI completion
**internal/ui/**
@@ -84,7 +494,7 @@ All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
- [x] Phase 6: mciasdb — direct SQLite maintenance tool
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
- [ ] Phase 9: Client libraries (Go, Rust, Common Lisp, Python) — designed in ARCHITECTURE.md §19 but not yet implemented; `clients/` directory does not exist
- [x] Phase 10: Policy engine — ABAC with machine/service gating
---
### 2026-03-11 — Phase 10: Policy engine (ABAC + machine/service gating)
@@ -96,9 +506,10 @@ All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
- `engine.go``Evaluate(input, operatorRules) (Effect, *Rule)`: pure function;
merges operator rules with default rules, sorts by priority, deny-wins,
then first allow, then default-deny
- `defaults.go`6 compiled-in rules (IDs -1 to -6, Priority 0): admin
wildcard, self-service logout/renew, self-service TOTP, system account own
pgcreds, system account own service token, public login/validate endpoints
- `defaults.go`7 compiled-in rules (IDs -1 to -7, Priority 0): admin
wildcard, self-service logout/renew, self-service TOTP, self-service password
change (human only), system account own pgcreds, system account own service
token, public login/validate endpoints
- `engine_wrapper.go``Engine` struct with `sync.RWMutex`; `SetRules()`
decodes DB records; `PolicyRecord` type avoids import cycle
- `engine_test.go` — 11 tests: DefaultDeny, AdminWildcard, SelfService*,
@@ -188,44 +599,15 @@ All tests pass; `go test ./...` clean; `golangci-lint run ./...` clean.
All 5 packages pass `go test ./...`; `golangci-lint run ./...` clean.
### 2026-03-11 — Phase 9: Client libraries
### 2026-03-11 — Phase 9: Client libraries (DESIGNED, NOT IMPLEMENTED)
**clients/testdata/** — shared JSON fixtures
- login_response.json, account_response.json, accounts_list_response.json
- validate_token_response.json, public_key_response.json, pgcreds_response.json
- error_response.json, roles_response.json
**clients/go/** — Go client library
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`; package `mciasgoclient`
- Typed errors: `MciasAuthError`, `MciasForbiddenError`, `MciasNotFoundError`,
`MciasInputError`, `MciasConflictError`, `MciasServerError`
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
- Token state guarded by `sync.RWMutex` for concurrent safety
- JSON decoded with `DisallowUnknownFields` on all responses
- 25 tests in `client_test.go`; all pass with `go test -race`
**clients/rust/** — Rust async client library
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
- 23 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
**clients/lisp/** — Common Lisp client library
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
- CLOS class `mcias-client`; plain functions for all operations
- Conditions: `mcias-error` base + 6 typed subclasses
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
- 37 fiveam checks; all pass on SBCL 2.6.1
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
to `t`/`nil` before returning
**clients/python/** — Python 3.11+ client library
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
- `Client` context manager; `py.typed` marker; all symbols fully annotated
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
- 32 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
**NOTE:** The client libraries described in ARCHITECTURE.md §19 were designed
but never committed to the repository. The `clients/` directory does not exist.
Only `test/mock/mockserver.go` was implemented. The designs remain in
ARCHITECTURE.md for future implementation.
**test/mock/mockserver.go** — Go in-memory mock server
- `Server` struct with `sync.RWMutex`; used by Go client integration test
- `Server` struct with `sync.RWMutex`; used for Go integration tests
- `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use
---

View File

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

View File

@@ -2,7 +2,8 @@
MCIAS is a self-hosted SSO and IAM service for personal projects.
It provides authentication (JWT/Ed25519), account management, TOTP, and
Postgres credential storage over a REST API (HTTPS) and a gRPC API (TLS).
Postgres credential storage over a REST API (HTTPS), a gRPC API (TLS),
and an HTMX-based web management UI.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and
[PROJECT_PLAN.md](PROJECT_PLAN.md) for the implementation roadmap.
@@ -148,7 +149,7 @@ MCIAS_MASTER_PASSPHRASE=your-passphrase mciassrv -config /etc/mcias/mcias.conf
### 6. Verify
```sh
curl -k https://localhost:8443/v1/health
curl -k https://mcias.metacircular.net:8443/v1/health
# {"status":"ok"}
```
@@ -172,12 +173,12 @@ make docker # build Docker image mcias:<version>
## Admin CLI (mciasctl)
```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)
export MCIAS_TOKEN=$TOKEN
mciasctl -server https://localhost:8443 account list
mciasctl account create -username alice -password s3cr3t
mciasctl -server https://mcias.metacircular.net:8443 account list
mciasctl account create -username alice # password prompted interactively
mciasctl role set -id $UUID -roles admin
mciasctl token issue -id $SYSTEM_UUID
mciasctl pgcreds set -id $UUID -host db.example.com -port 5432 \
@@ -241,6 +242,24 @@ See `man mciasgrpcctl` and [ARCHITECTURE.md](ARCHITECTURE.md) §17.
---
## Web Management UI
mciassrv includes a built-in web interface for day-to-day administration.
After starting the server, navigate to `https://mcias.metacircular.net:8443/login` and
log in with an admin account.
The UI provides:
- **Dashboard** — account summary overview
- **Accounts** — list, create, update, delete accounts; manage roles and tags
- **PG Credentials** — view, create, and manage Postgres credential access grants
- **Policies** — create and manage ABAC policy rules
- **Audit** — browse the audit log
Sessions use `HttpOnly; Secure; SameSite=Strict` cookies with CSRF protection.
See [ARCHITECTURE.md](ARCHITECTURE.md) §8 (Web Management UI) for design details.
---
## Deploying with Docker
```sh
@@ -259,7 +278,7 @@ docker run -d \
-p 9443:9443 \
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.

View File

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

View File

@@ -1,8 +1,9 @@
// Package mciasgoclient provides a thread-safe Go client for the MCIAS REST API.
// Package mcias provides a thread-safe Go client for the MCIAS REST API.
//
// Security: bearer tokens are stored under a sync.RWMutex and are never written
// to logs or included in error messages anywhere in this package.
package mciasgoclient
package mcias
import (
"bytes"
"crypto/tls"
@@ -15,32 +16,43 @@ import (
"strings"
"sync"
)
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
// MciasError is the base error type for all MCIAS client errors.
type MciasError struct {
StatusCode int
Message 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.
type MciasAuthError struct{ MciasError }
// MciasForbiddenError is returned for 403 Forbidden responses.
type MciasForbiddenError struct{ MciasError }
// MciasNotFoundError is returned for 404 Not Found responses.
type MciasNotFoundError struct{ MciasError }
// MciasInputError is returned for 400 Bad Request responses.
type MciasInputError struct{ MciasError }
// MciasConflictError is returned for 409 Conflict responses.
type MciasConflictError struct{ MciasError }
// MciasServerError is returned for 5xx responses.
type MciasServerError struct{ MciasError }
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
// Account represents a user or service account.
type Account struct {
ID string `json:"id"`
@@ -51,6 +63,7 @@ type Account struct {
UpdatedAt string `json:"updated_at"`
TOTPEnabled bool `json:"totp_enabled"`
}
// PublicKey represents the server's Ed25519 public key in JWK format.
type PublicKey struct {
Kty string `json:"kty"`
@@ -59,6 +72,7 @@ type PublicKey struct {
Use string `json:"use,omitempty"`
Alg string `json:"alg,omitempty"`
}
// TokenClaims is returned by ValidateToken.
type TokenClaims struct {
Valid bool `json:"valid"`
@@ -66,6 +80,7 @@ type TokenClaims struct {
Roles []string `json:"roles,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// PGCreds holds Postgres connection credentials.
type PGCreds struct {
Host string `json:"host"`
@@ -74,9 +89,94 @@ type PGCreds struct {
Username string `json:"username"`
Password string `json:"password"`
}
// TOTPEnrollResponse is returned by EnrollTOTP.
type TOTPEnrollResponse struct {
Secret string `json:"secret"`
OTPAuthURI string `json:"otpauth_uri"`
}
// AuditEvent is a single entry in the audit log.
type AuditEvent struct {
ID int `json:"id"`
EventType string `json:"event_type"`
EventTime string `json:"event_time"`
ActorID string `json:"actor_id,omitempty"`
TargetID string `json:"target_id,omitempty"`
IPAddress string `json:"ip_address"`
Details string `json:"details,omitempty"`
}
// AuditListResponse is returned by ListAudit.
type AuditListResponse struct {
Events []AuditEvent `json:"events"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// AuditFilter holds optional filter parameters for ListAudit.
type AuditFilter struct {
Limit int
Offset int
EventType string
ActorID string
}
// PolicyRuleBody holds the match conditions and effect of a policy rule.
// All fields except Effect are optional; an omitted field acts as a wildcard.
type PolicyRuleBody struct {
Effect string `json:"effect"`
Roles []string `json:"roles,omitempty"`
AccountTypes []string `json:"account_types,omitempty"`
SubjectUUID string `json:"subject_uuid,omitempty"`
Actions []string `json:"actions,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"`
ServiceNames []string `json:"service_names,omitempty"`
RequiredTags []string `json:"required_tags,omitempty"`
}
// PolicyRule is a complete operator-defined policy rule as returned by the API.
type PolicyRule struct {
ID int `json:"id"`
Priority int `json:"priority"`
Description string `json:"description"`
Rule PolicyRuleBody `json:"rule"`
Enabled bool `json:"enabled"`
NotBefore string `json:"not_before,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CreatePolicyRuleRequest holds the parameters for creating a policy rule.
type CreatePolicyRuleRequest struct {
Description string `json:"description"`
Priority int `json:"priority,omitempty"`
Rule PolicyRuleBody `json:"rule"`
NotBefore string `json:"not_before,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// UpdatePolicyRuleRequest holds the parameters for updating a policy rule.
// All fields are optional; omitted fields are left unchanged.
// Set ClearNotBefore or ClearExpiresAt to true to remove those constraints.
type UpdatePolicyRuleRequest struct {
Description string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Rule *PolicyRuleBody `json:"rule,omitempty"`
NotBefore string `json:"not_before,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
ClearNotBefore bool `json:"clear_not_before,omitempty"`
ClearExpiresAt bool `json:"clear_expires_at,omitempty"`
}
// ---------------------------------------------------------------------------
// Options and Client struct
// ---------------------------------------------------------------------------
// Options configures the MCIAS client.
type Options struct {
// CACertPath is an optional path to a PEM-encoded CA certificate for TLS
@@ -85,6 +185,7 @@ type Options struct {
// Token is an optional pre-existing bearer token.
Token string
}
// Client is a thread-safe MCIAS REST API client.
// Security: the bearer token is guarded by a sync.RWMutex; it is never
// written to logs or included in error messages in this library.
@@ -94,9 +195,11 @@ type Client struct {
mu sync.RWMutex
token string
}
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
// New creates a new Client for the given serverURL.
// TLS 1.2 is the minimum version enforced on all connections.
// If opts.CACertPath is set, that CA certificate is added to the trust pool.
@@ -126,20 +229,24 @@ func New(serverURL string, opts Options) (*Client, error) {
}
return c, nil
}
// Token returns the current bearer token (empty string if not logged in).
func (c *Client) Token() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
func (c *Client) setToken(tok string) {
c.mu.Lock()
defer c.mu.Unlock()
c.token = tok
}
func (c *Client) do(method, path string, body interface{}, out interface{}) error {
var reqBody io.Reader
if body != nil {
@@ -195,6 +302,7 @@ func (c *Client) do(method, path string, body interface{}, out interface{}) erro
}
return nil
}
func makeError(status int, msg string) error {
base := MciasError{StatusCode: status, Message: msg}
switch {
@@ -212,13 +320,16 @@ func makeError(status int, msg string) error {
return &MciasServerError{base}
}
}
// ---------------------------------------------------------------------------
// API methods
// API methods — Public
// ---------------------------------------------------------------------------
// Health calls GET /v1/health. Returns nil if the server is healthy.
func (c *Client) Health() error {
return c.do(http.MethodGet, "/v1/health", nil, nil)
}
// GetPublicKey returns the server's Ed25519 public key in JWK format.
func (c *Client) GetPublicKey() (*PublicKey, error) {
var pk PublicKey
@@ -227,6 +338,7 @@ func (c *Client) GetPublicKey() (*PublicKey, error) {
}
return &pk, nil
}
// Login authenticates with username and password. On success the token is
// stored in the Client and returned along with the expiry timestamp.
// totpCode may be empty for accounts without TOTP.
@@ -245,6 +357,23 @@ func (c *Client) Login(username, password, totpCode string) (token, expiresAt st
c.setToken(resp.Token)
return resp.Token, resp.ExpiresAt, nil
}
// ValidateToken validates a token string against the server.
// Returns claims; Valid is false (not an error) if the token is expired or
// revoked.
func (c *Client) ValidateToken(token string) (*TokenClaims, error) {
var claims TokenClaims
if err := c.do(http.MethodPost, "/v1/token/validate",
map[string]string{"token": token}, &claims); err != nil {
return nil, err
}
return &claims, nil
}
// ---------------------------------------------------------------------------
// API methods — Authenticated
// ---------------------------------------------------------------------------
// Logout revokes the current token on the server and clears it from the client.
func (c *Client) Logout() error {
if err := c.do(http.MethodPost, "/v1/auth/logout", nil, nil); err != nil {
@@ -253,6 +382,7 @@ func (c *Client) Logout() error {
c.setToken("")
return nil
}
// RenewToken exchanges the current token for a fresh one.
// The new token is stored in the client and returned.
func (c *Client) RenewToken() (token, expiresAt string, err error) {
@@ -266,17 +396,69 @@ func (c *Client) RenewToken() (token, expiresAt string, err error) {
c.setToken(resp.Token)
return resp.Token, resp.ExpiresAt, nil
}
// ValidateToken validates a token string against the server.
// Returns claims; Valid is false (not an error) if the token is expired or
// revoked.
func (c *Client) ValidateToken(token string) (*TokenClaims, error) {
var claims TokenClaims
if err := c.do(http.MethodPost, "/v1/token/validate",
map[string]string{"token": token}, &claims); err != nil {
// EnrollTOTP begins TOTP enrollment for the authenticated account.
// Returns a base32 secret and an otpauth:// URI for QR-code generation.
// The secret is shown once; it is not retrievable after this call.
// TOTP is not enforced until confirmed via ConfirmTOTP.
//
// Security (SEC-01): the current password is required to prevent a stolen
// session token from being used to enroll attacker-controlled TOTP.
func (c *Client) EnrollTOTP(password string) (*TOTPEnrollResponse, error) {
var resp TOTPEnrollResponse
body := struct {
Password string `json:"password"`
}{Password: password}
if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", body, &resp); err != nil {
return nil, err
}
return &claims, nil
return &resp, nil
}
// ConfirmTOTP completes TOTP enrollment by verifying the current code against
// the pending secret. On success, TOTP becomes required for all future logins.
func (c *Client) ConfirmTOTP(code string) error {
return c.do(http.MethodPost, "/v1/auth/totp/confirm",
map[string]string{"code": code}, nil)
}
// ChangePassword changes the password of the currently authenticated human
// account. currentPassword is required to prevent token-theft attacks.
// On success, all active sessions except the caller's are revoked.
//
// Security: both passwords are transmitted over TLS only; the server verifies
// currentPassword with constant-time comparison before accepting the change.
func (c *Client) ChangePassword(currentPassword, newPassword string) error {
return c.do(http.MethodPut, "/v1/auth/password", map[string]string{
"current_password": currentPassword,
"new_password": newPassword,
}, nil)
}
// ---------------------------------------------------------------------------
// API methods — Admin: Auth
// ---------------------------------------------------------------------------
// RemoveTOTP clears TOTP enrollment for the given account (admin).
// Use for account recovery when a user has lost their TOTP device.
func (c *Client) RemoveTOTP(accountID string) error {
return c.do(http.MethodDelete, "/v1/auth/totp",
map[string]string{"account_id": accountID}, nil)
}
// ---------------------------------------------------------------------------
// API methods — Admin: Accounts
// ---------------------------------------------------------------------------
// ListAccounts returns all accounts. Requires admin role.
func (c *Client) ListAccounts() ([]Account, error) {
var accounts []Account
if err := c.do(http.MethodGet, "/v1/accounts", nil, &accounts); err != nil {
return nil, err
}
return accounts, nil
}
// CreateAccount creates a new account. accountType is "human" or "system".
// password is required for human accounts.
func (c *Client) CreateAccount(username, accountType, password string) (*Account, error) {
@@ -293,14 +475,7 @@ func (c *Client) CreateAccount(username, accountType, password string) (*Account
}
return &acct, nil
}
// ListAccounts returns all accounts. Requires admin role.
func (c *Client) ListAccounts() ([]Account, error) {
var accounts []Account
if err := c.do(http.MethodGet, "/v1/accounts", nil, &accounts); err != nil {
return nil, err
}
return accounts, nil
}
// GetAccount returns the account with the given ID. Requires admin role.
func (c *Client) GetAccount(id string) (*Account, error) {
var acct Account
@@ -309,23 +484,22 @@ func (c *Client) GetAccount(id string) (*Account, error) {
}
return &acct, nil
}
// UpdateAccount updates mutable account fields. Requires admin role.
// Pass an empty string for fields that should not be changed.
func (c *Client) UpdateAccount(id, status string) (*Account, error) {
// UpdateAccount updates mutable account fields (currently only status).
// Requires admin role. Returns nil on success (HTTP 204).
func (c *Client) UpdateAccount(id, status string) error {
req := map[string]string{}
if status != "" {
req["status"] = status
}
var acct Account
if err := c.do(http.MethodPatch, "/v1/accounts/"+id, req, &acct); err != nil {
return nil, err
}
return &acct, nil
return c.do(http.MethodPatch, "/v1/accounts/"+id, req, nil)
}
// DeleteAccount soft-deletes the account with the given ID. Requires admin.
func (c *Client) DeleteAccount(id string) error {
return c.do(http.MethodDelete, "/v1/accounts/"+id, nil, nil)
}
// GetRoles returns the roles for accountID. Requires admin.
func (c *Client) GetRoles(accountID string) ([]string, error) {
var resp struct {
@@ -336,11 +510,49 @@ func (c *Client) GetRoles(accountID string) ([]string, error) {
}
return resp.Roles, nil
}
// SetRoles replaces the role set for accountID. Requires admin.
func (c *Client) SetRoles(accountID string, roles []string) error {
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/roles",
map[string][]string{"roles": roles}, nil)
}
// AdminSetPassword resets a human account's password without requiring the
// current password. Requires admin. All active sessions for the target account
// are revoked on success.
func (c *Client) AdminSetPassword(accountID, newPassword string) error {
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/password",
map[string]string{"new_password": newPassword}, nil)
}
// GetAccountTags returns the current tag set for an account. Requires admin.
func (c *Client) GetAccountTags(accountID string) ([]string, error) {
var resp struct {
Tags []string `json:"tags"`
}
if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/tags", nil, &resp); err != nil {
return nil, err
}
return resp.Tags, nil
}
// SetAccountTags replaces the full tag set for an account atomically.
// Pass an empty slice to clear all tags. Requires admin.
func (c *Client) SetAccountTags(accountID string, tags []string) ([]string, error) {
var resp struct {
Tags []string `json:"tags"`
}
if err := c.do(http.MethodPut, "/v1/accounts/"+accountID+"/tags",
map[string][]string{"tags": tags}, &resp); err != nil {
return nil, err
}
return resp.Tags, nil
}
// ---------------------------------------------------------------------------
// API methods — Admin: Tokens
// ---------------------------------------------------------------------------
// IssueServiceToken issues a long-lived token for a system account. Requires admin.
func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, err error) {
var resp struct {
@@ -353,10 +565,16 @@ func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, e
}
return resp.Token, resp.ExpiresAt, nil
}
// RevokeToken revokes a token by JTI. Requires admin.
func (c *Client) RevokeToken(jti string) error {
return c.do(http.MethodDelete, "/v1/token/"+jti, nil, nil)
}
// ---------------------------------------------------------------------------
// API methods — Admin: Credentials
// ---------------------------------------------------------------------------
// GetPGCreds returns Postgres credentials for accountID. Requires admin.
func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
var creds PGCreds
@@ -365,6 +583,7 @@ func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
}
return &creds, nil
}
// SetPGCreds stores Postgres credentials for accountID. Requires admin.
// The password is sent over TLS and encrypted at rest server-side.
func (c *Client) SetPGCreds(accountID, host string, port int, database, username, password string) error {
@@ -376,3 +595,78 @@ func (c *Client) SetPGCreds(accountID, host string, port int, database, username
"password": password,
}, nil)
}
// ---------------------------------------------------------------------------
// API methods — Admin: Audit
// ---------------------------------------------------------------------------
// ListAudit retrieves audit log entries, newest first. Requires admin.
// f may be zero-valued to use defaults (limit=50, offset=0, no filter).
func (c *Client) ListAudit(f AuditFilter) (*AuditListResponse, error) {
path := "/v1/audit?"
if f.Limit > 0 {
path += fmt.Sprintf("limit=%d&", f.Limit)
}
if f.Offset > 0 {
path += fmt.Sprintf("offset=%d&", f.Offset)
}
if f.EventType != "" {
path += fmt.Sprintf("event_type=%s&", f.EventType)
}
if f.ActorID != "" {
path += fmt.Sprintf("actor_id=%s&", f.ActorID)
}
path = strings.TrimRight(path, "&?")
var resp AuditListResponse
if err := c.do(http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// ---------------------------------------------------------------------------
// API methods — Admin: Policy
// ---------------------------------------------------------------------------
// ListPolicyRules returns all operator-defined policy rules ordered by
// priority (ascending). Requires admin.
func (c *Client) ListPolicyRules() ([]PolicyRule, error) {
var rules []PolicyRule
if err := c.do(http.MethodGet, "/v1/policy/rules", nil, &rules); err != nil {
return nil, err
}
return rules, nil
}
// CreatePolicyRule creates a new policy rule. Requires admin.
func (c *Client) CreatePolicyRule(req CreatePolicyRuleRequest) (*PolicyRule, error) {
var rule PolicyRule
if err := c.do(http.MethodPost, "/v1/policy/rules", req, &rule); err != nil {
return nil, err
}
return &rule, nil
}
// GetPolicyRule returns a single policy rule by integer ID. Requires admin.
func (c *Client) GetPolicyRule(id int) (*PolicyRule, error) {
var rule PolicyRule
if err := c.do(http.MethodGet, fmt.Sprintf("/v1/policy/rules/%d", id), nil, &rule); err != nil {
return nil, err
}
return &rule, nil
}
// UpdatePolicyRule updates one or more fields of an existing policy rule.
// Requires admin.
func (c *Client) UpdatePolicyRule(id int, req UpdatePolicyRuleRequest) (*PolicyRule, error) {
var rule PolicyRule
if err := c.do(http.MethodPatch, fmt.Sprintf("/v1/policy/rules/%d", id), req, &rule); err != nil {
return nil, err
}
return &rule, nil
}
// DeletePolicyRule permanently deletes a policy rule. Requires admin.
func (c *Client) DeletePolicyRule(id int) error {
return c.do(http.MethodDelete, fmt.Sprintf("/v1/policy/rules/%d", id), nil, nil)
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,10 @@ from ._errors import (
MciasForbiddenError,
MciasInputError,
MciasNotFoundError,
MciasRateLimitError,
MciasServerError,
)
from ._models import Account, PGCreds, PublicKey, TokenClaims
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
__all__ = [
"Client",
@@ -19,9 +20,12 @@ __all__ = [
"MciasNotFoundError",
"MciasInputError",
"MciasConflictError",
"MciasRateLimitError",
"MciasServerError",
"Account",
"PublicKey",
"TokenClaims",
"PGCreds",
"PolicyRule",
"RuleBody",
]

View File

@@ -8,7 +8,7 @@ from typing import Any
import httpx
from ._errors import raise_for_status
from ._models import Account, PGCreds, PublicKey, TokenClaims
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
class Client:
@@ -76,6 +76,29 @@ class Client:
if status == 204 or not response.content:
return None
return response.json() # type: ignore[no-any-return]
def _request_list(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Send a request that returns a JSON array at the top level."""
url = f"{self._base_url}{path}"
headers: dict[str, str] = {}
if self.token is not None:
headers["Authorization"] = f"Bearer {self.token}"
response = self._http.request(method, url, json=json, headers=headers)
status = response.status_code
if status >= 400:
try:
body = response.json()
message = str(body.get("error", response.text))
except Exception:
message = response.text
raise_for_status(status, message)
return response.json() # type: ignore[no-any-return]
# ── Public ────────────────────────────────────────────────────────────────
def health(self) -> None:
"""GET /v1/health — liveness check."""
self._request("GET", "/v1/health")
@@ -105,6 +128,12 @@ class Client:
expires_at = str(data["expires_at"])
self.token = token
return token, expires_at
def validate_token(self, token: str) -> TokenClaims:
"""POST /v1/token/validate — check whether a token is valid."""
data = self._request("POST", "/v1/token/validate", json={"token": token})
assert data is not None
return TokenClaims.from_dict(data)
# ── Authenticated ──────────────────────────────────────────────────────────
def logout(self) -> None:
"""POST /v1/auth/logout — invalidate the current token."""
self._request("POST", "/v1/auth/logout")
@@ -119,11 +148,49 @@ class Client:
expires_at = str(data["expires_at"])
self.token = token
return token, expires_at
def validate_token(self, token: str) -> TokenClaims:
"""POST /v1/token/validate — check whether a token is valid."""
data = self._request("POST", "/v1/token/validate", json={"token": token})
def enroll_totp(self, password: str) -> tuple[str, str]:
"""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.
"""
data = self._request("POST", "/v1/auth/totp/enroll", json={"password": password})
assert data is not None
return TokenClaims.from_dict(data)
return str(data["secret"]), str(data["otpauth_uri"])
def confirm_totp(self, code: str) -> None:
"""POST /v1/auth/totp/confirm — confirm TOTP enrollment with a code."""
self._request("POST", "/v1/auth/totp/confirm", json={"code": code})
def change_password(self, current_password: str, new_password: str) -> None:
"""PUT /v1/auth/password — change own password (self-service)."""
self._request(
"PUT",
"/v1/auth/password",
json={"current_password": current_password, "new_password": new_password},
)
# ── Admin — Auth ──────────────────────────────────────────────────────────
def remove_totp(self, account_id: str) -> None:
"""DELETE /v1/auth/totp — remove TOTP from an account (admin)."""
self._request("DELETE", "/v1/auth/totp", json={"account_id": account_id})
# ── Admin — Tokens ────────────────────────────────────────────────────────
def issue_service_token(self, account_id: str) -> tuple[str, str]:
"""POST /v1/token/issue — issue a long-lived service token (admin).
Returns (token, expires_at).
"""
data = self._request("POST", "/v1/token/issue", json={"account_id": account_id})
assert data is not None
return str(data["token"]), str(data["expires_at"])
def revoke_token(self, jti: str) -> None:
"""DELETE /v1/token/{jti} — revoke a token by JTI (admin)."""
self._request("DELETE", f"/v1/token/{jti}")
# ── Admin — Accounts ──────────────────────────────────────────────────────
def list_accounts(self) -> list[Account]:
"""GET /v1/accounts — list all accounts (admin).
The API returns a JSON array directly (no wrapper object).
"""
items = self._request_list("GET", "/v1/accounts")
return [Account.from_dict(a) for a in items]
def create_account(
self,
username: str,
@@ -131,7 +198,7 @@ class Client:
*,
password: str | None = None,
) -> Account:
"""POST /v1/accounts — create a new account."""
"""POST /v1/accounts — create a new account (admin)."""
payload: dict[str, Any] = {
"username": username,
"account_type": account_type,
@@ -141,14 +208,8 @@ class Client:
data = self._request("POST", "/v1/accounts", json=payload)
assert data is not None
return Account.from_dict(data)
def list_accounts(self) -> list[Account]:
"""GET /v1/accounts — list all accounts."""
data = self._request("GET", "/v1/accounts")
assert data is not None
accounts_raw = data.get("accounts") or []
return [Account.from_dict(a) for a in accounts_raw]
def get_account(self, account_id: str) -> Account:
"""GET /v1/accounts/{id} — retrieve a single account."""
"""GET /v1/accounts/{id} — retrieve a single account (admin)."""
data = self._request("GET", f"/v1/accounts/{account_id}")
assert data is not None
return Account.from_dict(data)
@@ -157,42 +218,40 @@ class Client:
account_id: str,
*,
status: str | None = None,
) -> Account:
"""PATCH /v1/accounts/{id} — update account fields."""
) -> None:
"""PATCH /v1/accounts/{id} — update account fields (admin).
Currently only `status` is patchable. Returns None (204 No Content).
"""
payload: dict[str, Any] = {}
if status is not None:
payload["status"] = status
data = self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
assert data is not None
return Account.from_dict(data)
self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
def delete_account(self, account_id: str) -> None:
"""DELETE /v1/accounts/{id}permanently remove an account."""
"""DELETE /v1/accounts/{id}soft-delete an account (admin)."""
self._request("DELETE", f"/v1/accounts/{account_id}")
def get_roles(self, account_id: str) -> list[str]:
"""GET /v1/accounts/{id}/roles — list roles for an account."""
"""GET /v1/accounts/{id}/roles — list roles for an account (admin)."""
data = self._request("GET", f"/v1/accounts/{account_id}/roles")
assert data is not None
roles_raw = data.get("roles") or []
return [str(r) for r in roles_raw]
def set_roles(self, account_id: str, roles: list[str]) -> None:
"""PUT /v1/accounts/{id}/roles — replace the full role set."""
"""PUT /v1/accounts/{id}/roles — replace the full role set (admin)."""
self._request(
"PUT",
f"/v1/accounts/{account_id}/roles",
json={"roles": roles},
)
def issue_service_token(self, account_id: str) -> tuple[str, str]:
"""POST /v1/accounts/{id}/token — issue a long-lived service token.
Returns (token, expires_at).
"""
data = self._request("POST", f"/v1/accounts/{account_id}/token")
assert data is not None
return str(data["token"]), str(data["expires_at"])
def revoke_token(self, jti: str) -> None:
"""DELETE /v1/token/{jti} — revoke a token by JTI."""
self._request("DELETE", f"/v1/token/{jti}")
def admin_set_password(self, account_id: str, new_password: str) -> None:
"""PUT /v1/accounts/{id}/password — reset a password without the old one (admin)."""
self._request(
"PUT",
f"/v1/accounts/{account_id}/password",
json={"new_password": new_password},
)
# ── Admin — Credentials ───────────────────────────────────────────────────
def get_pg_creds(self, account_id: str) -> PGCreds:
"""GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials."""
"""GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials (admin)."""
data = self._request("GET", f"/v1/accounts/{account_id}/pgcreds")
assert data is not None
return PGCreds.from_dict(data)
@@ -205,7 +264,7 @@ class Client:
username: str,
password: str,
) -> None:
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials."""
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials (admin)."""
payload: dict[str, Any] = {
"host": host,
"port": port,
@@ -214,3 +273,89 @@ class Client:
"password": password,
}
self._request("PUT", f"/v1/accounts/{account_id}/pgcreds", json=payload)
# ── Admin — Policy ────────────────────────────────────────────────────────
def get_account_tags(self, account_id: str) -> list[str]:
"""GET /v1/accounts/{id}/tags — get account tags (admin)."""
data = self._request("GET", f"/v1/accounts/{account_id}/tags")
assert data is not None
return [str(t) for t in (data.get("tags") or [])]
def set_account_tags(self, account_id: str, tags: list[str]) -> list[str]:
"""PUT /v1/accounts/{id}/tags — replace the full tag set (admin).
Returns the updated tag list.
"""
data = self._request(
"PUT",
f"/v1/accounts/{account_id}/tags",
json={"tags": tags},
)
assert data is not None
return [str(t) for t in (data.get("tags") or [])]
def list_policy_rules(self) -> list[PolicyRule]:
"""GET /v1/policy/rules — list all operator policy rules (admin)."""
items = self._request_list("GET", "/v1/policy/rules")
return [PolicyRule.from_dict(r) for r in items]
def create_policy_rule(
self,
description: str,
rule: RuleBody,
*,
priority: int | None = None,
not_before: str | None = None,
expires_at: str | None = None,
) -> PolicyRule:
"""POST /v1/policy/rules — create a policy rule (admin)."""
payload: dict[str, Any] = {
"description": description,
"rule": rule.to_dict(),
}
if priority is not None:
payload["priority"] = priority
if not_before is not None:
payload["not_before"] = not_before
if expires_at is not None:
payload["expires_at"] = expires_at
data = self._request("POST", "/v1/policy/rules", json=payload)
assert data is not None
return PolicyRule.from_dict(data)
def get_policy_rule(self, rule_id: int) -> PolicyRule:
"""GET /v1/policy/rules/{id} — get a policy rule (admin)."""
data = self._request("GET", f"/v1/policy/rules/{rule_id}")
assert data is not None
return PolicyRule.from_dict(data)
def update_policy_rule(
self,
rule_id: int,
*,
description: str | None = None,
priority: int | None = None,
enabled: bool | None = None,
rule: RuleBody | None = None,
not_before: str | None = None,
expires_at: str | None = None,
clear_not_before: bool | None = None,
clear_expires_at: bool | None = None,
) -> PolicyRule:
"""PATCH /v1/policy/rules/{id} — update a policy rule (admin)."""
payload: dict[str, Any] = {}
if description is not None:
payload["description"] = description
if priority is not None:
payload["priority"] = priority
if enabled is not None:
payload["enabled"] = enabled
if rule is not None:
payload["rule"] = rule.to_dict()
if not_before is not None:
payload["not_before"] = not_before
if expires_at is not None:
payload["expires_at"] = expires_at
if clear_not_before is not None:
payload["clear_not_before"] = clear_not_before
if clear_expires_at is not None:
payload["clear_expires_at"] = clear_expires_at
data = self._request("PATCH", f"/v1/policy/rules/{rule_id}", json=payload)
assert data is not None
return PolicyRule.from_dict(data)
def delete_policy_rule(self, rule_id: int) -> None:
"""DELETE /v1/policy/rules/{id} — delete a policy rule (admin)."""
self._request("DELETE", f"/v1/policy/rules/{rule_id}")

View File

@@ -15,6 +15,8 @@ class MciasInputError(MciasError):
"""400 Bad Request — malformed request."""
class MciasConflictError(MciasError):
"""409 Conflict — e.g. duplicate username."""
class MciasRateLimitError(MciasError):
"""429 Too Many Requests — rate limit exceeded."""
class MciasServerError(MciasError):
"""5xx — unexpected server error."""
def raise_for_status(status_code: int, message: str) -> None:
@@ -25,6 +27,7 @@ def raise_for_status(status_code: int, message: str) -> None:
403: MciasForbiddenError,
404: MciasNotFoundError,
409: MciasConflictError,
429: MciasRateLimitError,
}
cls = exc_map.get(status_code, MciasServerError)
raise cls(status_code, message)

View File

@@ -1,6 +1,6 @@
"""Data models for MCIAS API responses."""
from dataclasses import dataclass, field
from typing import cast
from typing import Any, cast
@dataclass
@@ -74,3 +74,73 @@ class PGCreds:
username=str(d["username"]),
password=str(d["password"]),
)
@dataclass
class RuleBody:
"""Match conditions and effect of a policy rule."""
effect: str
roles: list[str] = field(default_factory=list)
account_types: list[str] = field(default_factory=list)
subject_uuid: str | None = None
actions: list[str] = field(default_factory=list)
resource_type: str | None = None
owner_matches_subject: bool | None = None
service_names: list[str] = field(default_factory=list)
required_tags: list[str] = field(default_factory=list)
@classmethod
def from_dict(cls, d: dict[str, object]) -> "RuleBody":
return cls(
effect=str(d["effect"]),
roles=[str(r) for r in cast(list[Any], d.get("roles") or [])],
account_types=[str(t) for t in cast(list[Any], d.get("account_types") or [])],
subject_uuid=str(d["subject_uuid"]) if d.get("subject_uuid") is not None else None,
actions=[str(a) for a in cast(list[Any], d.get("actions") or [])],
resource_type=str(d["resource_type"]) if d.get("resource_type") is not None else None,
owner_matches_subject=bool(d["owner_matches_subject"]) if d.get("owner_matches_subject") is not None else None,
service_names=[str(s) for s in cast(list[Any], d.get("service_names") or [])],
required_tags=[str(t) for t in cast(list[Any], d.get("required_tags") or [])],
)
def to_dict(self) -> dict[str, Any]:
"""Serialise to a JSON-compatible dict, omitting None/empty fields."""
out: dict[str, Any] = {"effect": self.effect}
if self.roles:
out["roles"] = self.roles
if self.account_types:
out["account_types"] = self.account_types
if self.subject_uuid is not None:
out["subject_uuid"] = self.subject_uuid
if self.actions:
out["actions"] = self.actions
if self.resource_type is not None:
out["resource_type"] = self.resource_type
if self.owner_matches_subject is not None:
out["owner_matches_subject"] = self.owner_matches_subject
if self.service_names:
out["service_names"] = self.service_names
if self.required_tags:
out["required_tags"] = self.required_tags
return out
@dataclass
class PolicyRule:
"""An operator-defined policy rule."""
id: int
priority: int
description: str
rule: RuleBody
enabled: bool
created_at: str
updated_at: str
not_before: str | None = None
expires_at: str | None = None
@classmethod
def from_dict(cls, d: dict[str, object]) -> "PolicyRule":
return cls(
id=int(cast(int, d["id"])),
priority=int(cast(int, d["priority"])),
description=str(d["description"]),
rule=RuleBody.from_dict(cast(dict[str, object], d["rule"])),
enabled=bool(d["enabled"]),
created_at=str(d["created_at"]),
updated_at=str(d["updated_at"]),
not_before=str(d["not_before"]) if d.get("not_before") is not None else None,
expires_at=str(d["expires_at"]) if d.get("expires_at") is not None else None,
)

View File

@@ -13,15 +13,16 @@ from mcias_client import (
MciasForbiddenError,
MciasInputError,
MciasNotFoundError,
MciasRateLimitError,
MciasServerError,
)
from mcias_client._models import Account, PGCreds, PublicKey, TokenClaims
from mcias_client._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
BASE_URL = "https://auth.example.com"
SAMPLE_ACCOUNT: dict[str, object] = {
"id": "acc-001",
"username": "alice",
"account_type": "user",
"account_type": "human",
"status": "active",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
@@ -34,6 +35,24 @@ SAMPLE_PK: dict[str, object] = {
"use": "sig",
"alg": "EdDSA",
}
SAMPLE_RULE_BODY: dict[str, object] = {
"effect": "allow",
"roles": ["svc:payments-api"],
"actions": ["pgcreds:read"],
"resource_type": "pgcreds",
"owner_matches_subject": True,
}
SAMPLE_POLICY_RULE: dict[str, object] = {
"id": 1,
"priority": 100,
"description": "Allow payments-api to read its own pgcreds",
"rule": SAMPLE_RULE_BODY,
"enabled": True,
"not_before": None,
"expires_at": None,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T09:00:00Z",
}
@pytest.fixture
def client() -> Client:
return Client(BASE_URL)
@@ -88,6 +107,16 @@ def test_login_success(client: Client) -> None:
assert expires_at == "2099-01-01T00:00:00Z"
assert client.token == "jwt-token-abc"
@respx.mock
def test_login_with_totp(client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/login").mock(
return_value=httpx.Response(
200,
json={"token": "jwt-token-totp", "expires_at": "2099-01-01T00:00:00Z"},
)
)
token, _ = client.login("alice", "s3cr3t", totp_code="123456")
assert token == "jwt-token-totp"
@respx.mock
def test_login_unauthorized(client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/login").mock(
return_value=httpx.Response(
@@ -98,6 +127,14 @@ def test_login_unauthorized(client: Client) -> None:
client.login("alice", "wrong")
assert exc_info.value.status_code == 401
@respx.mock
def test_login_rate_limited(client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/login").mock(
return_value=httpx.Response(429, json={"error": "rate limit exceeded", "code": "rate_limited"})
)
with pytest.raises(MciasRateLimitError) as exc_info:
client.login("alice", "s3cr3t")
assert exc_info.value.status_code == 429
@respx.mock
def test_logout_clears_token(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/logout").mock(
return_value=httpx.Response(204)
@@ -147,11 +184,58 @@ def test_validate_token_invalid(admin_client: Client) -> None:
claims = admin_client.validate_token("expired-token")
assert claims.valid is False
@respx.mock
def test_enroll_totp(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/totp/enroll").mock(
return_value=httpx.Response(
200,
json={"secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"},
)
)
secret, uri = admin_client.enroll_totp("testpass123")
assert secret == "JBSWY3DPEHPK3PXP"
assert "otpauth://totp/" in uri
@respx.mock
def test_confirm_totp(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/totp/confirm").mock(
return_value=httpx.Response(204)
)
admin_client.confirm_totp("123456") # should not raise
@respx.mock
def test_change_password(admin_client: Client) -> None:
respx.put(f"{BASE_URL}/v1/auth/password").mock(
return_value=httpx.Response(204)
)
admin_client.change_password("old-pass", "new-pass-long-enough") # should not raise
@respx.mock
def test_remove_totp(admin_client: Client) -> None:
respx.delete(f"{BASE_URL}/v1/auth/totp").mock(
return_value=httpx.Response(204)
)
admin_client.remove_totp("acc-001") # should not raise
@respx.mock
def test_issue_service_token(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/token/issue").mock(
return_value=httpx.Response(
200,
json={"token": "svc-token-xyz", "expires_at": "2099-12-31T00:00:00Z"},
)
)
token, expires_at = admin_client.issue_service_token("acc-001")
assert token == "svc-token-xyz"
assert expires_at == "2099-12-31T00:00:00Z"
@respx.mock
def test_revoke_token(admin_client: Client) -> None:
jti = "some-jti-uuid"
respx.delete(f"{BASE_URL}/v1/token/{jti}").mock(
return_value=httpx.Response(204)
)
admin_client.revoke_token(jti) # should not raise
@respx.mock
def test_create_account(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/accounts").mock(
return_value=httpx.Response(201, json=SAMPLE_ACCOUNT)
)
acc = admin_client.create_account("alice", "user", password="pass123")
acc = admin_client.create_account("alice", "human", password="pass123")
assert isinstance(acc, Account)
assert acc.id == "acc-001"
assert acc.username == "alice"
@@ -161,15 +245,14 @@ def test_create_account_conflict(admin_client: Client) -> None:
return_value=httpx.Response(409, json={"error": "username already exists"})
)
with pytest.raises(MciasConflictError) as exc_info:
admin_client.create_account("alice", "user")
admin_client.create_account("alice", "human")
assert exc_info.value.status_code == 409
@respx.mock
def test_list_accounts(admin_client: Client) -> None:
second = {**SAMPLE_ACCOUNT, "id": "acc-002"}
# API returns a plain JSON array, not a wrapper object
respx.get(f"{BASE_URL}/v1/accounts").mock(
return_value=httpx.Response(
200, json={"accounts": [SAMPLE_ACCOUNT, second]}
)
return_value=httpx.Response(200, json=[SAMPLE_ACCOUNT, second])
)
accounts = admin_client.list_accounts()
assert len(accounts) == 2
@@ -183,12 +266,12 @@ def test_get_account(admin_client: Client) -> None:
assert acc.id == "acc-001"
@respx.mock
def test_update_account(admin_client: Client) -> None:
updated = {**SAMPLE_ACCOUNT, "status": "suspended"}
# PATCH /v1/accounts/{id} returns 204 No Content
respx.patch(f"{BASE_URL}/v1/accounts/acc-001").mock(
return_value=httpx.Response(200, json=updated)
return_value=httpx.Response(204)
)
acc = admin_client.update_account("acc-001", status="suspended")
assert acc.status == "suspended"
result = admin_client.update_account("acc-001", status="inactive")
assert result is None
@respx.mock
def test_delete_account(admin_client: Client) -> None:
respx.delete(f"{BASE_URL}/v1/accounts/acc-001").mock(
@@ -209,23 +292,11 @@ def test_set_roles(admin_client: Client) -> None:
)
admin_client.set_roles("acc-001", ["viewer"]) # should not raise
@respx.mock
def test_issue_service_token(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/accounts/acc-001/token").mock(
return_value=httpx.Response(
200,
json={"token": "svc-token-xyz", "expires_at": "2099-12-31T00:00:00Z"},
)
)
token, expires_at = admin_client.issue_service_token("acc-001")
assert token == "svc-token-xyz"
assert expires_at == "2099-12-31T00:00:00Z"
@respx.mock
def test_revoke_token(admin_client: Client) -> None:
jti = "some-jti-uuid"
respx.delete(f"{BASE_URL}/v1/token/{jti}").mock(
def test_admin_set_password(admin_client: Client) -> None:
respx.put(f"{BASE_URL}/v1/accounts/acc-001/password").mock(
return_value=httpx.Response(204)
)
admin_client.revoke_token(jti) # should not raise
admin_client.admin_set_password("acc-001", "new-secure-password") # should not raise
SAMPLE_PG_CREDS: dict[str, object] = {
"host": "db.example.com",
"port": 5432,
@@ -256,6 +327,68 @@ def test_set_pg_creds(admin_client: Client) -> None:
username="appuser",
password="s3cr3t",
) # should not raise
@respx.mock
def test_get_account_tags(admin_client: Client) -> None:
respx.get(f"{BASE_URL}/v1/accounts/acc-001/tags").mock(
return_value=httpx.Response(200, json={"tags": ["env:production", "svc:payments-api"]})
)
tags = admin_client.get_account_tags("acc-001")
assert tags == ["env:production", "svc:payments-api"]
@respx.mock
def test_set_account_tags(admin_client: Client) -> None:
respx.put(f"{BASE_URL}/v1/accounts/acc-001/tags").mock(
return_value=httpx.Response(200, json={"tags": ["env:staging"]})
)
tags = admin_client.set_account_tags("acc-001", ["env:staging"])
assert tags == ["env:staging"]
@respx.mock
def test_list_policy_rules(admin_client: Client) -> None:
respx.get(f"{BASE_URL}/v1/policy/rules").mock(
return_value=httpx.Response(200, json=[SAMPLE_POLICY_RULE])
)
rules = admin_client.list_policy_rules()
assert len(rules) == 1
assert isinstance(rules[0], PolicyRule)
assert rules[0].id == 1
assert rules[0].rule.effect == "allow"
@respx.mock
def test_create_policy_rule(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/policy/rules").mock(
return_value=httpx.Response(201, json=SAMPLE_POLICY_RULE)
)
rule_body = RuleBody(effect="allow", actions=["pgcreds:read"], resource_type="pgcreds")
rule = admin_client.create_policy_rule(
"Allow payments-api to read its own pgcreds",
rule_body,
priority=50,
)
assert isinstance(rule, PolicyRule)
assert rule.id == 1
assert rule.description == "Allow payments-api to read its own pgcreds"
@respx.mock
def test_get_policy_rule(admin_client: Client) -> None:
respx.get(f"{BASE_URL}/v1/policy/rules/1").mock(
return_value=httpx.Response(200, json=SAMPLE_POLICY_RULE)
)
rule = admin_client.get_policy_rule(1)
assert isinstance(rule, PolicyRule)
assert rule.id == 1
assert rule.enabled is True
@respx.mock
def test_update_policy_rule(admin_client: Client) -> None:
updated = {**SAMPLE_POLICY_RULE, "enabled": False}
respx.patch(f"{BASE_URL}/v1/policy/rules/1").mock(
return_value=httpx.Response(200, json=updated)
)
rule = admin_client.update_policy_rule(1, enabled=False)
assert isinstance(rule, PolicyRule)
assert rule.enabled is False
@respx.mock
def test_delete_policy_rule(admin_client: Client) -> None:
respx.delete(f"{BASE_URL}/v1/policy/rules/1").mock(
return_value=httpx.Response(204)
)
admin_client.delete_policy_rule(1) # should not raise
@pytest.mark.parametrize(
("status_code", "exc_class"),
[
@@ -264,6 +397,7 @@ def test_set_pg_creds(admin_client: Client) -> None:
(403, MciasForbiddenError),
(404, MciasNotFoundError),
(409, MciasConflictError),
(429, MciasRateLimitError),
(500, MciasServerError),
],
)

View File

@@ -70,7 +70,7 @@ pub enum MciasError {
Decode(String),
}
// ---- Data types ----
// ---- Public data types ----
/// Account information returned by the server.
#[derive(Debug, Clone, Deserialize)]
@@ -101,6 +101,11 @@ pub struct TokenClaims {
pub struct PublicKey {
pub kty: String,
pub crv: String,
/// Key use — always `"sig"` for the MCIAS signing key.
#[serde(rename = "use")]
pub key_use: Option<String>,
/// Algorithm — always `"EdDSA"`. Validate this before trusting the key.
pub alg: Option<String>,
pub x: String,
}
@@ -114,6 +119,106 @@ pub struct PgCreds {
pub password: String,
}
/// Audit log entry returned by `GET /v1/audit`.
#[derive(Debug, Clone, Deserialize)]
pub struct AuditEvent {
pub id: i64,
pub event_type: String,
pub event_time: String,
pub ip_address: String,
pub actor_id: Option<String>,
pub target_id: Option<String>,
pub details: Option<String>,
}
/// Paginated response from `GET /v1/audit`.
#[derive(Debug, Clone, Deserialize)]
pub struct AuditPage {
pub events: Vec<AuditEvent>,
pub total: i64,
pub limit: i64,
pub offset: i64,
}
/// Query parameters for `GET /v1/audit`.
#[derive(Debug, Clone, Default)]
pub struct AuditQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub event_type: Option<String>,
pub actor_id: Option<String>,
}
/// A single operator-defined policy rule.
#[derive(Debug, Clone, Deserialize)]
pub struct PolicyRule {
pub id: i64,
pub priority: i64,
pub description: String,
pub rule: RuleBody,
pub enabled: bool,
pub not_before: Option<String>,
pub expires_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// The match conditions and effect of a policy rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleBody {
pub effect: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub roles: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_types: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_uuid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_matches_subject: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_names: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required_tags: Option<Vec<String>>,
}
/// Request body for `POST /v1/policy/rules`.
#[derive(Debug, Clone, Serialize)]
pub struct CreatePolicyRuleRequest {
pub description: String,
pub rule: RuleBody,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
/// Request body for `PATCH /v1/policy/rules/{id}`.
#[derive(Debug, Clone, Serialize, Default)]
pub struct UpdatePolicyRuleRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule: Option<RuleBody>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clear_not_before: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clear_expires_at: Option<bool>,
}
// ---- Internal request/response types ----
#[derive(Serialize)]
@@ -136,6 +241,22 @@ struct ErrorResponse {
error: String,
}
#[derive(Deserialize)]
struct RolesResponse {
roles: Vec<String>,
}
#[derive(Deserialize)]
struct TagsResponse {
tags: Vec<String>,
}
#[derive(Deserialize)]
struct TotpEnrollResponse {
secret: String,
otpauth_uri: String,
}
// ---- Client options ----
/// Configuration options for the MCIAS client.
@@ -160,6 +281,7 @@ pub struct Client {
base_url: String,
http: reqwest::Client,
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
/// Security: the token is never logged or included in error messages.
token: Arc<RwLock<Option<String>>>,
}
@@ -285,9 +407,9 @@ impl Client {
}
/// Update an account's status. Allowed values: `"active"`, `"inactive"`.
pub async fn update_account(&self, id: &str, status: &str) -> Result<Account, MciasError> {
pub async fn update_account(&self, id: &str, status: &str) -> Result<(), MciasError> {
let body = serde_json::json!({ "status": status });
self.patch(&format!("/v1/accounts/{id}"), &body).await
self.patch_no_content(&format!("/v1/accounts/{id}"), &body).await
}
/// Soft-delete an account and revoke all its tokens.
@@ -299,13 +421,17 @@ impl Client {
/// Get all roles assigned to an account.
pub async fn get_roles(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
self.get(&format!("/v1/accounts/{account_id}/roles")).await
// Security: spec wraps roles in {"roles": [...]}, unwrap before returning.
let resp: RolesResponse = self.get(&format!("/v1/accounts/{account_id}/roles")).await?;
Ok(resp.roles)
}
/// Replace the complete role set for an account.
pub async fn set_roles(&self, account_id: &str, roles: &[&str]) -> Result<(), MciasError> {
let url = format!("/v1/accounts/{account_id}/roles");
self.put_no_content(&url, roles).await
// Spec requires {"roles": [...]} wrapper.
let body = serde_json::json!({ "roles": roles });
self.put_no_content(&url, &body).await
}
// ---- Token management (admin only) ----
@@ -354,10 +480,145 @@ impl Client {
.await
}
// ---- TOTP enrollment (authenticated) ----
/// Begin TOTP enrollment. Returns `(secret, otpauth_uri)`.
/// The secret is shown once; store it in an authenticator app immediately.
///
/// Security (SEC-01): current password is required to prevent session-theft
/// escalation to persistent account takeover.
pub async fn enroll_totp(&self, password: &str) -> Result<(String, String), MciasError> {
let resp: TotpEnrollResponse =
self.post("/v1/auth/totp/enroll", &serde_json::json!({"password": password})).await?;
Ok((resp.secret, resp.otpauth_uri))
}
/// Confirm TOTP enrollment with the current 6-digit code.
/// On success, TOTP becomes required for all future logins.
pub async fn confirm_totp(&self, code: &str) -> Result<(), MciasError> {
let body = serde_json::json!({ "code": code });
self.post_empty_body("/v1/auth/totp/confirm", &body).await
}
// ---- Password management ----
/// Change the caller's own password (self-service). Requires the current
/// password to guard against token-theft attacks.
pub async fn change_password(
&self,
current_password: &str,
new_password: &str,
) -> Result<(), MciasError> {
let body = serde_json::json!({
"current_password": current_password,
"new_password": new_password,
});
self.put_no_content("/v1/auth/password", &body).await
}
// ---- Admin: TOTP removal ----
/// Remove TOTP enrollment from an account (admin). Use for recovery when
/// a user loses their TOTP device.
pub async fn remove_totp(&self, account_id: &str) -> Result<(), MciasError> {
let body = serde_json::json!({ "account_id": account_id });
self.delete_with_body("/v1/auth/totp", &body).await
}
// ---- Admin: password reset ----
/// Reset an account's password without requiring the current password.
pub async fn admin_set_password(
&self,
account_id: &str,
new_password: &str,
) -> Result<(), MciasError> {
let body = serde_json::json!({ "new_password": new_password });
self.put_no_content(&format!("/v1/accounts/{account_id}/password"), &body)
.await
}
// ---- Account tags (admin) ----
/// Get all tags for an account.
pub async fn get_tags(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
let resp: TagsResponse =
self.get(&format!("/v1/accounts/{account_id}/tags")).await?;
Ok(resp.tags)
}
/// Replace the full tag set for an account atomically. Pass an empty slice
/// to clear all tags. Returns the updated tag list.
pub async fn set_tags(
&self,
account_id: &str,
tags: &[&str],
) -> Result<Vec<String>, MciasError> {
let body = serde_json::json!({ "tags": tags });
let resp: TagsResponse =
self.put_with_response(&format!("/v1/accounts/{account_id}/tags"), &body).await?;
Ok(resp.tags)
}
// ---- Audit log (admin) ----
/// Query the audit log. Returns a paginated [`AuditPage`].
pub async fn list_audit(&self, query: AuditQuery) -> Result<AuditPage, MciasError> {
let mut params: Vec<(&str, String)> = Vec::new();
if let Some(limit) = query.limit {
params.push(("limit", limit.to_string()));
}
if let Some(offset) = query.offset {
params.push(("offset", offset.to_string()));
}
if let Some(ref et) = query.event_type {
params.push(("event_type", et.clone()));
}
if let Some(ref aid) = query.actor_id {
params.push(("actor_id", aid.clone()));
}
self.get_with_query("/v1/audit", &params).await
}
// ---- Policy rules (admin) ----
/// List all operator-defined policy rules ordered by priority.
pub async fn list_policy_rules(&self) -> Result<Vec<PolicyRule>, MciasError> {
self.get("/v1/policy/rules").await
}
/// Create a new policy rule.
pub async fn create_policy_rule(
&self,
req: CreatePolicyRuleRequest,
) -> Result<PolicyRule, MciasError> {
self.post_expect_status("/v1/policy/rules", &req, StatusCode::CREATED)
.await
}
/// Get a single policy rule by ID.
pub async fn get_policy_rule(&self, id: i64) -> Result<PolicyRule, MciasError> {
self.get(&format!("/v1/policy/rules/{id}")).await
}
/// Update a policy rule. Omitted fields are left unchanged.
pub async fn update_policy_rule(
&self,
id: i64,
req: UpdatePolicyRuleRequest,
) -> Result<PolicyRule, MciasError> {
self.patch(&format!("/v1/policy/rules/{id}"), &req).await
}
/// Delete a policy rule permanently.
pub async fn delete_policy_rule(&self, id: i64) -> Result<(), MciasError> {
self.delete(&format!("/v1/policy/rules/{id}")).await
}
// ---- HTTP helpers ----
/// Build a request with the Authorization header set from the stored token.
/// Security: the token is read under a read-lock and is not logged.
/// Build the Authorization header value from the stored token.
/// Security: the token is read under a read-lock and is never logged.
async fn auth_header(&self) -> Option<header::HeaderValue> {
let guard = self.token.read().await;
guard.as_deref().and_then(|tok| {
@@ -383,6 +644,22 @@ impl Client {
self.expect_success(resp).await
}
async fn get_with_query<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
params: &[(&str, String)],
) -> Result<T, MciasError> {
let mut req = self
.http
.get(format!("{}{path}", self.base_url))
.query(params);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.decode(resp).await
}
async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
@@ -434,6 +711,19 @@ impl Client {
self.expect_success(resp).await
}
/// POST with a JSON body that expects a 2xx (no body) response.
async fn post_empty_body<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
let mut req = self
.http
.post(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn patch<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
@@ -450,6 +740,18 @@ impl Client {
self.decode(resp).await
}
async fn patch_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
let mut req = self
.http
.patch(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn put_no_content<B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<(), MciasError> {
let mut req = self
.http
@@ -462,6 +764,22 @@ impl Client {
self.expect_success(resp).await
}
async fn put_with_response<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<T, MciasError> {
let mut req = self
.http
.put(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.decode(resp).await
}
async fn delete(&self, path: &str) -> Result<(), MciasError> {
let mut req = self.http.delete(format!("{}{path}", self.base_url));
if let Some(auth) = self.auth_header().await {
@@ -471,6 +789,19 @@ impl Client {
self.expect_success(resp).await
}
/// DELETE with a JSON request body (used by `DELETE /v1/auth/totp`).
async fn delete_with_body<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
let mut req = self
.http
.delete(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn decode<T: for<'de> Deserialize<'de>>(
&self,
resp: reqwest::Response,

View File

@@ -1,12 +1,18 @@
use mcias_client::{Client, ClientOptions, MciasError};
use mcias_client::{
AuditQuery, Client, ClientOptions, CreatePolicyRuleRequest, MciasError, RuleBody,
UpdatePolicyRuleRequest,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn admin_client(server: &MockServer) -> Client {
Client::new(&server.uri(), ClientOptions {
token: Some("admin-token".to_string()),
..Default::default()
})
Client::new(
&server.uri(),
ClientOptions {
token: Some("admin-token".to_string()),
..Default::default()
},
)
.unwrap()
}
@@ -48,7 +54,10 @@ async fn test_health_server_error() {
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
let err = c.health().await.unwrap_err();
assert!(matches!(err, MciasError::Server { .. }), "expected Server error, got {err:?}");
assert!(
matches!(err, MciasError::Server { .. }),
"expected Server error, got {err:?}"
);
}
// ---- public key ----
@@ -61,6 +70,8 @@ async fn test_get_public_key() {
.respond_with(json_body(serde_json::json!({
"kty": "OKP",
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
})))
.mount(&server)
@@ -70,6 +81,8 @@ async fn test_get_public_key() {
let pk = c.get_public_key().await.expect("get_public_key should succeed");
assert_eq!(pk.kty, "OKP");
assert_eq!(pk.crv, "Ed25519");
assert_eq!(pk.key_use.as_deref(), Some("sig"));
assert_eq!(pk.alg.as_deref(), Some("EdDSA"));
}
// ---- login ----
@@ -99,7 +112,10 @@ async fn test_login_bad_credentials() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/login"))
.respond_with(json_body_status(401, serde_json::json!({"error": "invalid credentials"})))
.respond_with(json_body_status(
401,
serde_json::json!({"error": "invalid credentials"}),
))
.mount(&server)
.await;
@@ -119,10 +135,13 @@ async fn test_logout_clears_token() {
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions {
token: Some("existing-token".to_string()),
..Default::default()
})
let c = Client::new(
&server.uri(),
ClientOptions {
token: Some("existing-token".to_string()),
..Default::default()
},
)
.unwrap();
c.logout().await.unwrap();
assert!(c.token().await.is_none(), "token should be cleared after logout");
@@ -142,10 +161,13 @@ async fn test_renew_token() {
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions {
token: Some("old-token".to_string()),
..Default::default()
})
let c = Client::new(
&server.uri(),
ClientOptions {
token: Some("old-token".to_string()),
..Default::default()
},
)
.unwrap();
let (tok, _) = c.renew_token().await.unwrap();
assert_eq!(tok, "new-token");
@@ -224,7 +246,10 @@ async fn test_create_account_conflict() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/accounts"))
.respond_with(json_body_status(409, serde_json::json!({"error": "username already exists"})))
.respond_with(json_body_status(
409,
serde_json::json!({"error": "username already exists"}),
))
.mount(&server)
.await;
@@ -259,7 +284,10 @@ async fn test_get_account_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/accounts/missing"))
.respond_with(json_body_status(404, serde_json::json!({"error": "account not found"})))
.respond_with(json_body_status(
404,
serde_json::json!({"error": "account not found"}),
))
.mount(&server)
.await;
@@ -271,19 +299,15 @@ async fn test_get_account_not_found() {
#[tokio::test]
async fn test_update_account() {
let server = MockServer::start().await;
// PATCH /v1/accounts/{id} returns 204 No Content per spec.
Mock::given(method("PATCH"))
.and(path("/v1/accounts/uuid-1"))
.respond_with(json_body(serde_json::json!({
"id": "uuid-1", "username": "alice", "account_type": "human",
"status": "inactive", "created_at": "2023-11-15T12:00:00Z",
"updated_at": "2023-11-15T13:00:00Z", "totp_enabled": false
})))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
let a = c.update_account("uuid-1", "inactive").await.unwrap();
assert_eq!(a.status, "inactive");
c.update_account("uuid-1", "inactive").await.unwrap();
}
#[tokio::test]
@@ -305,12 +329,14 @@ async fn test_delete_account() {
async fn test_get_set_roles() {
let server = MockServer::start().await;
// Spec wraps the array: {"roles": [...]}
Mock::given(method("GET"))
.and(path("/v1/accounts/uuid-1/roles"))
.respond_with(json_body(serde_json::json!(["admin", "viewer"])))
.respond_with(json_body(serde_json::json!({"roles": ["admin", "viewer"]})))
.mount(&server)
.await;
// Spec requires {"roles": [...]} in the PUT body.
Mock::given(method("PUT"))
.and(path("/v1/accounts/uuid-1/roles"))
.respond_with(ResponseTemplate::new(204))
@@ -363,7 +389,10 @@ async fn test_pg_creds_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/accounts/uuid-1/pgcreds"))
.respond_with(json_body_status(404, serde_json::json!({"error": "no pg credentials found"})))
.respond_with(json_body_status(
404,
serde_json::json!({"error": "no pg credentials found"}),
))
.mount(&server)
.await;
@@ -405,6 +434,298 @@ async fn test_set_get_pg_creds() {
assert_eq!(creds.password, "dbpass");
}
// ---- TOTP ----
#[tokio::test]
async fn test_enroll_totp() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/totp/enroll"))
.respond_with(json_body(serde_json::json!({
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let (secret, uri) = c.enroll_totp("testpass123").await.unwrap();
assert_eq!(secret, "JBSWY3DPEHPK3PXP");
assert!(uri.starts_with("otpauth://totp/"));
}
#[tokio::test]
async fn test_confirm_totp() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/totp/confirm"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.confirm_totp("123456").await.unwrap();
}
#[tokio::test]
async fn test_remove_totp() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v1/auth/totp"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.remove_totp("some-account-uuid").await.unwrap();
}
// ---- password management ----
#[tokio::test]
async fn test_change_password() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/v1/auth/password"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.change_password("old-pass", "new-pass-long-enough").await.unwrap();
}
#[tokio::test]
async fn test_change_password_wrong_current() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/v1/auth/password"))
.respond_with(json_body_status(
401,
serde_json::json!({"error": "current password is incorrect", "code": "unauthorized"}),
))
.mount(&server)
.await;
let c = admin_client(&server).await;
let err = c
.change_password("wrong", "new-pass-long-enough")
.await
.unwrap_err();
assert!(matches!(err, MciasError::Auth(_)));
}
#[tokio::test]
async fn test_admin_set_password() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/v1/accounts/uuid-1/password"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.admin_set_password("uuid-1", "new-pass-long-enough").await.unwrap();
}
// ---- tags ----
#[tokio::test]
async fn test_get_set_tags() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/accounts/uuid-1/tags"))
.respond_with(json_body(
serde_json::json!({"tags": ["env:production", "svc:payments-api"]}),
))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/v1/accounts/uuid-1/tags"))
.respond_with(json_body(serde_json::json!({"tags": ["env:staging"]})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let tags = c.get_tags("uuid-1").await.unwrap();
assert_eq!(tags, vec!["env:production", "svc:payments-api"]);
let updated = c.set_tags("uuid-1", &["env:staging"]).await.unwrap();
assert_eq!(updated, vec!["env:staging"]);
}
// ---- audit log ----
#[tokio::test]
async fn test_list_audit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/audit"))
.respond_with(json_body(serde_json::json!({
"events": [
{
"id": 1,
"event_type": "login_ok",
"event_time": "2026-03-11T09:01:23Z",
"ip_address": "192.0.2.1",
"actor_id": "uuid-1",
"target_id": null,
"details": null
}
],
"total": 1,
"limit": 50,
"offset": 0
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let page = c.list_audit(AuditQuery::default()).await.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.events.len(), 1);
assert_eq!(page.events[0].event_type, "login_ok");
}
// ---- policy rules ----
#[tokio::test]
async fn test_list_policy_rules() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/policy/rules"))
.respond_with(json_body(serde_json::json!([])))
.mount(&server)
.await;
let c = admin_client(&server).await;
let rules = c.list_policy_rules().await.unwrap();
assert!(rules.is_empty());
}
#[tokio::test]
async fn test_create_policy_rule() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/policy/rules"))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(serde_json::json!({
"id": 1,
"priority": 100,
"description": "Allow payments-api to read its own pgcreds",
"rule": {"effect": "allow", "roles": ["svc:payments-api"]},
"enabled": true,
"not_before": null,
"expires_at": null,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T09:00:00Z"
}))
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let c = admin_client(&server).await;
let rule = c
.create_policy_rule(CreatePolicyRuleRequest {
description: "Allow payments-api to read its own pgcreds".to_string(),
rule: RuleBody {
effect: "allow".to_string(),
roles: Some(vec!["svc:payments-api".to_string()]),
account_types: None,
subject_uuid: None,
actions: None,
resource_type: None,
owner_matches_subject: None,
service_names: None,
required_tags: None,
},
priority: None,
not_before: None,
expires_at: None,
})
.await
.unwrap();
assert_eq!(rule.id, 1);
assert_eq!(rule.description, "Allow payments-api to read its own pgcreds");
}
#[tokio::test]
async fn test_get_policy_rule() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/policy/rules/1"))
.respond_with(json_body(serde_json::json!({
"id": 1,
"priority": 100,
"description": "test rule",
"rule": {"effect": "deny"},
"enabled": true,
"not_before": null,
"expires_at": null,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T09:00:00Z"
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let rule = c.get_policy_rule(1).await.unwrap();
assert_eq!(rule.id, 1);
assert_eq!(rule.rule.effect, "deny");
}
#[tokio::test]
async fn test_update_policy_rule() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/v1/policy/rules/1"))
.respond_with(json_body(serde_json::json!({
"id": 1,
"priority": 75,
"description": "updated rule",
"rule": {"effect": "allow"},
"enabled": false,
"not_before": null,
"expires_at": null,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T10:00:00Z"
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let rule = c
.update_policy_rule(
1,
UpdatePolicyRuleRequest {
enabled: Some(false),
priority: Some(75),
..Default::default()
},
)
.await
.unwrap();
assert!(!rule.enabled);
assert_eq!(rule.priority, 75);
}
#[tokio::test]
async fn test_delete_policy_rule() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v1/policy/rules/1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.delete_policy_rule(1).await.unwrap();
}
// ---- error type coverage ----
#[tokio::test]
@@ -416,11 +737,13 @@ async fn test_forbidden_error() {
.mount(&server)
.await;
// Use a non-admin token.
let c = Client::new(&server.uri(), ClientOptions {
token: Some("user-token".to_string()),
..Default::default()
})
let c = Client::new(
&server.uri(),
ClientOptions {
token: Some("user-token".to_string()),
..Default::default()
},
)
.unwrap();
let err = c.list_accounts().await.unwrap_err();
assert!(matches!(err, MciasError::Forbidden(_)));

View File

@@ -10,33 +10,38 @@
//
// 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)
// -cacert Path to CA certificate for TLS verification (optional)
//
// Commands:
//
// auth login -username NAME [-password PASS] [-totp CODE]
// auth login -username NAME [-totp CODE]
// auth change-password (passwords always prompted interactively)
//
// account list
// account create -username NAME [-password PASS] [-type human|system]
// account get -id UUID
// account update -id UUID [-status active|inactive]
// account delete -id UUID
// account create -username NAME [-type human|system]
// account get -id UUID
// account update -id UUID [-status active|inactive]
// account delete -id UUID
// account set-password -id UUID
//
// role list -id UUID
// role set -id UUID -roles role1,role2,...
// role grant -id UUID -role ROLE
// role revoke -id UUID -role ROLE
//
// token issue -id UUID
// token revoke -jti JTI
//
// pgcreds list
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
// pgcreds get -id UUID
//
// policy list
// policy create -description STR -json FILE [-priority N]
// policy create -description STR -json FILE [-priority N] [-not-before RFC3339] [-expires-at RFC3339]
// policy get -id ID
// policy update -id ID [-priority N] [-enabled true|false]
// policy update -id ID [-priority N] [-enabled true|false] [-not-before RFC3339] [-expires-at RFC3339] [-clear-not-before] [-clear-expires-at]
// policy delete -id ID
//
// tag list -id UUID
@@ -59,7 +64,7 @@ import (
func main() {
// 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)")
caCert := flag.String("cacert", "", "path to CA certificate for TLS")
flag.Usage = usage
@@ -123,28 +128,28 @@ type controller struct {
func (c *controller) runAuth(args []string) {
if len(args) == 0 {
fatalf("auth requires a subcommand: login")
fatalf("auth requires a subcommand: login, change-password")
}
switch args[0] {
case "login":
c.authLogin(args[1:])
case "change-password":
c.authChangePassword(args[1:])
default:
fatalf("unknown auth subcommand %q", args[0])
}
}
// authLogin authenticates with the server using username and password, then
// prints the resulting bearer token to stdout. If -password is not supplied on
// the command line, the user is prompted interactively (input is hidden so the
// password does not appear in shell history or terminal output).
// prints the resulting bearer token to stdout. The password is always prompted
// interactively; it is never accepted as a command-line flag to prevent it from
// appearing in shell history, ps output, and process argument lists.
//
// Security: passwords are never stored by this process beyond the lifetime of
// the HTTP request. Interactive reads use golang.org/x/term.ReadPassword so
// that terminal echo is disabled; the byte slice is zeroed after use.
// Security: terminal echo is disabled during password entry
// (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use.
func (c *controller) authLogin(args []string) {
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
username := fs.String("username", "", "username (required)")
password := fs.String("password", "", "password (reads from stdin if omitted)")
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
_ = fs.Parse(args)
@@ -152,21 +157,19 @@ func (c *controller) authLogin(args []string) {
fatalf("auth login: -username is required")
}
// If no password flag was provided, prompt interactively so it does not
// appear in process arguments or shell history.
passwd := *password
if passwd == "" {
fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr) // newline after hidden input
if err != nil {
fatalf("read password: %v", err)
}
passwd = string(raw)
// Zero the raw byte slice once copied into the string.
for i := range raw {
raw[i] = 0
}
// Security: always prompt interactively; never accept password as a flag.
// This prevents the credential from appearing in shell history, ps output,
// and /proc/PID/cmdline.
fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr) // newline after hidden input
if err != nil {
fatalf("read password: %v", err)
}
passwd := string(raw)
// Zero the raw byte slice once copied into the string.
for i := range raw {
raw[i] = 0
}
body := map[string]string{
@@ -191,11 +194,53 @@ func (c *controller) authLogin(args []string) {
}
}
// authChangePassword allows an authenticated user to change their own password.
// A valid bearer token must be set (via -token flag or MCIAS_TOKEN env var).
// Both passwords are always prompted interactively; they are never accepted as
// command-line flags to prevent them from appearing in shell history, ps
// output, and process argument lists.
//
// Security: terminal echo is disabled during entry (golang.org/x/term);
// raw byte slices are zeroed after use. The server requires the current
// password to prevent token-theft attacks. On success all other active
// sessions are revoked server-side.
func (c *controller) authChangePassword(_ []string) {
// Security: always prompt interactively; never accept passwords as flags.
fmt.Fprint(os.Stderr, "Current password: ")
rawCurrent, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read current password: %v", err)
}
currentPasswd := string(rawCurrent)
for i := range rawCurrent {
rawCurrent[i] = 0
}
fmt.Fprint(os.Stderr, "New password: ")
rawNew, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read new password: %v", err)
}
newPasswd := string(rawNew)
for i := range rawNew {
rawNew[i] = 0
}
body := map[string]string{
"current_password": currentPasswd,
"new_password": newPasswd,
}
c.doRequest("PUT", "/v1/auth/password", body, nil)
fmt.Println("password changed; other active sessions revoked")
}
// ---- account subcommands ----
func (c *controller) runAccount(args []string) {
if len(args) == 0 {
fatalf("account requires a subcommand: list, create, get, update, delete")
fatalf("account requires a subcommand: list, create, get, update, delete, set-password")
}
switch args[0] {
case "list":
@@ -208,6 +253,8 @@ func (c *controller) runAccount(args []string) {
c.accountUpdate(args[1:])
case "delete":
c.accountDelete(args[1:])
case "set-password":
c.accountSetPassword(args[1:])
default:
fatalf("unknown account subcommand %q", args[0])
}
@@ -222,7 +269,6 @@ func (c *controller) accountList() {
func (c *controller) accountCreate(args []string) {
fs := flag.NewFlagSet("account create", flag.ExitOnError)
username := fs.String("username", "", "username (required)")
password := fs.String("password", "", "password for human accounts (prompted if omitted)")
accountType := fs.String("type", "human", "account type: human or system")
_ = fs.Parse(args)
@@ -230,12 +276,11 @@ func (c *controller) accountCreate(args []string) {
fatalf("account create: -username is required")
}
// For human accounts, prompt for a password interactively if one was not
// supplied on the command line so it stays out of shell history.
// Security: terminal echo is disabled during entry; the raw byte slice is
// zeroed after conversion to string. System accounts have no password.
passwd := *password
if passwd == "" && *accountType == "human" {
// Security: always prompt interactively for human-account passwords; never
// accept them as a flag. Terminal echo is disabled; the raw byte slice is
// zeroed after conversion to string. System accounts have no password.
var passwd string
if *accountType == "human" {
fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
@@ -306,17 +351,55 @@ func (c *controller) accountDelete(args []string) {
fmt.Println("account deleted")
}
// accountSetPassword resets a human account's password (admin operation).
// No current password is required. All active sessions for the target account
// are revoked by the server on success.
//
// Security: the new password is always prompted interactively; it is never
// accepted as a command-line flag to prevent it from appearing in shell
// history, ps output, and process argument lists. Terminal echo is disabled
// (golang.org/x/term); the raw byte slice is zeroed after use.
func (c *controller) accountSetPassword(args []string) {
fs := flag.NewFlagSet("account set-password", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("account set-password: -id is required")
}
// Security: always prompt interactively; never accept password as a flag.
fmt.Fprint(os.Stderr, "New password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read password: %v", err)
}
passwd := string(raw)
for i := range raw {
raw[i] = 0
}
body := map[string]string{"new_password": passwd}
c.doRequest("PUT", "/v1/accounts/"+*id+"/password", body, nil)
fmt.Println("password updated; all active sessions revoked")
}
// ---- role subcommands ----
func (c *controller) runRole(args []string) {
if len(args) == 0 {
fatalf("role requires a subcommand: list, set")
fatalf("role requires a subcommand: list, set, grant, revoke")
}
switch args[0] {
case "list":
c.roleList(args[1:])
case "set":
c.roleSet(args[1:])
case "grant":
c.roleGrant(args[1:])
case "revoke":
c.roleRevoke(args[1:])
default:
fatalf("unknown role subcommand %q", args[0])
}
@@ -361,6 +444,41 @@ func (c *controller) roleSet(args []string) {
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 ----
func (c *controller) runToken(args []string) {
@@ -409,9 +527,11 @@ func (c *controller) tokenRevoke(args []string) {
func (c *controller) runPGCreds(args []string) {
if len(args) == 0 {
fatalf("pgcreds requires a subcommand: get, set")
fatalf("pgcreds requires a subcommand: list, get, set")
}
switch args[0] {
case "list":
c.pgCredsList(args[1:])
case "get":
c.pgCredsGet(args[1:])
case "set":
@@ -421,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) {
fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
@@ -511,6 +640,8 @@ func (c *controller) policyCreate(args []string) {
description := fs.String("description", "", "rule description (required)")
jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)")
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339, optional)")
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339, optional)")
_ = fs.Parse(args)
if *description == "" {
@@ -537,6 +668,18 @@ func (c *controller) policyCreate(args []string) {
"priority": *priority,
"rule": ruleBody,
}
if *notBefore != "" {
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
fatalf("policy create: -not-before must be RFC3339: %v", err)
}
body["not_before"] = *notBefore
}
if *expiresAt != "" {
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
fatalf("policy create: -expires-at must be RFC3339: %v", err)
}
body["expires_at"] = *expiresAt
}
var result json.RawMessage
c.doRequest("POST", "/v1/policy/rules", body, &result)
@@ -562,6 +705,10 @@ func (c *controller) policyUpdate(args []string) {
id := fs.String("id", "", "rule ID (required)")
priority := fs.Int("priority", -1, "new priority (-1 = no change)")
enabled := fs.String("enabled", "", "true or false")
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339)")
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339)")
clearNotBefore := fs.Bool("clear-not-before", false, "remove not_before constraint")
clearExpiresAt := fs.Bool("clear-expires-at", false, "remove expires_at constraint")
_ = fs.Parse(args)
if *id == "" {
@@ -584,8 +731,24 @@ func (c *controller) policyUpdate(args []string) {
fatalf("policy update: -enabled must be true or false")
}
}
if *clearNotBefore {
body["clear_not_before"] = true
} else if *notBefore != "" {
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
fatalf("policy update: -not-before must be RFC3339: %v", err)
}
body["not_before"] = *notBefore
}
if *clearExpiresAt {
body["clear_expires_at"] = true
} else if *expiresAt != "" {
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
fatalf("policy update: -expires-at must be RFC3339: %v", err)
}
body["expires_at"] = *expiresAt
}
if len(body) == 0 {
fatalf("policy update: at least one of -priority or -enabled is required")
fatalf("policy update: at least one flag is required")
}
var result json.RawMessage
@@ -761,21 +924,30 @@ func usage() {
Usage: mciasctl [global flags] <command> [args]
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)
-cacert Path to CA certificate for TLS verification
Commands:
auth login -username NAME [-password PASS] [-totp CODE]
Obtain a bearer token. Password is prompted if -password is
omitted. Token is written to stdout; expiry to stderr.
auth login -username NAME [-totp CODE]
Obtain a bearer token. Password is always prompted interactively
(never accepted as a flag) to avoid shell-history exposure.
Token is written to stdout; expiry to stderr.
Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice)
auth change-password
Change the current user's own password. Requires a valid bearer
token. Current and new passwords are always prompted interactively.
Revokes all other active sessions on success.
account list
account create -username NAME [-password PASS] [-type human|system]
account get -id UUID
account update -id UUID -status active|inactive
account delete -id UUID
account create -username NAME [-type human|system]
account get -id UUID
account update -id UUID -status active|inactive
account delete -id UUID
account set-password -id UUID
Admin: reset a human account's password without requiring the
current password. New password is always prompted interactively.
Revokes all active sessions for the account.
role list -id UUID
role set -id UUID -roles role1,role2,...
@@ -783,15 +955,19 @@ Commands:
token issue -id UUID
token revoke -jti JTI
pgcreds list
pgcreds get -id UUID
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
tag list -id UUID

View File

@@ -15,6 +15,7 @@
//
// schema verify
// schema migrate
// schema force --version N
//
// account list
// account get --id UUID
@@ -62,7 +63,22 @@ func main() {
os.Exit(1)
}
database, masterKey, err := openDB(*configPath)
command := args[0]
subArgs := args[1:]
// schema subcommands manage migrations themselves and must not trigger
// auto-migration on open (a dirty database would prevent the tool from
// opening at all, blocking recovery operations like "schema force").
var (
database *db.DB
masterKey []byte
err error
)
if command == "schema" {
database, masterKey, err = openDBRaw(*configPath)
} else {
database, masterKey, err = openDB(*configPath)
}
if err != nil {
fatalf("%v", err)
}
@@ -76,9 +92,6 @@ func main() {
tool := &tool{db: database, masterKey: masterKey}
command := args[0]
subArgs := args[1:]
switch command {
case "schema":
tool.runSchema(subArgs)
@@ -111,6 +124,21 @@ type tool struct {
// the same passphrase always yields the same key and encrypted secrets remain
// readable. The passphrase env var is unset immediately after reading.
func openDB(configPath string) (*db.DB, []byte, error) {
database, masterKey, err := openDBRaw(configPath)
if err != nil {
return nil, nil, err
}
if err := db.Migrate(database); err != nil {
_ = database.Close()
return nil, nil, fmt.Errorf("migrate database: %w", err)
}
return database, masterKey, nil
}
// openDBRaw opens the database without running migrations. Used by schema
// subcommands so they remain operational even when the database is in a dirty
// migration state (e.g. to allow "schema force" to clear a dirty flag).
func openDBRaw(configPath string) (*db.DB, []byte, error) {
cfg, err := config.Load(configPath)
if err != nil {
return nil, nil, fmt.Errorf("load config: %w", err)
@@ -121,11 +149,6 @@ func openDB(configPath string) (*db.DB, []byte, error) {
return nil, nil, fmt.Errorf("open database %q: %w", cfg.Database.Path, err)
}
if err := db.Migrate(database); err != nil {
_ = database.Close()
return nil, nil, fmt.Errorf("migrate database: %w", err)
}
masterKey, err := deriveMasterKey(cfg, database)
if err != nil {
_ = database.Close()
@@ -210,6 +233,7 @@ Global flags:
Commands:
schema verify Check schema version; exit 1 if migrations pending
schema migrate Apply any pending schema migrations
schema force --version N Force schema version (clears dirty state)
account list List all accounts
account get --id UUID

View File

@@ -206,12 +206,12 @@ func TestRoleRevoke(t *testing.T) {
t.Fatalf("create account: %v", err)
}
if err := tool.db.GrantRole(a.ID, "editor", nil); err != nil {
if err := tool.db.GrantRole(a.ID, "user", nil); err != nil {
t.Fatalf("grant role: %v", err)
}
captureStdout(t, func() {
tool.roleRevoke([]string{"--id", a.UUID, "--role", "editor"})
tool.roleRevoke([]string{"--id", a.UUID, "--role", "user"})
})
roles, err := tool.db.GetRoles(a.ID)

View File

@@ -1,6 +1,7 @@
package main
import (
"flag"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/db"
@@ -8,13 +9,15 @@ import (
func (t *tool) runSchema(args []string) {
if len(args) == 0 {
fatalf("schema requires a subcommand: verify, migrate")
fatalf("schema requires a subcommand: verify, migrate, force")
}
switch args[0] {
case "verify":
t.schemaVerify()
case "migrate":
t.schemaMigrate()
case "force":
t.schemaForce(args[1:])
default:
fatalf("unknown schema subcommand %q", args[0])
}
@@ -39,6 +42,26 @@ func (t *tool) schemaVerify() {
fmt.Println("schema is up-to-date")
}
// schemaForce marks the database as being at a specific migration version
// without running any SQL. Use this to clear a dirty migration state after
// you have verified that the schema already reflects the target version.
//
// Example: mciasdb schema force --version 6
func (t *tool) schemaForce(args []string) {
fs := flag.NewFlagSet("schema force", flag.ExitOnError)
version := fs.Int("version", 0, "schema version to force (required)")
_ = fs.Parse(args)
if *version <= 0 {
fatalf("--version must be a positive integer")
}
if err := db.ForceSchemaVersion(t.db, *version); err != nil {
fatalf("force schema version: %v", err)
}
fmt.Printf("schema version forced to %d; run 'schema migrate' to apply any remaining migrations\n", *version)
}
// schemaMigrate applies any pending migrations and reports each one.
func (t *tool) schemaMigrate() {
before, err := db.SchemaVersion(t.db)

View File

@@ -1,7 +1,8 @@
// Command mciasgrpcctl is the MCIAS gRPC admin CLI.
//
// 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:
//
@@ -9,7 +10,7 @@
//
// 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)
// -cacert Path to CA certificate for TLS verification (optional)
//
@@ -18,14 +19,19 @@
// health
// pubkey
//
// auth login -username NAME [-totp CODE]
// auth logout
//
// account list
// account create -username NAME -password PASS [-type human|system]
// account get -id UUID
// account update -id UUID -status active|inactive
// account delete -id UUID
//
// role list -id UUID
// role set -id UUID -roles role1,role2,...
// role list -id UUID
// role set -id UUID -roles role1,role2,...
// role grant -id UUID -role ROLE
// role revoke -id UUID -role ROLE
//
// token validate -token TOKEN
// token issue -id UUID
@@ -33,6 +39,12 @@
//
// pgcreds get -id UUID
// 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
import (
@@ -43,9 +55,11 @@ import (
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
"golang.org/x/term"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
@@ -55,7 +69,7 @@ import (
func main() {
// 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)")
caCert := flag.String("cacert", "", "path to CA certificate for TLS")
flag.Usage = usage
@@ -93,6 +107,8 @@ func main() {
ctl.runHealth()
case "pubkey":
ctl.runPubKey()
case "auth":
ctl.runAuth(subArgs)
case "account":
ctl.runAccount(subArgs)
case "role":
@@ -101,6 +117,8 @@ func main() {
ctl.runToken(subArgs)
case "pgcreds":
ctl.runPGCreds(subArgs)
case "policy":
ctl.runPolicy(subArgs)
default:
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 ----
func (c *controller) runAccount(args []string) {
@@ -293,13 +394,17 @@ func (c *controller) accountDelete(args []string) {
func (c *controller) runRole(args []string) {
if len(args) == 0 {
fatalf("role requires a subcommand: list, set")
fatalf("role requires a subcommand: list, set, grant, revoke")
}
switch args[0] {
case "list":
c.roleList(args[1:])
case "set":
c.roleSet(args[1:])
case "grant":
c.roleGrant(args[1:])
case "revoke":
c.roleRevoke(args[1:])
default:
fatalf("unknown role subcommand %q", args[0])
}
@@ -356,6 +461,54 @@ func (c *controller) roleSet(args []string) {
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 ----
func (c *controller) runToken(args []string) {
@@ -518,6 +671,208 @@ func (c *controller) pgCredsSet(args []string) {
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 ----
// newGRPCConn dials the gRPC server with TLS.
@@ -575,7 +930,7 @@ func usage() {
Usage: mciasgrpcctl [global flags] <command> [args]
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)
-cacert Path to CA certificate for TLS verification
@@ -583,6 +938,12 @@ Commands:
health
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 create -username NAME -password PASS [-type human|system]
account get -id UUID
@@ -598,5 +959,16 @@ Commands:
pgcreds get -id UUID
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

@@ -22,6 +22,7 @@ listen_addr = "127.0.0.1:8443"
grpc_addr = "127.0.0.1:9443"
tls_cert = "/tmp/mcias-dev.crt"
tls_key = "/tmp/mcias-dev.key"
# trusted_proxy not set — direct local development, no reverse proxy.
[database]
path = "/tmp/mcias-dev.db"

View File

@@ -25,6 +25,10 @@ listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443"
tls_cert = "/etc/mcias/server.crt"
tls_key = "/etc/mcias/server.key"
# If a reverse proxy (nginx, Caddy, Traefik) sits in front of this container,
# set trusted_proxy to its container IP so real client IPs are used for rate
# limiting and audit logging. Leave commented out for direct exposure.
# trusted_proxy = "172.17.0.1"
[database]
# VOLUME /data is declared in the Dockerfile; map a named volume here.
@@ -32,7 +36,7 @@ path = "/data/mcias.db"
[tokens]
issuer = "https://auth.example.com"
default_expiry = "720h"
default_expiry = "168h"
admin_expiry = "8h"
service_expiry = "8760h"

View File

@@ -32,6 +32,21 @@ tls_cert = "/etc/mcias/server.crt"
# Permissions: mode 0640, owner root:mcias.
tls_key = "/etc/mcias/server.key"
# OPTIONAL. IP address of a trusted reverse proxy (e.g. nginx, Caddy, HAProxy).
# When set, the rate limiter and audit log extract the real client IP from the
# X-Real-IP or X-Forwarded-For header, but ONLY for requests whose TCP source
# address matches this exact IP. All other requests use RemoteAddr directly,
# preventing IP spoofing by external clients.
#
# Must be an IP address, not a hostname or CIDR range.
# Omit when running without a reverse proxy (direct Internet exposure).
#
# Example — local nginx proxy:
# trusted_proxy = "127.0.0.1"
#
# Example — Docker network gateway:
# trusted_proxy = "172.17.0.1"
# ---------------------------------------------------------------------------
# [database] — SQLite database
# ---------------------------------------------------------------------------
@@ -54,8 +69,8 @@ issuer = "https://auth.example.com"
# OPTIONAL. Default token expiry for interactive (human) logins.
# Go duration string: "h" hours, "m" minutes, "s" seconds.
# Default: 720h (30 days). Reduce for higher-security deployments.
default_expiry = "720h"
# Default: 168h (7 days). The maximum allowed value is 720h (30 days).
default_expiry = "168h"
# OPTIONAL. Expiry for admin tokens (tokens with the "admin" role).
# 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}
}
// 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.
type GetPGCredsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -664,7 +844,7 @@ type GetPGCredsRequest struct {
func (x *GetPGCredsRequest) Reset() {
*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.StoreMessageInfo(mi)
}
@@ -676,7 +856,7 @@ func (x *GetPGCredsRequest) String() string {
func (*GetPGCredsRequest) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -689,7 +869,7 @@ func (x *GetPGCredsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPGCredsRequest.ProtoReflect.Descriptor instead.
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 {
@@ -710,7 +890,7 @@ type GetPGCredsResponse struct {
func (x *GetPGCredsResponse) Reset() {
*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.StoreMessageInfo(mi)
}
@@ -722,7 +902,7 @@ func (x *GetPGCredsResponse) String() string {
func (*GetPGCredsResponse) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -735,7 +915,7 @@ func (x *GetPGCredsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPGCredsResponse.ProtoReflect.Descriptor instead.
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 {
@@ -756,7 +936,7 @@ type SetPGCredsRequest struct {
func (x *SetPGCredsRequest) Reset() {
*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.StoreMessageInfo(mi)
}
@@ -768,7 +948,7 @@ func (x *SetPGCredsRequest) String() string {
func (*SetPGCredsRequest) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -781,7 +961,7 @@ func (x *SetPGCredsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetPGCredsRequest.ProtoReflect.Descriptor instead.
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 {
@@ -807,7 +987,7 @@ type SetPGCredsResponse struct {
func (x *SetPGCredsResponse) Reset() {
*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.StoreMessageInfo(mi)
}
@@ -819,7 +999,7 @@ func (x *SetPGCredsResponse) String() string {
func (*SetPGCredsResponse) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -832,7 +1012,7 @@ func (x *SetPGCredsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetPGCredsResponse.ProtoReflect.Descriptor instead.
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
@@ -867,7 +1047,15 @@ const file_mcias_v1_account_proto_rawDesc = "" +
"\x0fSetRolesRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\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" +
"\x02id\x18\x01 \x01(\tR\x02id\"=\n" +
"\x12GetPGCredsResponse\x12'\n" +
@@ -875,7 +1063,7 @@ const file_mcias_v1_account_proto_rawDesc = "" +
"\x11SetPGCredsRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12'\n" +
"\x05creds\x18\x02 \x01(\v2\x11.mcias.v1.PGCredsR\x05creds\"\x14\n" +
"\x12SetPGCredsResponse2\xa4\x04\n" +
"\x12SetPGCredsResponse2\xb3\x05\n" +
"\x0eAccountService\x12M\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" +
@@ -884,7 +1072,10 @@ const file_mcias_v1_account_proto_rawDesc = "" +
"\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" +
"\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" +
"\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
}
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{
(*ListAccountsRequest)(nil), // 0: mcias.v1.ListAccountsRequest
(*ListAccountsResponse)(nil), // 1: mcias.v1.ListAccountsResponse
@@ -919,19 +1110,23 @@ var file_mcias_v1_account_proto_goTypes = []any{
(*GetRolesResponse)(nil), // 11: mcias.v1.GetRolesResponse
(*SetRolesRequest)(nil), // 12: mcias.v1.SetRolesRequest
(*SetRolesResponse)(nil), // 13: mcias.v1.SetRolesResponse
(*GetPGCredsRequest)(nil), // 14: mcias.v1.GetPGCredsRequest
(*GetPGCredsResponse)(nil), // 15: mcias.v1.GetPGCredsResponse
(*SetPGCredsRequest)(nil), // 16: mcias.v1.SetPGCredsRequest
(*SetPGCredsResponse)(nil), // 17: mcias.v1.SetPGCredsResponse
(*Account)(nil), // 18: mcias.v1.Account
(*PGCreds)(nil), // 19: mcias.v1.PGCreds
(*GrantRoleRequest)(nil), // 14: mcias.v1.GrantRoleRequest
(*GrantRoleResponse)(nil), // 15: mcias.v1.GrantRoleResponse
(*RevokeRoleRequest)(nil), // 16: mcias.v1.RevokeRoleRequest
(*RevokeRoleResponse)(nil), // 17: mcias.v1.RevokeRoleResponse
(*GetPGCredsRequest)(nil), // 18: mcias.v1.GetPGCredsRequest
(*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{
18, // 0: mcias.v1.ListAccountsResponse.accounts:type_name -> mcias.v1.Account
18, // 1: mcias.v1.CreateAccountResponse.account:type_name -> mcias.v1.Account
18, // 2: mcias.v1.GetAccountResponse.account:type_name -> mcias.v1.Account
19, // 3: mcias.v1.GetPGCredsResponse.creds:type_name -> mcias.v1.PGCreds
19, // 4: mcias.v1.SetPGCredsRequest.creds:type_name -> mcias.v1.PGCreds
22, // 0: mcias.v1.ListAccountsResponse.accounts:type_name -> mcias.v1.Account
22, // 1: mcias.v1.CreateAccountResponse.account:type_name -> mcias.v1.Account
22, // 2: mcias.v1.GetAccountResponse.account:type_name -> mcias.v1.Account
23, // 3: mcias.v1.GetPGCredsResponse.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
2, // 6: mcias.v1.AccountService.CreateAccount:input_type -> mcias.v1.CreateAccountRequest
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
10, // 10: mcias.v1.AccountService.GetRoles:input_type -> mcias.v1.GetRolesRequest
12, // 11: mcias.v1.AccountService.SetRoles:input_type -> mcias.v1.SetRolesRequest
14, // 12: mcias.v1.CredentialService.GetPGCreds:input_type -> mcias.v1.GetPGCredsRequest
16, // 13: mcias.v1.CredentialService.SetPGCreds:input_type -> mcias.v1.SetPGCredsRequest
1, // 14: mcias.v1.AccountService.ListAccounts:output_type -> mcias.v1.ListAccountsResponse
3, // 15: mcias.v1.AccountService.CreateAccount:output_type -> mcias.v1.CreateAccountResponse
5, // 16: mcias.v1.AccountService.GetAccount:output_type -> mcias.v1.GetAccountResponse
7, // 17: mcias.v1.AccountService.UpdateAccount:output_type -> mcias.v1.UpdateAccountResponse
9, // 18: mcias.v1.AccountService.DeleteAccount:output_type -> mcias.v1.DeleteAccountResponse
11, // 19: mcias.v1.AccountService.GetRoles:output_type -> mcias.v1.GetRolesResponse
13, // 20: mcias.v1.AccountService.SetRoles:output_type -> mcias.v1.SetRolesResponse
15, // 21: mcias.v1.CredentialService.GetPGCreds:output_type -> mcias.v1.GetPGCredsResponse
17, // 22: mcias.v1.CredentialService.SetPGCreds:output_type -> mcias.v1.SetPGCredsResponse
14, // [14:23] is the sub-list for method output_type
5, // [5:14] is the sub-list for method input_type
14, // 12: mcias.v1.AccountService.GrantRole:input_type -> mcias.v1.GrantRoleRequest
16, // 13: mcias.v1.AccountService.RevokeRole:input_type -> mcias.v1.RevokeRoleRequest
18, // 14: mcias.v1.CredentialService.GetPGCreds:input_type -> mcias.v1.GetPGCredsRequest
20, // 15: mcias.v1.CredentialService.SetPGCreds:input_type -> mcias.v1.SetPGCredsRequest
1, // 16: mcias.v1.AccountService.ListAccounts:output_type -> mcias.v1.ListAccountsResponse
3, // 17: mcias.v1.AccountService.CreateAccount:output_type -> mcias.v1.CreateAccountResponse
5, // 18: mcias.v1.AccountService.GetAccount:output_type -> mcias.v1.GetAccountResponse
7, // 19: mcias.v1.AccountService.UpdateAccount:output_type -> mcias.v1.UpdateAccountResponse
9, // 20: mcias.v1.AccountService.DeleteAccount:output_type -> mcias.v1.DeleteAccountResponse
11, // 21: mcias.v1.AccountService.GetRoles:output_type -> mcias.v1.GetRolesResponse
13, // 22: mcias.v1.AccountService.SetRoles:output_type -> mcias.v1.SetRolesResponse
15, // 23: mcias.v1.AccountService.GrantRole:output_type -> mcias.v1.GrantRoleResponse
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 extendee
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(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_account_proto_rawDesc), len(file_mcias_v1_account_proto_rawDesc)),
NumEnums: 0,
NumMessages: 18,
NumMessages: 22,
NumExtensions: 0,
NumServices: 2,
},

View File

@@ -29,6 +29,8 @@ const (
AccountService_DeleteAccount_FullMethodName = "/mcias.v1.AccountService/DeleteAccount"
AccountService_GetRoles_FullMethodName = "/mcias.v1.AccountService/GetRoles"
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.
@@ -44,6 +46,8 @@ type AccountServiceClient interface {
DeleteAccount(ctx context.Context, in *DeleteAccountRequest, opts ...grpc.CallOption) (*DeleteAccountResponse, error)
GetRoles(ctx context.Context, in *GetRolesRequest, opts ...grpc.CallOption) (*GetRolesResponse, 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 {
@@ -124,6 +128,26 @@ func (c *accountServiceClient) SetRoles(ctx context.Context, in *SetRolesRequest
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.
// All implementations must embed UnimplementedAccountServiceServer
// for forward compatibility.
@@ -137,6 +161,8 @@ type AccountServiceServer interface {
DeleteAccount(context.Context, *DeleteAccountRequest) (*DeleteAccountResponse, error)
GetRoles(context.Context, *GetRolesRequest) (*GetRolesResponse, error)
SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error)
GrantRole(context.Context, *GrantRoleRequest) (*GrantRoleResponse, error)
RevokeRole(context.Context, *RevokeRoleRequest) (*RevokeRoleResponse, error)
mustEmbedUnimplementedAccountServiceServer()
}
@@ -168,6 +194,12 @@ func (UnimplementedAccountServiceServer) GetRoles(context.Context, *GetRolesRequ
func (UnimplementedAccountServiceServer) SetRoles(context.Context, *SetRolesRequest) (*SetRolesResponse, error) {
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) testEmbeddedByValue() {}
@@ -315,6 +347,42 @@ func _AccountService_SetRoles_Handler(srv interface{}, ctx context.Context, dec
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.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -350,6 +418,14 @@ var AccountService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetRoles",
Handler: _AccountService_SetRoles_Handler,
},
{
MethodName: "GrantRole",
Handler: _AccountService_GrantRole_Handler,
},
{
MethodName: "RevokeRole",
Handler: _AccountService_RevokeRole_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "mcias/v1/account.proto",

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.33.4
// protoc v3.20.3
// source: mcias/v1/auth.proto
package mciasv1
@@ -304,9 +304,12 @@ func (x *RenewTokenResponse) GetExpiresAt() *timestamppb.Timestamp {
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 {
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
sizeCache protoimpl.SizeCache
}
@@ -341,6 +344,13 @@ func (*EnrollTOTPRequest) Descriptor() ([]byte, []int) {
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.
// Security: the secret is shown once; it is stored only in encrypted form.
type EnrollTOTPResponse struct {
@@ -578,8 +588,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\x12RenewTokenResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x129\n" +
"\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\x13\n" +
"\x11EnrollTOTPRequest\"M\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"/\n" +
"\x11EnrollTOTPRequest\x12\x1a\n" +
"\bpassword\x18\x01 \x01(\tR\bpassword\"M\n" +
"\x12EnrollTOTPResponse\x12\x16\n" +
"\x06secret\x18\x01 \x01(\tR\x06secret\x12\x1f\n" +
"\votpauth_uri\x18\x02 \x01(\tR\n" +

View File

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

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.33.4
// protoc v3.20.3
// source: mcias/v1/common.proto
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.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.33.4
// protoc v3.20.3
// source: mcias/v1/token.proto
package mciasv1

View File

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

15
go.mod
View File

@@ -4,10 +4,13 @@ go 1.26.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/crypto v0.33.0
golang.org/x/term v0.29.0
golang.org/x/crypto v0.45.0
golang.org/x/term v0.37.0
google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.7
modernc.org/sqlite v1.46.1
)
@@ -17,12 +20,10 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.68.0 // indirect
google.golang.org/protobuf v1.36.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

64
go.sum
View File

@@ -1,46 +1,78 @@
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=

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

@@ -200,19 +200,31 @@ func parsePHC(phc string) (ArgonParams, []byte, []byte, error) {
// ValidateTOTP checks a 6-digit TOTP code against a raw TOTP secret (bytes).
// A ±1 time-step window (±30s) is allowed to accommodate clock skew.
//
// Returns (true, counter, nil) on a valid code where counter is the HOTP
// counter value that matched. The caller MUST pass this counter to
// db.CheckAndUpdateTOTPCounter to prevent replay attacks within the validity
// window (CRIT-01).
//
// Security:
// - Comparison uses crypto/subtle.ConstantTimeCompare to resist timing attacks.
// - Only RFC 6238-compliant HOTP (HMAC-SHA1) is implemented; no custom crypto.
// - A ±1 window is the RFC 6238 recommendation; wider windows increase
// exposure to code interception between generation and submission.
func ValidateTOTP(secret []byte, code string) (bool, error) {
// - The returned counter enables replay prevention: callers store it and
// reject any future code that does not advance past it (RFC 6238 §5.2).
func ValidateTOTP(secret []byte, code string) (bool, int64, error) {
if len(code) != 6 {
return false, nil
return false, 0, nil
}
now := time.Now().Unix()
step := int64(30) // RFC 6238 default time step in seconds
// Security: evaluate all three counters with constant-time comparisons
// before returning. Early-exit would leak which counter matched via
// timing; we instead record the match and continue, returning at the end.
var matched bool
var matchedCounter int64
for _, counter := range []int64{
now/step - 1,
now / step,
@@ -220,14 +232,21 @@ func ValidateTOTP(secret []byte, code string) (bool, error) {
} {
expected, err := hotp(secret, uint64(counter)) //nolint:gosec // G115: counter is Unix time / step, always non-negative
if err != nil {
return false, fmt.Errorf("auth: compute TOTP: %w", err)
return false, 0, fmt.Errorf("auth: compute TOTP: %w", err)
}
// Security: constant-time comparison to prevent timing attack.
// We deliberately do NOT break early so that all three comparisons
// always execute, preventing a timing side-channel on which counter
// slot matched.
if subtle.ConstantTimeCompare([]byte(code), []byte(expected)) == 1 {
return true, nil
matched = true
matchedCounter = counter
}
}
return false, nil
if matched {
return true, matchedCounter, nil
}
return false, 0, nil
}
// hotp computes an HMAC-SHA1-based OTP for a given counter value.

View File

@@ -101,13 +101,16 @@ func TestValidateTOTP(t *testing.T) {
t.Fatalf("hotp: %v", err)
}
ok, err := ValidateTOTP(rawSecret, code)
ok, counter, err := ValidateTOTP(rawSecret, code)
if err != nil {
t.Fatalf("ValidateTOTP: %v", err)
}
if !ok {
t.Errorf("ValidateTOTP rejected a valid code %q", code)
}
if ok && counter == 0 {
t.Errorf("ValidateTOTP returned zero counter for valid code")
}
}
// TestValidateTOTPWrongCode verifies that an incorrect code is rejected.
@@ -117,7 +120,7 @@ func TestValidateTOTPWrongCode(t *testing.T) {
t.Fatalf("GenerateTOTPSecret: %v", err)
}
ok, err := ValidateTOTP(rawSecret, "000000")
ok, _, err := ValidateTOTP(rawSecret, "000000")
if err != nil {
t.Fatalf("ValidateTOTP: %v", err)
}
@@ -135,7 +138,7 @@ func TestValidateTOTPWrongLength(t *testing.T) {
}
for _, code := range []string{"", "12345", "1234567", "abcdef"} {
ok, err := ValidateTOTP(rawSecret, code)
ok, _, err := ValidateTOTP(rawSecret, code)
if err != nil {
t.Errorf("ValidateTOTP(%q): unexpected error: %v", code, err)
}

View File

@@ -6,6 +6,7 @@ package config
import (
"errors"
"fmt"
"net"
"os"
"time"
@@ -30,6 +31,17 @@ type ServerConfig struct {
GRPCAddr string `toml:"grpc_addr"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
// TrustedProxy is the IP address (not a range) of a reverse proxy that
// sits in front of the server and sets X-Forwarded-For or X-Real-IP
// headers. When set, the rate limiter and audit log extract the real
// client IP from these headers instead of r.RemoteAddr.
//
// Security: only requests whose r.RemoteAddr matches TrustedProxy are
// trusted to carry a valid forwarded-IP header. All other requests use
// r.RemoteAddr directly, so this field cannot be exploited for IP
// spoofing by external clients. Omit or leave empty when running
// without a reverse proxy.
TrustedProxy string `toml:"trusted_proxy"`
}
// DatabaseConfig holds SQLite database settings.
@@ -63,7 +75,7 @@ type MasterKeyConfig struct {
}
// duration is a wrapper around time.Duration that supports TOML string parsing
// (e.g. "720h", "8h").
// (e.g. "168h", "8h").
type duration struct {
time.Duration
}
@@ -137,6 +149,14 @@ func (c *Config) validate() error {
if c.Server.TLSKey == "" {
errs = append(errs, errors.New("server.tls_key is required"))
}
// Security (DEF-03): if trusted_proxy is set it must be a valid IP address
// (not a hostname or CIDR) so the middleware can compare it to the parsed
// host part of r.RemoteAddr using a reliable byte-level equality check.
if c.Server.TrustedProxy != "" {
if net.ParseIP(c.Server.TrustedProxy) == nil {
errs = append(errs, fmt.Errorf("server.trusted_proxy %q is not a valid IP address", c.Server.TrustedProxy))
}
}
// Database
if c.Database.Path == "" {
@@ -147,14 +167,31 @@ func (c *Config) validate() error {
if c.Tokens.Issuer == "" {
errs = append(errs, errors.New("tokens.issuer is required"))
}
// Security (DEF-05): enforce both lower and upper bounds on token expiry
// durations. An operator misconfiguration could otherwise produce tokens
// valid for centuries, which would be irrevocable (bar explicit JTI
// revocation) if a token were stolen. Upper bounds are intentionally
// generous to accommodate a range of legitimate deployments while
// catching obvious typos (e.g. "876000h" instead of "8760h").
const (
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
maxAdminExpiry = 24 * time.Hour // 24 hours
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
)
if c.Tokens.DefaultExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.default_expiry must be positive"))
} else if c.Tokens.DefaultExpiry.Duration > maxDefaultExpiry {
errs = append(errs, fmt.Errorf("tokens.default_expiry must be <= %s (got %s)", maxDefaultExpiry, c.Tokens.DefaultExpiry.Duration))
}
if c.Tokens.AdminExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.admin_expiry must be positive"))
} else if c.Tokens.AdminExpiry.Duration > maxAdminExpiry {
errs = append(errs, fmt.Errorf("tokens.admin_expiry must be <= %s (got %s)", maxAdminExpiry, c.Tokens.AdminExpiry.Duration))
}
if c.Tokens.ServiceExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.service_expiry must be positive"))
} else if c.Tokens.ServiceExpiry.Duration > maxServiceExpiry {
errs = append(errs, fmt.Errorf("tokens.service_expiry must be <= %s (got %s)", maxServiceExpiry, c.Tokens.ServiceExpiry.Duration))
}
// Argon2 — enforce OWASP 2023 minimums (time=2, memory=65536 KiB).

View File

@@ -210,6 +210,40 @@ threads = 4
}
}
// TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP.
func TestTrustedProxyValidation(t *testing.T) {
tests := []struct {
name string
proxy string
wantErr bool
}{
{"empty is valid (disabled)", "", false},
{"valid IPv4", "127.0.0.1", false},
{"valid IPv6 loopback", "::1", false},
{"valid private IPv4", "10.0.0.1", false},
{"hostname rejected", "proxy.example.com", true},
{"CIDR rejected", "10.0.0.0/8", true},
{"garbage rejected", "not-an-ip", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg, _ := Load(writeTempConfig(t, validConfig()))
if cfg == nil {
t.Fatal("baseline config load failed")
}
cfg.Server.TrustedProxy = tc.proxy
err := cfg.validate()
if tc.wantErr && err == nil {
t.Errorf("expected validation error for proxy=%q, got nil", tc.proxy)
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error for proxy=%q: %v", tc.proxy, err)
}
})
}
}
func TestDurationParsing(t *testing.T) {
var d duration
if err := d.UnmarshalText([]byte("1h30m")); err != nil {

View File

@@ -70,7 +70,10 @@ func (db *DB) GetAccountByID(id int64) (*model.Account, error) {
`, id))
}
// GetAccountByUsername retrieves an account by username (case-insensitive).
// GetAccountByUsername retrieves an account by username.
// Matching is case-sensitive: SQLite uses BINARY collation by default, so
// "admin" and "Admin" are distinct usernames. This is intentional for an
// SSO system where usernames should be treated as opaque identifiers.
// Returns ErrNotFound if no matching account exists.
func (db *DB) GetAccountByUsername(username string) (*model.Account, error) {
return db.scanAccount(db.sql.QueryRow(`
@@ -128,14 +131,23 @@ func (db *DB) UpdateAccountStatus(accountID int64, status model.AccountStatus) e
}
// UpdatePasswordHash updates the Argon2id password hash for an account.
// Returns ErrNotFound if no active account with the given ID exists, consistent
// with the RowsAffected checks in RevokeToken and RenewToken.
func (db *DB) UpdatePasswordHash(accountID int64, hash string) error {
_, err := db.sql.Exec(`
result, err := db.sql.Exec(`
UPDATE accounts SET password_hash = ?, updated_at = ?
WHERE id = ?
`, hash, now(), accountID)
if err != nil {
return fmt.Errorf("db: update password hash: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: update password hash rows affected: %w", err)
}
if rows == 0 {
return ErrNotFound
}
return nil
}
@@ -175,6 +187,46 @@ func (db *DB) SetTOTP(accountID int64, secretEnc, secretNonce []byte) error {
return nil
}
// CheckAndUpdateTOTPCounter atomically verifies that counter is strictly
// greater than the last accepted TOTP counter for the account, and if so,
// stores counter as the new last accepted value.
//
// Returns ErrTOTPReplay if counter ≤ the stored value, preventing a replay
// of a previously accepted code within the ±1 time-step validity window.
// On the first successful TOTP login (stored value NULL) any counter is
// accepted.
//
// Security (CRIT-01): RFC 6238 §5.2 recommends recording the last OTP
// counter used and rejecting any code that does not advance it. Without
// this, an intercepted code remains valid for up to 90 seconds. The update
// is performed in a single parameterized SQL statement, so there is no
// TOCTOU window between the check and the write.
func (db *DB) CheckAndUpdateTOTPCounter(accountID int64, counter int64) error {
result, err := db.sql.Exec(`
UPDATE accounts
SET last_totp_counter = ?, updated_at = ?
WHERE id = ?
AND (last_totp_counter IS NULL OR last_totp_counter < ?)
`, counter, now(), accountID, counter)
if err != nil {
return fmt.Errorf("db: check-and-update TOTP counter: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: check-and-update TOTP counter rows affected: %w", err)
}
if rows == 0 {
// Security: the counter was not advanced — this code has already been
// used within its validity window. Treat as authentication failure.
return ErrTOTPReplay
}
return nil
}
// ErrTOTPReplay is returned by CheckAndUpdateTOTPCounter when the submitted
// TOTP code corresponds to a counter value that has already been accepted.
var ErrTOTPReplay = errors.New("db: TOTP code already used (replay)")
// ClearTOTP removes the TOTP secret and disables TOTP requirement.
func (db *DB) ClearTOTP(accountID int64) error {
_, err := db.sql.Exec(`
@@ -291,6 +343,12 @@ func (db *DB) GetRoles(accountID int64) ([]string, error) {
// GrantRole adds a role to an account. If the role already exists, it is a no-op.
func (db *DB) GrantRole(accountID int64, role string, grantedBy *int64) error {
// Security (DEF-10): reject unknown roles before writing to the DB so
// that typos (e.g. "admim") are caught immediately rather than silently
// creating an unmatchable role.
if err := model.ValidateRole(role); err != nil {
return err
}
_, err := db.sql.Exec(`
INSERT OR IGNORE INTO account_roles (account_id, role, granted_by, granted_at)
VALUES (?, ?, ?, ?)
@@ -314,6 +372,14 @@ func (db *DB) RevokeRole(accountID int64, role string) error {
// SetRoles replaces the full role set for an account atomically.
func (db *DB) SetRoles(accountID int64, roles []string, grantedBy *int64) error {
// Security (DEF-10): validate all roles before opening the transaction so
// we fail fast without touching the database on an invalid input.
for _, role := range roles {
if err := model.ValidateRole(role); err != nil {
return err
}
}
tx, err := db.sql.Begin()
if err != nil {
return fmt.Errorf("db: set roles begin tx: %w", err)
@@ -450,16 +516,17 @@ func (db *DB) WritePGCredentials(accountID int64, host string, port int, dbName,
func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
var cred model.PGCredential
var createdAtStr, updatedAtStr string
var ownerID sql.NullInt64
err := db.sql.QueryRow(`
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username,
pg_password_enc, pg_password_nonce, created_at, updated_at
pg_password_enc, pg_password_nonce, created_at, updated_at, owner_id
FROM pg_credentials WHERE account_id = ?
`, accountID).Scan(
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
&cred.PGDatabase, &cred.PGUsername,
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
&createdAtStr, &updatedAtStr,
&createdAtStr, &updatedAtStr, &ownerID,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -476,6 +543,10 @@ func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
if err != nil {
return nil, err
}
if ownerID.Valid {
v := ownerID.Int64
cred.OwnerID = &v
}
return &cred, nil
}
@@ -621,6 +692,70 @@ func (db *DB) RenewToken(oldJTI, reason, newJTI string, accountID int64, issuedA
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.
func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
n := now()
@@ -635,6 +770,23 @@ func (db *DB) RevokeAllUserTokens(accountID int64, reason string) error {
return nil
}
// RevokeAllUserTokensExcept revokes all non-expired, non-revoked tokens for an
// account except for the token identified by exceptJTI. Used by the
// self-service password change flow to invalidate all other sessions while
// keeping the caller's current session active.
func (db *DB) RevokeAllUserTokensExcept(accountID int64, exceptJTI, reason string) error {
n := now()
_, err := db.sql.Exec(`
UPDATE token_revocation
SET revoked_at = ?, revoke_reason = ?
WHERE account_id = ? AND jti != ? AND revoked_at IS NULL AND expires_at > ?
`, n, nullString(reason), accountID, exceptJTI, n)
if err != nil {
return fmt.Errorf("db: revoke all tokens except %q for account %d: %w", exceptJTI, accountID, err)
}
return nil
}
// PruneExpiredTokens removes token_revocation rows that are past their expiry.
// Returns the number of rows deleted.
func (db *DB) PruneExpiredTokens() (int64, error) {

View File

@@ -12,19 +12,36 @@ import (
"database/sql"
"errors"
"fmt"
"sync/atomic"
"time"
_ "modernc.org/sqlite" // register the sqlite3 driver
)
// memCounter generates unique names for in-memory shared-cache databases.
var memCounter atomic.Int64
// DB wraps a *sql.DB with MCIAS-specific helpers.
type DB struct {
sql *sql.DB
// path is the DSN used to open this database. For in-memory databases
// (originally ":memory:") it is a unique shared-cache URI of the form
// file:mcias_N?mode=memory&cache=shared so that a second connection can be
// opened to the same in-memory database (needed by the migration runner).
path string
}
// Open opens (or creates) the SQLite database at path and configures it for
// MCIAS use (WAL mode, foreign keys, busy timeout).
func Open(path string) (*DB, error) {
// Translate bare ":memory:" to a named shared-cache in-memory URI.
// This allows the migration runner to open a second connection to the
// same in-memory database without sharing the *sql.DB handle (which
// would be closed by golang-migrate when the migrator is done).
if path == ":memory:" {
path = fmt.Sprintf("file:mcias_%d?mode=memory&cache=shared", memCounter.Add(1))
}
// The modernc.org/sqlite driver is registered as "sqlite".
sqlDB, err := sql.Open("sqlite", path)
if err != nil {
@@ -34,7 +51,7 @@ func Open(path string) (*DB, error) {
// Use a single connection for writes; reads can use the pool.
sqlDB.SetMaxOpenConns(1)
db := &DB{sql: sqlDB}
db := &DB{sql: sqlDB, path: path}
if err := db.configure(); err != nil {
_ = sqlDB.Close()
return nil, err
@@ -48,7 +65,14 @@ func (db *DB) configure() error {
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
"PRAGMA synchronous=NORMAL",
// Security (DEF-07): FULL synchronous mode ensures every write is
// flushed to disk before SQLite considers it committed. With WAL
// mode + NORMAL, a power failure between a write and the next
// checkpoint could lose the most recent committed transactions,
// including token issuance and revocation records — which must be
// durable. The performance cost is negligible for a single-node
// personal SSO server.
"PRAGMA synchronous=FULL",
}
for _, p := range pragmas {
if _, err := db.sql.Exec(p); err != nil {

View File

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

View File

@@ -2,239 +2,188 @@ package db
import (
"database/sql"
"embed"
"errors"
"fmt"
"strings"
"github.com/golang-migrate/migrate/v4"
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite" // driver registration
)
// migration represents a single schema migration with an ID and SQL statement.
type migration struct {
sql string
id int
}
// migrationsFS embeds all migration SQL files from the migrations/ directory.
// Each file is named NNN_description.up.sql (and optionally .down.sql).
//
//go:embed migrations/*.sql
var migrationsFS embed.FS
// migrations is the ordered list of schema migrations applied to the database.
// Once applied, migrations must never be modified — only new ones appended.
var migrations = []migration{
{
id: 1,
sql: `
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
// LatestSchemaVersion is the highest migration version defined in the
// migrations/ directory. Update this constant whenever a new migration file
// is added.
const LatestSchemaVersion = 7
CREATE TABLE IF NOT EXISTS server_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
signing_key_enc BLOB,
signing_key_nonce BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
// files. It opens a dedicated *sql.DB using the same DSN as the main
// database so that calling m.Close() (which closes the underlying connection)
// does not affect the caller's main database connection.
//
// Security: migration SQL is embedded at compile time from the migrations/
// directory and is never loaded from the filesystem at runtime, preventing
// injection of arbitrary SQL via a compromised working directory.
func newMigrate(database *DB) (*migrate.Migrate, error) {
src, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return nil, fmt.Errorf("db: create migration source: %w", err)
}
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
account_type TEXT NOT NULL CHECK (account_type IN ('human','system')),
password_hash TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','inactive','deleted')),
totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)),
totp_secret_enc BLOB,
totp_secret_nonce BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
deleted_at TEXT
);
// Open a dedicated connection for the migrator. golang-migrate's sqlite
// driver calls db.Close() when the migrator is closed; using a dedicated
// connection (same DSN, different *sql.DB) prevents it from closing the
// shared connection. For in-memory databases, Open() translates
// ":memory:" to a named shared-cache URI so both connections see the same
// data.
migrateDB, err := sql.Open("sqlite", database.path)
if err != nil {
return nil, fmt.Errorf("db: open migration connection: %w", err)
}
migrateDB.SetMaxOpenConns(1)
if _, err := migrateDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
_ = migrateDB.Close()
return nil, fmt.Errorf("db: migration connection pragma: %w", err)
}
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts (username);
CREATE INDEX IF NOT EXISTS idx_accounts_uuid ON accounts (uuid);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status);
driver, err := sqlitedriver.WithInstance(migrateDB, &sqlitedriver.Config{
MigrationsTable: "schema_migrations",
})
if err != nil {
_ = migrateDB.Close()
return nil, fmt.Errorf("db: create migration driver: %w", err)
}
CREATE TABLE IF NOT EXISTS account_roles (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
role TEXT NOT NULL,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (account_id, role)
);
CREATE INDEX IF NOT EXISTS idx_account_roles_account ON account_roles (account_id);
CREATE TABLE IF NOT EXISTS token_revocation (
id INTEGER PRIMARY KEY,
jti TEXT NOT NULL UNIQUE,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
revoke_reason TEXT,
issued_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_token_jti ON token_revocation (jti);
CREATE INDEX IF NOT EXISTS idx_token_account ON token_revocation (account_id);
CREATE INDEX IF NOT EXISTS idx_token_expires ON token_revocation (expires_at);
CREATE TABLE IF NOT EXISTS system_tokens (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
jti TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS pg_credentials (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
pg_host TEXT NOT NULL,
pg_port INTEGER NOT NULL DEFAULT 5432,
pg_database TEXT NOT NULL,
pg_username TEXT NOT NULL,
pg_password_enc BLOB NOT NULL,
pg_password_nonce BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY,
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
event_type TEXT NOT NULL,
actor_id INTEGER REFERENCES accounts(id),
target_id INTEGER REFERENCES accounts(id),
ip_address TEXT,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_log (event_time);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);
`,
},
{
id: 2,
sql: `
-- Add master_key_salt to server_config for Argon2id KDF salt storage.
-- The salt must be stable across restarts so the passphrase always yields the same key.
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;
`,
},
{
id: 3,
sql: `
-- Track per-account failed login attempts for lockout enforcement (F-08).
-- One row per account; window_start resets when the window expires or on
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
CREATE TABLE IF NOT EXISTS failed_logins (
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
window_start TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 1
);
`,
},
{
id: 4,
sql: `
-- Machine/service tags on accounts (many-to-many).
-- Used by the policy engine to gate access by machine or service identity
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
CREATE TABLE IF NOT EXISTS account_tags (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
PRIMARY KEY (account_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
-- Policy rules stored in the database and evaluated in-process.
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
-- Built-in default rules are compiled into the binary and are not stored here.
-- Rows with enabled=0 are loaded but skipped during evaluation.
CREATE TABLE IF NOT EXISTS policy_rules (
id INTEGER PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL,
rule_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
`,
},
}
// LatestSchemaVersion is the highest migration ID in the migrations list.
// It is updated automatically when new migrations are appended.
var LatestSchemaVersion = migrations[len(migrations)-1].id
// SchemaVersion returns the current applied schema version of the database.
// Returns 0 if no migrations have been applied yet.
func SchemaVersion(database *DB) (int, error) {
return currentSchemaVersion(database.sql)
m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
if err != nil {
return nil, fmt.Errorf("db: initialise migrator: %w", err)
}
return m, nil
}
// Migrate applies any unapplied schema migrations to the database in order.
// It is idempotent: running it multiple times is safe.
func Migrate(db *DB) error {
// Ensure the schema_version table exists first.
if _, err := db.sql.Exec(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
)
`); err != nil {
return fmt.Errorf("db: ensure schema_version: %w", err)
}
currentVersion, err := currentSchemaVersion(db.sql)
// It is idempotent: running it on an already-current database is safe and
// returns nil.
//
// Existing databases that were migrated by the previous hand-rolled runner
// (schema_version table) are handled by the compatibility shim below: the
// legacy version is read and used to fast-forward the golang-migrate state
// before calling Up, so no migration is applied twice.
func Migrate(database *DB) error {
// Compatibility shim: if the database was previously migrated by the
// hand-rolled runner it has a schema_version table with the current
// version. Inform golang-migrate of the existing version so it does
// not try to re-apply already-applied migrations.
legacyVersion, err := legacySchemaVersion(database)
if err != nil {
return fmt.Errorf("db: get current schema version: %w", err)
return fmt.Errorf("db: read legacy schema version: %w", err)
}
for _, m := range migrations {
if m.id <= currentVersion {
continue
}
m, err := newMigrate(database)
if err != nil {
return err
}
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
tx, err := db.sql.Begin()
if err != nil {
return fmt.Errorf("db: begin migration %d transaction: %w", m.id, err)
}
if _, err := tx.Exec(m.sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: apply migration %d: %w", m.id, err)
}
// Update the schema version within the same transaction.
if currentVersion == 0 {
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.id); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: insert schema version %d: %w", m.id, err)
}
} else {
if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, m.id); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: update schema version to %d: %w", m.id, err)
if legacyVersion > 0 {
// Only fast-forward from the legacy version when golang-migrate has no
// version record of its own yet (ErrNilVersion). If schema_migrations
// already has an entry — including a dirty entry from a previously
// failed migration — leave it alone and let golang-migrate handle it.
// Overriding a non-nil version would discard progress (or a dirty
// state that needs idempotent re-application) and cause migrations to
// be retried unnecessarily.
_, _, versionErr := m.Version()
if errors.Is(versionErr, migrate.ErrNilVersion) {
if err := m.Force(legacyVersion); err != nil {
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: commit migration %d: %w", m.id, err)
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
// A "duplicate column name" error means the failing migration is an
// ADD COLUMN that was already applied outside the migration runner
// (common during development before a migration file existed).
// If this is the last migration and its version matches LatestSchemaVersion,
// force it clean so subsequent starts succeed.
//
// This is intentionally narrow: we only suppress the error when the
// dirty version equals the latest known version, preventing accidental
// masking of errors in intermediate migrations.
if strings.Contains(err.Error(), "duplicate column name") {
v, dirty, verErr := m.Version()
if verErr == nil && dirty && int(v) == LatestSchemaVersion { //nolint:gosec // G115: safe conversion
if forceErr := m.Force(LatestSchemaVersion); forceErr != nil {
return fmt.Errorf("db: force after duplicate column: %w", forceErr)
}
return nil
}
}
currentVersion = m.id
return fmt.Errorf("db: apply migrations: %w", err)
}
return nil
}
// currentSchemaVersion returns the current schema version, or 0 if none applied.
func currentSchemaVersion(db *sql.DB) (int, error) {
var version int
err := db.QueryRow(`SELECT version FROM schema_version LIMIT 1`).Scan(&version)
// ForceSchemaVersion marks the database as being at the given version without
// running any SQL. This is a break-glass operation: use it to clear a dirty
// migration state after verifying (or manually applying) the migration SQL.
//
// Passing a version that has never been recorded by golang-migrate is safe;
// it simply sets the version and clears the dirty flag. The next call to
// Migrate will apply any versions higher than the forced one.
func ForceSchemaVersion(database *DB, version int) error {
m, err := newMigrate(database)
if err != nil {
// No rows means version 0 (fresh database).
return err
}
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
if err := m.Force(version); err != nil {
return fmt.Errorf("db: force schema version %d: %w", version, err)
}
return nil
}
// SchemaVersion returns the current applied schema version of the database.
// Returns 0 if no migrations have been applied yet.
func SchemaVersion(database *DB) (int, error) {
m, err := newMigrate(database)
if err != nil {
return 0, err
}
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
v, _, err := m.Version()
if errors.Is(err, migrate.ErrNilVersion) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("db: read schema version: %w", err)
}
// Security: v is a migration version number (small positive integer);
// the uint→int conversion is safe for any realistic schema version count.
return int(v), nil //nolint:gosec // G115: migration version is always a small positive integer
}
// legacySchemaVersion reads the version from the old schema_version table
// created by the hand-rolled migration runner. Returns 0 if the table does
// not exist (fresh database or already migrated to golang-migrate only).
func legacySchemaVersion(database *DB) (int, error) {
var version int
err := database.sql.QueryRow(
`SELECT version FROM schema_version LIMIT 1`,
).Scan(&version)
if err != nil {
// Table does not exist or is empty — treat as version 0.
return 0, nil //nolint:nilerr
}
return version, nil

View File

@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
signing_key_enc BLOB,
signing_key_nonce BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
account_type TEXT NOT NULL CHECK (account_type IN ('human','system')),
password_hash TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','inactive','deleted')),
totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)),
totp_secret_enc BLOB,
totp_secret_nonce BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts (username);
CREATE INDEX IF NOT EXISTS idx_accounts_uuid ON accounts (uuid);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status);
CREATE TABLE IF NOT EXISTS account_roles (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
role TEXT NOT NULL,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (account_id, role)
);
CREATE INDEX IF NOT EXISTS idx_account_roles_account ON account_roles (account_id);
CREATE TABLE IF NOT EXISTS token_revocation (
id INTEGER PRIMARY KEY,
jti TEXT NOT NULL UNIQUE,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
revoke_reason TEXT,
issued_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_token_jti ON token_revocation (jti);
CREATE INDEX IF NOT EXISTS idx_token_account ON token_revocation (account_id);
CREATE INDEX IF NOT EXISTS idx_token_expires ON token_revocation (expires_at);
CREATE TABLE IF NOT EXISTS system_tokens (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
jti TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS pg_credentials (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
pg_host TEXT NOT NULL,
pg_port INTEGER NOT NULL DEFAULT 5432,
pg_database TEXT NOT NULL,
pg_username TEXT NOT NULL,
pg_password_enc BLOB NOT NULL,
pg_password_nonce BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY,
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
event_type TEXT NOT NULL,
actor_id INTEGER REFERENCES accounts(id),
target_id INTEGER REFERENCES accounts(id),
ip_address TEXT,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_log (event_time);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);

View File

@@ -0,0 +1,4 @@
-- Add master_key_salt to server_config for Argon2id KDF salt storage.
-- The salt must be stable across restarts so the passphrase always yields the same key.
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;

View File

@@ -0,0 +1,8 @@
-- Track per-account failed login attempts for lockout enforcement (F-08).
-- One row per account; window_start resets when the window expires or on
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
CREATE TABLE IF NOT EXISTS failed_logins (
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
window_start TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 1
);

View File

@@ -0,0 +1,26 @@
-- Machine/service tags on accounts (many-to-many).
-- Used by the policy engine to gate access by machine or service identity
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
CREATE TABLE IF NOT EXISTS account_tags (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
PRIMARY KEY (account_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
-- Policy rules stored in the database and evaluated in-process.
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
-- Built-in default rules are compiled into the binary and are not stored here.
-- Rows with enabled=0 are loaded but skipped during evaluation.
CREATE TABLE IF NOT EXISTS policy_rules (
id INTEGER PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL,
rule_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

View File

@@ -0,0 +1,24 @@
-- Track which accounts own each set of pg_credentials and which other
-- accounts have been granted read access to them.
--
-- owner_id: the account that administers the credentials and may grant/revoke
-- access. Defaults to the system account itself. This column is
-- nullable so that rows created before migration 5 are not broken.
ALTER TABLE pg_credentials ADD COLUMN owner_id INTEGER REFERENCES accounts(id);
-- pg_credential_access records an explicit "all-or-nothing" read grant from
-- the credential owner to another account. Grantees may view connection
-- metadata (host, port, database, username) but the password is never
-- decrypted for them in the UI. Only the owner may update or delete the
-- credential set.
CREATE TABLE IF NOT EXISTS pg_credential_access (
id INTEGER PRIMARY KEY,
credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE,
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (credential_id, grantee_id)
);
CREATE INDEX IF NOT EXISTS idx_pgcred_access_cred ON pg_credential_access (credential_id);
CREATE INDEX IF NOT EXISTS idx_pgcred_access_grantee ON pg_credential_access (grantee_id);

View File

@@ -0,0 +1,6 @@
-- Add optional time-scoped validity window to policy rules.
-- NULL means "no constraint" (rule is always active / never expires).
-- The policy engine skips rules where not_before > now() or expires_at <= now()
-- at cache-load time (SetRules), not at query time.
ALTER TABLE policy_rules ADD COLUMN not_before TEXT DEFAULT NULL;
ALTER TABLE policy_rules ADD COLUMN expires_at TEXT DEFAULT NULL;

View File

@@ -0,0 +1,9 @@
-- Add last_totp_counter to track the most recently accepted TOTP counter value
-- per account. This is used to prevent TOTP replay attacks within the ±1
-- time-step validity window. NULL means no TOTP code has ever been accepted
-- for this account (fresh enrollment or TOTP not yet used).
--
-- Security (CRIT-01): RFC 6238 §5.2 recommends recording the last OTP counter
-- used and rejecting codes that do not advance it, eliminating the ~90-second
-- replay window that would otherwise be exploitable.
ALTER TABLE accounts ADD COLUMN last_totp_counter INTEGER DEFAULT NULL;

View File

@@ -0,0 +1,247 @@
package db
import (
"database/sql"
"errors"
"fmt"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// ListCredentialedAccountIDs returns the set of account IDs that already have
// a pg_credentials row. Used to filter the "uncredentialed system accounts"
// list on the /pgcreds create form without leaking credential content.
func (db *DB) ListCredentialedAccountIDs() (map[int64]struct{}, error) {
rows, err := db.sql.Query(`SELECT account_id FROM pg_credentials`)
if err != nil {
return nil, fmt.Errorf("db: list credentialed account ids: %w", err)
}
defer func() { _ = rows.Close() }()
ids := make(map[int64]struct{})
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("db: scan credentialed account id: %w", err)
}
ids[id] = struct{}{}
}
return ids, rows.Err()
}
// SetPGCredentialOwner records the owning account for a pg_credentials row.
// This is called on first write so that pre-migration rows retain a nil owner.
// It is idempotent: if the owner is already set it is overwritten.
func (db *DB) SetPGCredentialOwner(credentialID, ownerID int64) error {
_, err := db.sql.Exec(`
UPDATE pg_credentials SET owner_id = ? WHERE id = ?
`, ownerID, credentialID)
if err != nil {
return fmt.Errorf("db: set pg credential owner: %w", err)
}
return nil
}
// GetPGCredentialByID retrieves a single pg_credentials row by its primary key.
// Returns ErrNotFound if no such credential exists.
func (db *DB) GetPGCredentialByID(id int64) (*model.PGCredential, error) {
var cred model.PGCredential
var createdAtStr, updatedAtStr string
var ownerID sql.NullInt64
err := db.sql.QueryRow(`
SELECT p.id, p.account_id, p.pg_host, p.pg_port, p.pg_database, p.pg_username,
p.pg_password_enc, p.pg_password_nonce, p.created_at, p.updated_at, p.owner_id
FROM pg_credentials p WHERE p.id = ?
`, id).Scan(
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
&cred.PGDatabase, &cred.PGUsername,
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
&createdAtStr, &updatedAtStr, &ownerID,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("db: get pg credential by id: %w", err)
}
cred.CreatedAt, err = parseTime(createdAtStr)
if err != nil {
return nil, err
}
cred.UpdatedAt, err = parseTime(updatedAtStr)
if err != nil {
return nil, err
}
if ownerID.Valid {
v := ownerID.Int64
cred.OwnerID = &v
}
return &cred, nil
}
// GrantPGCredAccess grants an account read access to a pg_credentials set.
// If the grant already exists the call is a no-op (UNIQUE constraint).
// grantedBy may be nil if the grant is made programmatically.
func (db *DB) GrantPGCredAccess(credentialID, granteeID int64, grantedBy *int64) error {
n := now()
_, err := db.sql.Exec(`
INSERT INTO pg_credential_access (credential_id, grantee_id, granted_by, granted_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(credential_id, grantee_id) DO NOTHING
`, credentialID, granteeID, grantedBy, n)
if err != nil {
return fmt.Errorf("db: grant pg cred access: %w", err)
}
return nil
}
// RevokePGCredAccess removes a grantee's access to a pg_credentials set.
func (db *DB) RevokePGCredAccess(credentialID, granteeID int64) error {
_, err := db.sql.Exec(`
DELETE FROM pg_credential_access WHERE credential_id = ? AND grantee_id = ?
`, credentialID, granteeID)
if err != nil {
return fmt.Errorf("db: revoke pg cred access: %w", err)
}
return nil
}
// ListPGCredAccess returns all access grants for a pg_credentials set,
// joining against accounts to populate grantee username and UUID.
func (db *DB) ListPGCredAccess(credentialID int64) ([]*model.PGCredAccessGrant, error) {
rows, err := db.sql.Query(`
SELECT pca.id, pca.credential_id, pca.grantee_id, pca.granted_by, pca.granted_at,
a.uuid, a.username
FROM pg_credential_access pca
JOIN accounts a ON a.id = pca.grantee_id
WHERE pca.credential_id = ?
ORDER BY pca.granted_at ASC
`, credentialID)
if err != nil {
return nil, fmt.Errorf("db: list pg cred access: %w", err)
}
defer func() { _ = rows.Close() }()
var grants []*model.PGCredAccessGrant
for rows.Next() {
g, err := scanPGCredAccessGrant(rows)
if err != nil {
return nil, err
}
grants = append(grants, g)
}
return grants, rows.Err()
}
// CheckPGCredAccess reports whether accountID has an explicit access grant for
// credentialID. The credential owner always has access implicitly; callers
// must check ownership separately.
func (db *DB) CheckPGCredAccess(credentialID, accountID int64) (bool, error) {
var count int
err := db.sql.QueryRow(`
SELECT COUNT(*) FROM pg_credential_access
WHERE credential_id = ? AND grantee_id = ?
`, credentialID, accountID).Scan(&count)
if err != nil {
return false, fmt.Errorf("db: check pg cred access: %w", err)
}
return count > 0, nil
}
// PGCredWithAccount extends PGCredential with the owning system account's
// username, used for the "My PG Credentials" listing view.
type PGCredWithAccount struct {
model.PGCredential
}
// ListAccessiblePGCreds returns all pg_credentials rows that accountID may
// view: those where accountID is the owner, plus those where an explicit
// access grant exists. The ServiceUsername and ServiceAccountUUID fields are
// populated from the owning system account for display and navigation.
func (db *DB) ListAccessiblePGCreds(accountID int64) ([]*model.PGCredential, error) {
rows, err := db.sql.Query(`
SELECT p.id, p.account_id, p.pg_host, p.pg_port, p.pg_database, p.pg_username,
p.pg_password_enc, p.pg_password_nonce, p.created_at, p.updated_at, p.owner_id,
a.username, a.uuid
FROM pg_credentials p
JOIN accounts a ON a.id = p.account_id
WHERE p.owner_id = ?
OR EXISTS (
SELECT 1 FROM pg_credential_access pca
WHERE pca.credential_id = p.id AND pca.grantee_id = ?
)
ORDER BY a.username ASC
`, accountID, accountID)
if err != nil {
return nil, fmt.Errorf("db: list accessible pg creds: %w", err)
}
defer func() { _ = rows.Close() }()
var creds []*model.PGCredential
for rows.Next() {
cred, err := scanPGCredWithUsername(rows)
if err != nil {
return nil, err
}
creds = append(creds, cred)
}
return creds, rows.Err()
}
func scanPGCredWithUsername(rows *sql.Rows) (*model.PGCredential, error) {
var cred model.PGCredential
var createdAtStr, updatedAtStr string
var ownerID sql.NullInt64
err := rows.Scan(
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
&cred.PGDatabase, &cred.PGUsername,
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
&createdAtStr, &updatedAtStr, &ownerID,
&cred.ServiceUsername, &cred.ServiceAccountUUID,
)
if err != nil {
return nil, fmt.Errorf("db: scan pg cred with username: %w", err)
}
cred.CreatedAt, err = parseTime(createdAtStr)
if err != nil {
return nil, err
}
cred.UpdatedAt, err = parseTime(updatedAtStr)
if err != nil {
return nil, err
}
if ownerID.Valid {
v := ownerID.Int64
cred.OwnerID = &v
}
return &cred, nil
}
func scanPGCredAccessGrant(rows *sql.Rows) (*model.PGCredAccessGrant, error) {
var g model.PGCredAccessGrant
var grantedAtStr string
var grantedBy sql.NullInt64
err := rows.Scan(
&g.ID, &g.CredentialID, &g.GranteeID, &grantedBy, &grantedAtStr,
&g.GranteeUUID, &g.GranteeName,
)
if err != nil {
return nil, fmt.Errorf("db: scan pg cred access grant: %w", err)
}
g.GrantedAt, err = time.Parse("2006-01-02T15:04:05Z", grantedAtStr)
if err != nil {
return nil, fmt.Errorf("db: parse pg cred access grant time %q: %w", grantedAtStr, err)
}
if grantedBy.Valid {
v := grantedBy.Int64
g.GrantedBy = &v
}
return &g, nil
}

View File

@@ -4,18 +4,23 @@ import (
"database/sql"
"errors"
"fmt"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// policyRuleCols is the column list for all policy rule SELECT queries.
const policyRuleCols = `id, priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at`
// CreatePolicyRule inserts a new policy rule record. The returned record
// includes the database-assigned ID and timestamps.
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64) (*model.PolicyRuleRecord, error) {
// notBefore and expiresAt are optional; nil means no constraint.
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64, notBefore, expiresAt *time.Time) (*model.PolicyRuleRecord, error) {
n := now()
result, err := db.sql.Exec(`
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?)
`, priority, description, ruleJSON, createdBy, n, n)
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at, not_before, expires_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)
`, priority, description, ruleJSON, createdBy, n, n, formatNullableTime(notBefore), formatNullableTime(expiresAt))
if err != nil {
return nil, fmt.Errorf("db: create policy rule: %w", err)
}
@@ -39,6 +44,8 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
CreatedBy: createdBy,
CreatedAt: createdAt,
UpdatedAt: createdAt,
NotBefore: notBefore,
ExpiresAt: expiresAt,
}, nil
}
@@ -46,7 +53,7 @@ func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string
// Returns ErrNotFound if no such rule exists.
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
return db.scanPolicyRule(db.sql.QueryRow(`
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
SELECT `+policyRuleCols+`
FROM policy_rules WHERE id = ?
`, id))
}
@@ -55,7 +62,7 @@ func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
// When enabledOnly is true, only rules with enabled=1 are returned.
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
query := `
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
SELECT ` + policyRuleCols + `
FROM policy_rules`
if enabledOnly {
query += ` WHERE enabled = 1`
@@ -80,8 +87,12 @@ func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, erro
}
// UpdatePolicyRule updates the mutable fields of a policy rule.
// Only the fields in the update map are changed; other fields are untouched.
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string) error {
// Only non-nil fields are changed; nil fields are left untouched.
// For notBefore and expiresAt, use a non-nil pointer-to-pointer:
// - nil (outer) → don't change
// - non-nil → nil → set column to NULL
// - non-nil → non-nil → set column to the time value
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string, notBefore, expiresAt **time.Time) error {
n := now()
// Build SET clause dynamically to only update provided fields.
@@ -102,6 +113,14 @@ func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, rul
setClauses += ", rule_json = ?"
args = append(args, *ruleJSON)
}
if notBefore != nil {
setClauses += ", not_before = ?"
args = append(args, formatNullableTime(*notBefore))
}
if expiresAt != nil {
setClauses += ", expires_at = ?"
args = append(args, formatNullableTime(*expiresAt))
}
args = append(args, id)
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...)
@@ -141,10 +160,12 @@ func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
var enabledInt int
var createdAtStr, updatedAtStr string
var createdBy *int64
var notBeforeStr, expiresAtStr *string
err := row.Scan(
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
&notBeforeStr, &expiresAtStr,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -153,7 +174,7 @@ func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
return nil, fmt.Errorf("db: scan policy rule: %w", err)
}
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr, notBeforeStr, expiresAtStr)
}
// scanPolicyRuleRow scans a single policy rule from *sql.Rows.
@@ -162,19 +183,21 @@ func (db *DB) scanPolicyRuleRow(rows *sql.Rows) (*model.PolicyRuleRecord, error)
var enabledInt int
var createdAtStr, updatedAtStr string
var createdBy *int64
var notBeforeStr, expiresAtStr *string
err := rows.Scan(
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
&notBeforeStr, &expiresAtStr,
)
if err != nil {
return nil, fmt.Errorf("db: scan policy rule row: %w", err)
}
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr, notBeforeStr, expiresAtStr)
}
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string) (*model.PolicyRuleRecord, error) {
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string, notBeforeStr, expiresAtStr *string) (*model.PolicyRuleRecord, error) {
r.Enabled = enabledInt == 1
r.CreatedBy = createdBy
@@ -187,5 +210,23 @@ func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *
if err != nil {
return nil, err
}
r.NotBefore, err = nullableTime(notBeforeStr)
if err != nil {
return nil, err
}
r.ExpiresAt, err = nullableTime(expiresAtStr)
if err != nil {
return nil, err
}
return r, nil
}
// formatNullableTime converts a *time.Time to a *string suitable for SQLite.
// Returns nil if the input is nil (stores NULL).
func formatNullableTime(t *time.Time) *string {
if t == nil {
return nil
}
s := t.UTC().Format(time.RFC3339)
return &s
}

View File

@@ -3,6 +3,7 @@ package db
import (
"errors"
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
@@ -11,7 +12,7 @@ func TestCreateAndGetPolicyRule(t *testing.T) {
db := openTestDB(t)
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}`
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil)
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil, nil, nil)
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
@@ -49,9 +50,9 @@ func TestGetPolicyRule_NotFound(t *testing.T) {
func TestListPolicyRules(t *testing.T) {
db := openTestDB(t)
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil)
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil)
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil)
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil, nil, nil)
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil, nil, nil)
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil, nil, nil)
rules, err := db.ListPolicyRules(false)
if err != nil {
@@ -70,8 +71,8 @@ func TestListPolicyRules(t *testing.T) {
func TestListPolicyRules_EnabledOnly(t *testing.T) {
db := openTestDB(t)
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil)
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil)
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil, nil, nil)
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil, nil, nil)
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
t.Fatalf("SetPolicyRuleEnabled: %v", err)
@@ -100,11 +101,11 @@ func TestListPolicyRules_EnabledOnly(t *testing.T) {
func TestUpdatePolicyRule(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil)
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil, nil, nil)
newDesc := "updated description"
newPriority := 25
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil); err != nil {
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil, nil, nil); err != nil {
t.Fatalf("UpdatePolicyRule: %v", err)
}
@@ -127,10 +128,10 @@ func TestUpdatePolicyRule(t *testing.T) {
func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil)
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil, nil, nil)
newJSON := `{"effect":"deny","roles":["auditor"]}`
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON); err != nil {
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON, nil, nil); err != nil {
t.Fatalf("UpdatePolicyRule (json only): %v", err)
}
@@ -150,7 +151,7 @@ func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
func TestSetPolicyRuleEnabled(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil)
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil, nil, nil)
if !rec.Enabled {
t.Fatal("new rule should be enabled")
}
@@ -175,7 +176,7 @@ func TestSetPolicyRuleEnabled(t *testing.T) {
func TestDeletePolicyRule(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil)
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil, nil, nil)
if err := db.DeletePolicyRule(rec.ID); err != nil {
t.Fatalf("DeletePolicyRule: %v", err)
@@ -200,7 +201,7 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
db := openTestDB(t)
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash")
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID)
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID, nil, nil)
if err != nil {
t.Fatalf("CreatePolicyRule with createdBy: %v", err)
}
@@ -210,3 +211,111 @@ func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
t.Errorf("expected CreatedBy=%d, got %v", acct.ID, got.CreatedBy)
}
}
func TestCreatePolicyRule_WithExpiresAt(t *testing.T) {
db := openTestDB(t)
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
rec, err := db.CreatePolicyRule("expiring rule", 100, `{"effect":"allow"}`, nil, nil, &exp)
if err != nil {
t.Fatalf("CreatePolicyRule with expiresAt: %v", err)
}
got, err := db.GetPolicyRule(rec.ID)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.ExpiresAt == nil {
t.Fatal("expected ExpiresAt to be set")
}
if !got.ExpiresAt.Equal(exp) {
t.Errorf("expected ExpiresAt=%v, got %v", exp, *got.ExpiresAt)
}
if got.NotBefore != nil {
t.Errorf("expected NotBefore=nil, got %v", *got.NotBefore)
}
}
func TestCreatePolicyRule_WithNotBefore(t *testing.T) {
db := openTestDB(t)
nb := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
rec, err := db.CreatePolicyRule("scheduled rule", 100, `{"effect":"allow"}`, nil, &nb, nil)
if err != nil {
t.Fatalf("CreatePolicyRule with notBefore: %v", err)
}
got, err := db.GetPolicyRule(rec.ID)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.NotBefore == nil {
t.Fatal("expected NotBefore to be set")
}
if !got.NotBefore.Equal(nb) {
t.Errorf("expected NotBefore=%v, got %v", nb, *got.NotBefore)
}
if got.ExpiresAt != nil {
t.Errorf("expected ExpiresAt=nil, got %v", *got.ExpiresAt)
}
}
func TestCreatePolicyRule_WithBothTimes(t *testing.T) {
db := openTestDB(t)
nb := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
rec, err := db.CreatePolicyRule("windowed rule", 100, `{"effect":"allow"}`, nil, &nb, &exp)
if err != nil {
t.Fatalf("CreatePolicyRule with both times: %v", err)
}
got, err := db.GetPolicyRule(rec.ID)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.NotBefore == nil || !got.NotBefore.Equal(nb) {
t.Errorf("NotBefore mismatch: got %v", got.NotBefore)
}
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(exp) {
t.Errorf("ExpiresAt mismatch: got %v", got.ExpiresAt)
}
}
func TestUpdatePolicyRule_SetExpiresAt(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("no expiry", 100, `{"effect":"allow"}`, nil, nil, nil)
exp := time.Date(2030, 12, 31, 23, 59, 59, 0, time.UTC)
expPtr := &exp
if err := db.UpdatePolicyRule(rec.ID, nil, nil, nil, nil, &expPtr); err != nil {
t.Fatalf("UpdatePolicyRule (set expires_at): %v", err)
}
got, _ := db.GetPolicyRule(rec.ID)
if got.ExpiresAt == nil {
t.Fatal("expected ExpiresAt to be set after update")
}
if !got.ExpiresAt.Equal(exp) {
t.Errorf("expected ExpiresAt=%v, got %v", exp, *got.ExpiresAt)
}
}
func TestUpdatePolicyRule_ClearExpiresAt(t *testing.T) {
db := openTestDB(t)
exp := time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)
rec, _ := db.CreatePolicyRule("will clear", 100, `{"effect":"allow"}`, nil, nil, &exp)
// Clear expires_at by passing non-nil outer, nil inner.
var nilTime *time.Time
if err := db.UpdatePolicyRule(rec.ID, nil, nil, nil, nil, &nilTime); err != nil {
t.Fatalf("UpdatePolicyRule (clear expires_at): %v", err)
}
got, _ := db.GetPolicyRule(rec.ID)
if got.ExpiresAt != nil {
t.Errorf("expected ExpiresAt=nil after clear, got %v", *got.ExpiresAt)
}
}

View File

@@ -227,3 +227,73 @@ func (a *accountServiceServer) SetRoles(ctx context.Context, req *mciasv1.SetRol
fmt.Sprintf(`{"roles":%v}`, req.Roles))
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"
"fmt"
"net"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
@@ -13,6 +14,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
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/crypto"
"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.
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
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")
}
@@ -60,7 +62,9 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
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)
@@ -72,8 +76,14 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
if acct.TOTPRequired {
if req.TotpCode == "" {
// Security (DEF-08): password was already verified, so a missing
// TOTP code means the gRPC client needs to re-prompt the user —
// it is not a credential failure. Do NOT increment the lockout
// counter here; doing so would lock out well-behaved clients that
// call Login in two steps (password first, TOTP second) and would
// also let an attacker trigger account lockout by omitting the
// code after a successful password guess.
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
}
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
@@ -81,12 +91,19 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
return nil, status.Error(codes.Internal, "internal error")
}
valid, err := auth.ValidateTOTP(secret, req.TotpCode)
valid, counter, err := auth.ValidateTOTP(secret, req.TotpCode)
if err != nil || !valid {
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"wrong_totp"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
// Security (CRIT-01): reject replay of a code already used within
// its ±30-second validity window.
if err := a.s.db.CheckAndUpdateTOTPCounter(acct.ID, counter); err != nil {
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"totp_replay"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
}
// Login succeeded: clear any outstanding failure counter.
@@ -116,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.EventTokenIssued, &acct.ID, nil, ip, //nolint:errcheck
fmt.Sprintf(`{"jti":%q}`, claims.JTI))
audit.JSON("jti", claims.JTI))
return &mciasv1.LoginResponse{
Token: tokenStr,
@@ -132,7 +149,7 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
return nil, status.Error(codes.Internal, "internal error")
}
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
}
@@ -140,6 +157,14 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) {
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)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "account not found")
@@ -173,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
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{
Token: newTokenStr,
@@ -182,13 +207,39 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
}
// 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)
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
if err != nil {
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()
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
@@ -199,7 +250,12 @@ func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOT
return nil, status.Error(codes.Internal, "internal error")
}
if err := a.s.db.SetTOTP(acct.ID, secretEnc, secretNonce); err != nil {
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required is
// not set to 1 until the user confirms the code via ConfirmTOTP. Calling
// SetTOTP here would immediately lock the account behind TOTP before the
// user has had a chance to configure their authenticator app — matching the
// behaviour of the REST EnrollTOTP handler at internal/server/server.go.
if err := a.s.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
@@ -232,10 +288,15 @@ func (a *authServiceServer) ConfirmTOTP(ctx context.Context, req *mciasv1.Confir
return nil, status.Error(codes.Internal, "internal error")
}
valid, err := auth.ValidateTOTP(secret, req.Code)
valid, counter, err := auth.ValidateTOTP(secret, req.Code)
if err != nil || !valid {
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
}
// Security (CRIT-01): record the counter even during enrollment confirmation
// so the same code cannot be replayed immediately after confirming.
if err := a.s.db.CheckAndUpdateTOTPCounter(acct.ID, counter); err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
}
// SetTOTP with existing enc/nonce sets totp_required=1, confirming enrollment.
if err := a.s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {

View File

@@ -120,6 +120,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server {
mciasv1.RegisterTokenServiceServer(srv, &tokenServiceServer{s: s})
mciasv1.RegisterAccountServiceServer(srv, &accountServiceServer{s: s})
mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s})
mciasv1.RegisterPolicyServiceServer(srv, &policyServiceServer{s: s})
return srv
}
@@ -288,28 +289,75 @@ func (l *grpcRateLimiter) cleanup() {
// rateLimitInterceptor applies per-IP rate limiting using the same token-bucket
// 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(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
ip := ""
if p, ok := peer.FromContext(ctx); ok {
host, _, err := net.SplitHostPort(p.Addr.String())
if err == nil {
ip = host
} else {
ip = p.Addr.String()
}
var trustedProxy net.IP
if s.cfg.Server.TrustedProxy != "" {
trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
}
ip := grpcClientIP(ctx, trustedProxy)
if ip != "" && !s.rateLimiter.allow(ip) {
return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded")
}
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.
// The key lookup is case-insensitive per gRPC metadata convention (all keys
// are lowercased by the framework; we match on "authorization").

View File

@@ -12,6 +12,7 @@ import (
"io"
"log/slog"
"net"
"strings"
"testing"
"time"
@@ -19,6 +20,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"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.
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
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 {
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) {
e := newTestEnv(t)
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)
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 ----
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for
@@ -542,7 +577,7 @@ func TestSetAndGetRoles(t *testing.T) {
_, err = cl.SetRoles(authCtx(adminTok), &mciasv1.SetRolesRequest{
Id: id,
Roles: []string{"editor", "viewer"},
Roles: []string{"admin", "user"},
})
if err != nil {
t.Fatalf("SetRoles: %v", err)
@@ -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")
}
// 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)
if err == nil && existing != nil {
_ = ts.s.db.RevokeToken(existing.JTI, "rotated")
oldJTI = existing.JTI
}
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 {
if err := ts.s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}

View File

@@ -176,15 +176,62 @@ type ipRateLimiter struct {
mu sync.Mutex
}
// ClientIP returns the real client IP for a request, optionally trusting a
// single reverse-proxy address.
//
// Security (DEF-03): X-Forwarded-For and X-Real-IP headers can be forged by
// any client. This function only honours them when the immediate TCP peer
// (r.RemoteAddr) matches trustedProxy exactly. When trustedProxy is nil or
// the peer address does not match, r.RemoteAddr is used unconditionally.
//
// This prevents IP-spoofing attacks: an attacker who sends a fake
// X-Forwarded-For header from their own connection still has their real IP
// used for rate limiting, because their RemoteAddr will not match the proxy.
//
// Only the first (leftmost) value in X-Forwarded-For is used, as that is the
// client-supplied address as appended by the outermost proxy. If neither
// header is present, RemoteAddr is used as a fallback even when the request
// comes from the proxy.
func ClientIP(r *http.Request, trustedProxy net.IP) string {
remoteHost, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
remoteHost = r.RemoteAddr
}
if trustedProxy != nil {
remoteIP := net.ParseIP(remoteHost)
if remoteIP != nil && remoteIP.Equal(trustedProxy) {
// Request is from the trusted proxy; extract the real client IP.
// Prefer X-Real-IP (single value) over X-Forwarded-For (may be a
// comma-separated list when multiple proxies are chained).
if xri := r.Header.Get("X-Real-IP"); xri != "" {
if ip := net.ParseIP(strings.TrimSpace(xri)); ip != nil {
return ip.String()
}
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first (leftmost) address — the original client.
first, _, _ := strings.Cut(xff, ",")
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
return ip.String()
}
}
}
}
return remoteHost
}
// RateLimit returns middleware implementing a per-IP token bucket.
// rps is the sustained request rate (tokens refilled per second).
// burst is the maximum burst size (initial and maximum token count).
// trustedProxy, if non-nil, enables proxy-aware client IP extraction via
// ClientIP; pass nil when not running behind a reverse proxy.
//
// Security: Rate limiting is applied at the IP level. In production, the
// server should be behind a reverse proxy that sets X-Forwarded-For; this
// middleware uses RemoteAddr directly which may be the proxy IP. For single-
// instance deployment without a proxy, RemoteAddr is the client IP.
func RateLimit(rps float64, burst int) func(http.Handler) http.Handler {
// Security (DEF-03): when trustedProxy is set, real client IPs are extracted
// from X-Forwarded-For/X-Real-IP headers but only for requests whose
// RemoteAddr matches the trusted proxy, preventing IP-spoofing.
func RateLimit(rps float64, burst int, trustedProxy net.IP) func(http.Handler) http.Handler {
limiter := &ipRateLimiter{
rps: rps,
burst: float64(burst),
@@ -197,10 +244,7 @@ func RateLimit(rps float64, burst int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
ip := ClientIP(r, trustedProxy)
if !limiter.allow(ip) {
w.Header().Set("Retry-After", "60")

View File

@@ -6,6 +6,7 @@ import (
"crypto/ed25519"
"crypto/rand"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"testing"
@@ -271,7 +272,7 @@ func TestRequireRoleNoClaims(t *testing.T) {
}
func TestRateLimitAllows(t *testing.T) {
handler := RateLimit(10, 5)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := RateLimit(10, 5, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -289,7 +290,7 @@ func TestRateLimitAllows(t *testing.T) {
}
func TestRateLimitBlocks(t *testing.T) {
handler := RateLimit(0.1, 2)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := RateLimit(0.1, 2, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -340,3 +341,124 @@ func TestExtractBearerToken(t *testing.T) {
})
}
}
// TestClientIP verifies the proxy-aware IP extraction logic.
func TestClientIP(t *testing.T) {
proxy := net.ParseIP("10.0.0.1")
tests := []struct {
name string
remoteAddr string
xForwardedFor string
xRealIP string
trustedProxy net.IP
want string
}{
{
name: "no proxy configured: uses RemoteAddr",
remoteAddr: "203.0.113.5:54321",
want: "203.0.113.5",
},
{
name: "proxy configured but request not from proxy: uses RemoteAddr",
remoteAddr: "198.51.100.9:12345",
xForwardedFor: "203.0.113.99",
trustedProxy: proxy,
want: "198.51.100.9",
},
{
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
remoteAddr: "10.0.0.1:8080",
xRealIP: "203.0.113.42",
trustedProxy: proxy,
want: "203.0.113.42",
},
{
name: "request from trusted proxy with X-Forwarded-For: uses first entry",
remoteAddr: "10.0.0.1:8080",
xForwardedFor: "203.0.113.77, 10.0.0.2",
trustedProxy: proxy,
want: "203.0.113.77",
},
{
name: "X-Real-IP takes precedence over X-Forwarded-For",
remoteAddr: "10.0.0.1:8080",
xRealIP: "203.0.113.11",
xForwardedFor: "203.0.113.22",
trustedProxy: proxy,
want: "203.0.113.11",
},
{
name: "proxy request with invalid X-Real-IP falls back to X-Forwarded-For",
remoteAddr: "10.0.0.1:8080",
xRealIP: "not-an-ip",
xForwardedFor: "203.0.113.55",
trustedProxy: proxy,
want: "203.0.113.55",
},
{
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
remoteAddr: "10.0.0.1:8080",
trustedProxy: proxy,
want: "10.0.0.1",
},
{
// Security: attacker fakes X-Forwarded-For but connects directly.
name: "spoofed X-Forwarded-For from non-proxy IP is ignored",
remoteAddr: "198.51.100.99:9999",
xForwardedFor: "127.0.0.1",
trustedProxy: proxy,
want: "198.51.100.99",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tc.remoteAddr
if tc.xForwardedFor != "" {
req.Header.Set("X-Forwarded-For", tc.xForwardedFor)
}
if tc.xRealIP != "" {
req.Header.Set("X-Real-IP", tc.xRealIP)
}
got := ClientIP(req, tc.trustedProxy)
if got != tc.want {
t.Errorf("ClientIP = %q, want %q", got, tc.want)
}
})
}
}
// TestRateLimitTrustedProxy verifies that rate limiting uses the forwarded IP
// when the request originates from a trusted proxy.
func TestRateLimitTrustedProxy(t *testing.T) {
proxy := net.ParseIP("10.0.0.1")
// Very low rps and burst=1 so any two requests from the same IP are blocked.
handler := RateLimit(0.001, 1, proxy)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Two requests from the same real client IP, forwarded by the proxy.
// Both carry the same X-Real-IP; the second should be rate-limited.
for i, wantStatus := range []int{http.StatusOK, http.StatusTooManyRequests} {
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
req.RemoteAddr = "10.0.0.1:5000" // from the trusted proxy
req.Header.Set("X-Real-IP", "203.0.113.5")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != wantStatus {
t.Errorf("request %d: status = %d, want %d", i+1, rr.Code, wantStatus)
}
}
// A different real client (different X-Real-IP) should still be allowed.
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
req.RemoteAddr = "10.0.0.1:5001"
req.Header.Set("X-Real-IP", "203.0.113.99")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("distinct client: status = %d, want 200 (separate bucket)", rr.Code)
}
}

View File

@@ -2,7 +2,10 @@
// These are pure data definitions with no external dependencies.
package model
import "time"
import (
"fmt"
"time"
)
// AccountType distinguishes human interactive accounts from non-interactive
// service accounts.
@@ -43,6 +46,41 @@ type Account struct {
TOTPRequired bool `json:"totp_required"`
}
// Allowlisted role names (DEF-10).
// Only these strings may be stored in account_roles. Extending the set of
// valid roles requires a code change, ensuring that typos such as "admim"
// are caught at grant time rather than silently creating a useless role.
const (
RoleAdmin = "admin"
RoleUser = "user"
RoleGuest = "guest"
RoleViewer = "viewer"
RoleEditor = "editor"
RoleCommenter = "commenter"
)
// allowedRoles is the compile-time set of recognised role names.
var allowedRoles = map[string]struct{}{
RoleAdmin: {},
RoleUser: {},
RoleGuest: {},
RoleViewer: {},
RoleEditor: {},
RoleCommenter: {},
}
// ValidateRole returns nil if role is an allowlisted role name, or an error
// describing the problem. Call this before writing to account_roles.
//
// Security (DEF-10): prevents admins from accidentally creating unmatchable
// roles (e.g. "admim") by enforcing a compile-time allowlist.
func ValidateRole(role string) error {
if _, ok := allowedRoles[role]; !ok {
return fmt.Errorf("model: unknown role %q; allowed roles: admin, user, guest, viewer, editor, commenter", role)
}
return nil
}
// Role is a string label assigned to an account to grant permissions.
type Role struct {
GrantedAt time.Time `json:"granted_at"`
@@ -87,18 +125,26 @@ type SystemToken struct {
// PGCredential holds Postgres connection details for a system account.
// The password is encrypted at rest; PGPassword is only populated after
// decryption and must never be logged or included in API responses.
//
// OwnerID identifies the account permitted to update, delete, and manage
// access grants for this credential set. A nil OwnerID means the credential
// pre-dates ownership tracking; for backwards compatibility, nil is treated as
// unowned (only admins can manage it via the UI).
type PGCredential struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PGHost string `json:"host"`
PGDatabase string `json:"database"`
PGUsername string `json:"username"`
PGPassword string `json:"-"`
PGPasswordEnc []byte `json:"-"`
PGPasswordNonce []byte `json:"-"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
PGPort int `json:"port"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OwnerID *int64 `json:"-"`
ServiceAccountUUID string `json:"service_account_uuid,omitempty"`
PGUsername string `json:"username"`
PGPassword string `json:"-"`
ServiceUsername string `json:"service_username,omitempty"`
PGDatabase string `json:"database"`
PGHost string `json:"host"`
PGPasswordEnc []byte `json:"-"`
PGPasswordNonce []byte `json:"-"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
PGPort int `json:"port"`
}
// AuditEvent represents a single entry in the append-only audit log.
@@ -141,16 +187,42 @@ const (
EventPolicyDeny = "policy_deny"
)
// PGCredAccessGrant records that a specific account has been granted read
// access to a pg_credentials set. Only the credential owner can manage
// grants; grantees can view connection metadata but never the plaintext
// password, and they cannot update or delete the credential set.
type PGCredAccessGrant struct {
GrantedAt time.Time `json:"granted_at"`
GrantedBy *int64 `json:"-"`
GranteeUUID string `json:"grantee_id"`
GranteeName string `json:"grantee_username"`
ID int64 `json:"-"`
CredentialID int64 `json:"-"`
GranteeID int64 `json:"-"`
}
// Audit event type for pg_credential_access changes.
const (
EventPGCredAccessGranted = "pgcred_access_granted" //nolint:gosec // G101: audit event type, not a credential
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
EventPasswordChanged = "password_changed"
)
// PolicyRuleRecord is the database representation of a policy rule.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns.
// NotBefore and ExpiresAt define an optional validity window; nil means no
// constraint (always active / never expires).
type PolicyRuleRecord struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *int64 `json:"-"`
Description string `json:"description"`
RuleJSON string `json:"rule_json"`
ID int64 `json:"id"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
NotBefore *time.Time `json:"not_before,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedBy *int64 `json:"-"`
Description string `json:"description"`
RuleJSON string `json:"rule_json"`
ID int64 `json:"id"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
}

View File

@@ -42,6 +42,18 @@ var defaultRules = []Rule{
Actions: []Action{ActionEnrollTOTP},
Effect: Allow,
},
{
// Self-service password change: any authenticated human account may
// change their own password. The handler derives the target exclusively
// from the JWT subject (claims.Subject) and requires the current
// password, so a non-admin caller can only affect their own account.
ID: -7,
Description: "Self-service: any human account may change their own password",
Priority: 0,
AccountTypes: []string{"human"},
Actions: []Action{ActionChangePassword},
Effect: Allow,
},
{
// System accounts reading their own pgcreds: a service that has already
// authenticated (e.g. via its bearer service token) may retrieve its own

View File

@@ -2,6 +2,7 @@ package policy
import (
"testing"
"time"
)
// adminInput is a convenience helper for building admin PolicyInputs.
@@ -378,3 +379,131 @@ func TestEvaluate_AccountTypeGating(t *testing.T) {
t.Error("human account should not match system-only rule")
}
}
// ---- Engine.SetRules time-filtering tests ----
func TestSetRules_SkipsExpiredRule(t *testing.T) {
engine := NewEngine()
past := time.Now().Add(-1 * time.Hour)
err := engine.SetRules([]PolicyRecord{
{
ID: 1,
Description: "expired",
Priority: 100,
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
Enabled: true,
ExpiresAt: &past,
},
})
if err != nil {
t.Fatalf("SetRules: %v", err)
}
// The expired rule should not be in the cache; evaluation should deny.
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionListAccounts,
Resource: Resource{Type: ResourceAccount},
}
effect, _ := engine.Evaluate(input)
if effect != Deny {
t.Error("expired rule should not match; expected Deny")
}
}
func TestSetRules_SkipsNotYetActiveRule(t *testing.T) {
engine := NewEngine()
future := time.Now().Add(1 * time.Hour)
err := engine.SetRules([]PolicyRecord{
{
ID: 2,
Description: "not yet active",
Priority: 100,
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
Enabled: true,
NotBefore: &future,
},
})
if err != nil {
t.Fatalf("SetRules: %v", err)
}
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionListAccounts,
Resource: Resource{Type: ResourceAccount},
}
effect, _ := engine.Evaluate(input)
if effect != Deny {
t.Error("future not_before rule should not match; expected Deny")
}
}
func TestSetRules_IncludesActiveWindowRule(t *testing.T) {
engine := NewEngine()
past := time.Now().Add(-1 * time.Hour)
future := time.Now().Add(1 * time.Hour)
err := engine.SetRules([]PolicyRecord{
{
ID: 3,
Description: "currently active",
Priority: 100,
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
Enabled: true,
NotBefore: &past,
ExpiresAt: &future,
},
})
if err != nil {
t.Fatalf("SetRules: %v", err)
}
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionListAccounts,
Resource: Resource{Type: ResourceAccount},
}
effect, _ := engine.Evaluate(input)
if effect != Allow {
t.Error("rule within its active window should match; expected Allow")
}
}
func TestSetRules_NilTimesAlwaysActive(t *testing.T) {
engine := NewEngine()
err := engine.SetRules([]PolicyRecord{
{
ID: 4,
Description: "no time constraints",
Priority: 100,
RuleJSON: `{"effect":"allow","actions":["accounts:list"]}`,
Enabled: true,
// NotBefore and ExpiresAt are both nil.
},
})
if err != nil {
t.Fatalf("SetRules: %v", err)
}
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionListAccounts,
Resource: Resource{Type: ResourceAccount},
}
effect, _ := engine.Evaluate(input)
if effect != Allow {
t.Error("nil time fields mean always active; expected Allow")
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"sync"
"time"
)
// Engine wraps the stateless Evaluate function with an in-memory cache of
@@ -31,11 +32,19 @@ func NewEngine() *Engine {
// into a Rule. This prevents the database from injecting values into the ID or
// Description fields that are stored as dedicated columns.
func (e *Engine) SetRules(records []PolicyRecord) error {
now := time.Now()
rules := make([]Rule, 0, len(records))
for _, rec := range records {
if !rec.Enabled {
continue
}
// Skip rules outside their validity window.
if rec.NotBefore != nil && now.Before(*rec.NotBefore) {
continue
}
if rec.ExpiresAt != nil && now.After(*rec.ExpiresAt) {
continue
}
var body RuleBody
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
@@ -75,6 +84,8 @@ func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) {
// Using a local struct avoids importing the db or model packages from policy,
// which would create a dependency cycle.
type PolicyRecord struct {
NotBefore *time.Time
ExpiresAt *time.Time
Description string
RuleJSON string
ID int64

View File

@@ -42,8 +42,9 @@ const (
ActionEnrollTOTP Action = "totp:enroll" // self-service
ActionRemoveTOTP Action = "totp:remove" // admin
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionChangePassword Action = "auth:change_password" // self-service
ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage"

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"strconv"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
@@ -90,6 +91,8 @@ func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
type policyRuleResponse struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
NotBefore *string `json:"not_before,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Description string `json:"description"`
RuleBody policy.RuleBody `json:"rule"`
ID int64 `json:"id"`
@@ -102,15 +105,24 @@ func policyRuleToResponse(rec *model.PolicyRuleRecord) (policyRuleResponse, erro
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
}
return policyRuleResponse{
resp := policyRuleResponse{
ID: rec.ID,
Priority: rec.Priority,
Description: rec.Description,
RuleBody: body,
Enabled: rec.Enabled,
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}, nil
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
UpdatedAt: rec.UpdatedAt.Format(time.RFC3339),
}
if rec.NotBefore != nil {
s := rec.NotBefore.UTC().Format(time.RFC3339)
resp.NotBefore = &s
}
if rec.ExpiresAt != nil {
s := rec.ExpiresAt.UTC().Format(time.RFC3339)
resp.ExpiresAt = &s
}
return resp, nil
}
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
@@ -133,6 +145,8 @@ func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
type createPolicyRuleRequest struct {
Description string `json:"description"`
NotBefore *string `json:"not_before,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Rule policy.RuleBody `json:"rule"`
Priority int `json:"priority"`
}
@@ -157,6 +171,29 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
priority = 100 // default
}
// Parse optional time-scoped validity window.
var notBefore, expiresAt *time.Time
if req.NotBefore != nil {
t, err := time.Parse(time.RFC3339, *req.NotBefore)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
return
}
notBefore = &t
}
if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
return
}
expiresAt = &t
}
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be after not_before", "bad_request")
return
}
ruleJSON, err := json.Marshal(req.Rule)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -171,7 +208,7 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
}
}
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy)
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy, notBefore, expiresAt)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
@@ -202,10 +239,14 @@ func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
}
type updatePolicyRuleRequest struct {
Description *string `json:"description,omitempty"`
Rule *policy.RuleBody `json:"rule,omitempty"`
Priority *int `json:"priority,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Description *string `json:"description,omitempty"`
NotBefore *string `json:"not_before,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Rule *policy.RuleBody `json:"rule,omitempty"`
Priority *int `json:"priority,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
ClearNotBefore *bool `json:"clear_not_before,omitempty"`
ClearExpiresAt *bool `json:"clear_expires_at,omitempty"`
}
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
@@ -230,11 +271,39 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s := string(b)
ruleJSON = &s
js := string(b)
ruleJSON = &js
}
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON); err != nil {
// Parse optional time-scoped validity window updates.
// Double-pointer semantics: nil = no change, non-nil→nil = clear, non-nil→non-nil = set.
var notBefore, expiresAt **time.Time
if req.ClearNotBefore != nil && *req.ClearNotBefore {
var nilTime *time.Time
notBefore = &nilTime // non-nil outer, nil inner → set to NULL
} else if req.NotBefore != nil {
t, err := time.Parse(time.RFC3339, *req.NotBefore)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
return
}
tp := &t
notBefore = &tp
}
if req.ClearExpiresAt != nil && *req.ClearExpiresAt {
var nilTime *time.Time
expiresAt = &nilTime // non-nil outer, nil inner → set to NULL
} else if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
return
}
tp := &t
expiresAt = &tp
}
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON, notBefore, expiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}

View File

@@ -16,8 +16,11 @@ import (
"fmt"
"io/fs"
"log/slog"
"net"
"net/http"
"time"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
@@ -56,10 +59,19 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Security (DEF-03): parse the optional trusted-proxy address once here
// so RateLimit and audit-log helpers use consistent IP extraction.
// net.ParseIP returns nil for an empty string, which disables proxy
// trust and falls back to r.RemoteAddr.
var trustedProxy net.IP
if s.cfg.Server.TrustedProxy != "" {
trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
}
// Security: per-IP rate limiting on public auth endpoints to prevent
// brute-force login attempts and token-validation abuse. Parameters match
// the gRPC rate limiter (10 req/s sustained, burst 10).
loginRateLimit := middleware.RateLimit(10, 10)
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
// Public endpoints (no authentication required).
mux.HandleFunc("GET /v1/health", s.handleHealth)
@@ -82,16 +94,20 @@ func (s *Server) Handler() http.Handler {
if err != nil {
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
}
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
// Security (DEF-09): apply defensive HTTP headers to the docs handlers.
// The Swagger UI page at /docs loads JavaScript from the same origin
// and renders untrusted content (API descriptions), so it benefits from
// CSP, X-Frame-Options, and the other headers applied to the UI sub-mux.
mux.Handle("GET /docs", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(docsHTML)
})
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, _ *http.Request) {
})))
mux.Handle("GET /docs/openapi.yaml", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(specYAML)
})
})))
// Authenticated endpoints.
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
@@ -116,11 +132,18 @@ func (s *Server) Handler() http.Handler {
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("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("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
// Self-service password change (requires valid token; actor must match target account).
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
@@ -134,10 +157,20 @@ func (s *Server) Handler() http.Handler {
}
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).
var root http.Handler = mux
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
}
@@ -194,7 +227,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Security: return a generic error whether the user exists or not.
// Always run a dummy Argon2 check to prevent timing-based user enumeration.
_, _ = 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")
return
}
@@ -218,7 +251,9 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
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
}
@@ -247,13 +282,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
valid, err := auth.ValidateTOTP(secret, req.TOTPCode)
valid, totpCounter, err := auth.ValidateTOTP(secret, req.TOTPCode)
if err != nil || !valid {
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
// Security (CRIT-01): reject replay of a code already used within
// its ±30-second validity window.
if err := s.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"totp_replay"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
}
// Login succeeded: clear any outstanding failure counter.
@@ -287,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.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{
Token: tokenStr,
@@ -302,13 +345,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
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)
}
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
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).
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
@@ -348,7 +400,7 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
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{
Token: newTokenStr,
@@ -432,17 +484,15 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
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)
if err == nil && existing != nil {
_ = s.db.RevokeToken(existing.JTI, "rotated")
oldJTI = existing.JTI
}
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 {
if err := s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
@@ -454,7 +504,7 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
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{
Token: tokenStr,
@@ -474,7 +524,7 @@ func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request) {
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)
}
@@ -569,7 +619,7 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
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))
}
@@ -640,6 +690,10 @@ type setRolesRequest struct {
Roles []string `json:"roles"`
}
type grantRoleRequest struct {
Role string `json:"role"`
}
func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
@@ -680,12 +734,78 @@ func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) {
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)
}
// ---- TOTP endpoints ----
type totpEnrollRequest struct {
Password string `json:"password"` // security: current password required to prevent session-theft escalation
}
type totpEnrollResponse struct {
Secret string `json:"secret"` // base32-encoded
OTPAuthURI string `json:"otpauth_uri"`
@@ -695,6 +815,12 @@ type totpConfirmRequest struct {
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) {
claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject)
@@ -703,6 +829,38 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
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()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -760,11 +918,18 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
return
}
valid, err := auth.ValidateTOTP(secret, req.Code)
valid, totpCounter, err := auth.ValidateTOTP(secret, req.Code)
if err != nil || !valid {
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
return
}
// Security (CRIT-01): record the counter even during enrollment
// confirmation so the same code cannot be replayed immediately after
// confirming.
if err := s.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
return
}
// Mark TOTP as confirmed and required.
if err := s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
@@ -801,6 +966,185 @@ func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ---- Password change endpoints ----
// adminSetPasswordRequest is the request body for PUT /v1/accounts/{id}/password.
// Used by admins to reset any human account's password without requiring the
// current password.
type adminSetPasswordRequest struct {
NewPassword string `json:"new_password"`
}
// handleAdminSetPassword allows an admin to reset any human account's password.
// No current-password verification is required because the admin role already
// represents a higher trust level, matching the break-glass recovery pattern.
//
// Security: new password is validated (minimum length) and hashed with Argon2id
// before storage. The plaintext is never logged. All active tokens for the
// target account are revoked so that a compromised-account recovery fully
// invalidates any outstanding sessions.
func (s *Server) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
if acct.AccountType != model.AccountTypeHuman {
middleware.WriteError(w, http.StatusBadRequest, "password can only be set on human accounts", "bad_request")
return
}
var req adminSetPasswordRequest
if !decodeJSON(w, r, &req) {
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(req.NewPassword); err != nil {
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
Time: s.cfg.Argon2.Time,
Memory: s.cfg.Argon2.Memory,
Threads: s.cfg.Argon2.Threads,
})
if err != nil {
s.logger.Error("hash password (admin reset)", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
s.logger.Error("update password hash", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Security: revoke all active sessions so a compromised account cannot
// continue to use old tokens after a password reset. Failure here means
// the API's documented guarantee ("all active sessions revoked") cannot be
// upheld, so we return 500 rather than silently succeeding.
if err := s.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
s.logger.Error("revoke tokens on password reset", "error", err, "account_id", acct.ID)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
actor := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if actor != nil {
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
actorID = &a.ID
}
}
s.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
w.WriteHeader(http.StatusNoContent)
}
// changePasswordRequest is the request body for PUT /v1/auth/password.
// The current_password is required to prevent token-theft attacks: an attacker
// who steals a valid JWT cannot change the password without also knowing the
// existing one.
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// handleChangePassword allows an authenticated user to change their own password.
// The current password must be verified before the new hash is written.
//
// Security: current password is verified with Argon2id (constant-time).
// Lockout is checked and failures are recorded to prevent the endpoint from
// being used as an oracle for the current password. On success, all other
// active sessions (other JTIs) are revoked so stale tokens cannot be used
// after a credential rotation.
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
return
}
if acct.AccountType != model.AccountTypeHuman {
middleware.WriteError(w, http.StatusBadRequest, "password change is only available for human accounts", "bad_request")
return
}
var req changePasswordRequest
if !decodeJSON(w, r, &req) {
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
middleware.WriteError(w, http.StatusBadRequest, "current_password and new_password are required", "bad_request")
return
}
// Security: check lockout before verifying (same as login flow) so an
// attacker cannot use this endpoint to brute-force the current password.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (password change)", "error", lockErr)
}
if locked {
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
// 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
}
// Security: verify the current password with the same constant-time
// Argon2id path used at login to prevent timing oracles.
ok, verifyErr := auth.VerifyPassword(req.CurrentPassword, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = s.db.RecordLoginFailure(acct.ID)
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
middleware.WriteError(w, http.StatusUnauthorized, "current password is incorrect", "unauthorized")
return
}
// Security (F-13): enforce minimum length on the new password before hashing.
if err := validate.Password(req.NewPassword); err != nil {
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
Time: s.cfg.Argon2.Time,
Memory: s.cfg.Argon2.Memory,
Threads: s.cfg.Argon2.Threads,
})
if err != nil {
s.logger.Error("hash password (self-service change)", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
s.logger.Error("update password hash", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Security: clear the failure counter since the user proved knowledge of
// the current password, then revoke all tokens *except* the current one so
// the caller retains their active session but any other stolen sessions are
// invalidated. Revocation failure breaks the documented guarantee so we
// return 500 rather than silently succeeding.
_ = s.db.ClearLoginFailures(acct.ID)
if err := s.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
s.logger.Error("revoke other tokens on password change", "error", err, "account_id", acct.ID)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"self_service"}`)
w.WriteHeader(http.StatusNoContent)
}
// ---- Postgres credential endpoints ----
type pgCredRequest struct {
@@ -880,6 +1224,58 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
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 ----
// handleListAudit returns paginated audit log entries with resolved usernames.
@@ -968,8 +1364,14 @@ func (s *Server) loadAccount(w http.ResponseWriter, r *http.Request) (*model.Acc
}
// writeAudit appends an audit log entry, logging errors but not failing the request.
// The logged IP honours the trusted-proxy setting so the real client address
// is recorded rather than the proxy's address (DEF-03).
func (s *Server) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
ip := r.RemoteAddr
var proxyIP net.IP
if s.cfg.Server.TrustedProxy != "" {
proxyIP = net.ParseIP(s.cfg.Server.TrustedProxy)
}
ip := middleware.ClientIP(r, proxyIP)
if err := s.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
s.logger.Error("write audit event", "error", err, "event_type", eventType)
}
@@ -985,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.
// 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 {
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
@@ -1010,6 +1424,39 @@ func extractBearerFromRequest(r *http.Request) (string, error) {
return auth[len(prefix):], nil
}
// docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux
// to the /docs and /docs/openapi.yaml endpoints.
//
// 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
// served without CSP, X-Frame-Options, or HSTS, leaving it susceptible
// to clickjacking and MIME-type confusion in browsers.
func docsSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
// encodeBase64URL encodes bytes as base64url without padding.
func encodeBase64URL(b []byte) string {
const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"

View File

@@ -376,7 +376,7 @@ func TestSetAndGetRoles(t *testing.T) {
// Set roles.
rr := doRequest(t, handler, "PUT", "/v1/accounts/"+target.UUID+"/roles", map[string][]string{
"roles": {"reader", "writer"},
"roles": {"admin", "user"},
}, adminToken)
if rr.Code != http.StatusNoContent {
t.Errorf("set roles status = %d, want 204; body: %s", rr.Code, rr.Body.String())
@@ -519,8 +519,10 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
t.Fatalf("TrackToken: %v", err)
}
// Start enrollment.
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", nil, tokenStr)
// Start enrollment (password required since SEC-01 fix).
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
Password: "testpass123",
}, tokenStr)
if rr.Code != http.StatusOK {
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) {
srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "renew-user")
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 {
t.Fatalf("IssueToken: %v", err)
}
@@ -572,6 +630,9 @@ func TestRenewToken(t *testing.T) {
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)
if rr.Code != http.StatusOK {
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")
}
}
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

@@ -70,11 +70,16 @@ func IssueToken(key ed25519.PrivateKey, issuer, subject string, roles []string,
exp := now.Add(expiry)
jti := uuid.New().String()
// Security (DEF-04): set NotBefore = now so tokens are not valid before
// the instant of issuance. This is a defence-in-depth measure: without
// nbf, a clock-skewed client or intermediate could present a token
// before its intended validity window.
jc := jwtClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: subject,
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(exp),
ID: jti,
},
@@ -127,6 +132,9 @@ func ValidateToken(key ed25519.PublicKey, tokenString, expectedIssuer string) (*
jwt.WithIssuedAt(),
jwt.WithIssuer(expectedIssuer),
jwt.WithExpirationRequired(),
// Security (DEF-04): nbf is validated automatically by the library
// when the claim is present; no explicit option is needed. If nbf is
// in the future the library returns ErrTokenNotValidYet.
)
if err != nil {
// Map library errors to our typed errors for consistent handling.

View File

@@ -15,7 +15,14 @@ import (
)
// 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.
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{
PageData: PageData{CSRFToken: csrfToken},
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Accounts: accounts,
})
}
@@ -132,15 +139,41 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
tokens = nil
}
// Resolve the currently logged-in actor.
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
// Load PG credentials for system accounts only; leave nil for human accounts
// and when no credentials have been stored yet.
var pgCred *model.PGCredential
var pgCredGrants []*model.PGCredAccessGrant
var grantableAccounts []*model.Account
if acct.AccountType == model.AccountTypeSystem {
pgCred, err = u.db.ReadPGCredentials(acct.ID)
if err != nil && !errors.Is(err, db.ErrNotFound) {
u.logger.Warn("read pg credentials", "error", err)
}
// ErrNotFound is expected when no credentials have been stored yet.
// Load access grants; only show management controls when the actor is owner.
if pgCred != nil {
pgCredGrants, err = u.db.ListPGCredAccess(pgCred.ID)
if err != nil {
u.logger.Warn("list pg cred access", "error", err)
}
// Populate the "add grantee" dropdown only for the credential owner.
if actorID != nil && pgCred.OwnerID != nil && *pgCred.OwnerID == *actorID {
grantableAccounts, err = u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcred grant", "error", err)
}
}
}
}
tags, err := u.db.GetAccountTags(acct.ID)
@@ -150,13 +183,16 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
}
u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
Tokens: tokens,
PGCred: pgCred,
Tags: tags,
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
Tokens: tokens,
PGCred: pgCred,
PGCredGrants: pgCredGrants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
Tags: tags,
})
}
@@ -456,15 +492,509 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
pgCred = nil
}
// Security: set the credential owner to the actor on first write so that
// subsequent grant/revoke operations can enforce ownership. If no actor
// is present (e.g. bootstrap), the owner remains nil.
if pgCred != nil && pgCred.OwnerID == nil && actorID != nil {
if err := u.db.SetPGCredentialOwner(pgCred.ID, *actorID); err != nil {
u.logger.Warn("set pg credential owner", "error", err)
} else {
pgCred.OwnerID = actorID
}
}
// Load existing access grants to re-render the full section.
var grants []*model.PGCredAccessGrant
if pgCred != nil {
grants, err = u.db.ListPGCredAccess(pgCred.ID)
if err != nil {
u.logger.Warn("list pg cred access after write", "error", err)
}
}
// Load non-system accounts available to grant access to.
grantableAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcred grant", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
PGCredGrants: grants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
})
}
// handleGrantPGCredAccess grants another account read access to a pg_credentials
// set owned by the actor. Only the credential owner may grant access; this is
// enforced by comparing the stored owner_id with the logged-in actor.
//
// Security: ownership is re-verified server-side on every request; the form
// field grantee_uuid is looked up in the accounts table (no ID injection).
// Audit event EventPGCredAccessGranted is recorded on success.
func (u *UIServer) handleGrantPGCredAccess(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
return
}
pgCred, err := u.db.ReadPGCredentials(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "no credentials stored for this account")
return
}
// Resolve the currently logged-in actor.
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
// Security: only the credential owner may grant access.
if actorID == nil || pgCred.OwnerID == nil || *pgCred.OwnerID != *actorID {
u.renderError(w, r, http.StatusForbidden, "only the credential owner may grant access")
return
}
granteeUUID := strings.TrimSpace(r.FormValue("grantee_uuid"))
if granteeUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "grantee is required")
return
}
grantee, err := u.db.GetAccountByUUID(granteeUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "grantee account not found")
return
}
if err := u.db.GrantPGCredAccess(pgCred.ID, grantee.ID, actorID); err != nil {
u.logger.Error("grant pg cred access", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to grant access")
return
}
u.writeAudit(r, model.EventPGCredAccessGranted, actorID, &grantee.ID,
fmt.Sprintf(`{"credential_id":%d,"grantee":%q}`, pgCred.ID, grantee.UUID))
// If the caller requested a redirect (e.g. from the /pgcreds page), honour it.
if next := r.FormValue("_next"); next == "/pgcreds" {
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
return
}
// Re-render the full pgcreds section so the new grant appears.
grants, err := u.db.ListPGCredAccess(pgCred.ID)
if err != nil {
u.logger.Warn("list pg cred access after grant", "error", err)
}
grantableAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcred grant", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
PGCredGrants: grants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
})
}
// handleRevokePGCredAccess removes a grantee's read access to a pg_credentials set.
// Only the credential owner may revoke grants; this is enforced server-side.
//
// Security: ownership re-verified on every request. grantee_uuid looked up
// in accounts table — not taken from URL path to prevent enumeration.
// Audit event EventPGCredAccessRevoked is recorded on success.
func (u *UIServer) handleRevokePGCredAccess(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
return
}
pgCred, err := u.db.ReadPGCredentials(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "no credentials stored for this account")
return
}
// Resolve the currently logged-in actor.
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
// Security: only the credential owner may revoke access.
if actorID == nil || pgCred.OwnerID == nil || *pgCred.OwnerID != *actorID {
u.renderError(w, r, http.StatusForbidden, "only the credential owner may revoke access")
return
}
granteeUUID := r.PathValue("grantee")
if granteeUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "grantee is required")
return
}
grantee, err := u.db.GetAccountByUUID(granteeUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "grantee account not found")
return
}
if err := u.db.RevokePGCredAccess(pgCred.ID, grantee.ID); err != nil {
u.logger.Error("revoke pg cred access", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to revoke access")
return
}
u.writeAudit(r, model.EventPGCredAccessRevoked, actorID, &grantee.ID,
fmt.Sprintf(`{"credential_id":%d,"grantee":%q}`, pgCred.ID, grantee.UUID))
// If the caller requested a redirect (e.g. from the /pgcreds page), honour it.
if r.URL.Query().Get("_next") == "/pgcreds" {
if isHTMX(r) {
w.Header().Set("HX-Redirect", "/pgcreds")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
return
}
// Re-render the full pgcreds section with the grant removed.
grants, err := u.db.ListPGCredAccess(pgCred.ID)
if err != nil {
u.logger.Warn("list pg cred access after revoke", "error", err)
}
grantableAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcred grant", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
PGCredGrants: grants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
})
}
// handlePGCredsList renders the "My PG Credentials" page, showing all
// pg_credentials accessible to the currently logged-in user (owned + granted),
// plus a create form for system accounts that have no credentials yet.
func (u *UIServer) handlePGCredsList(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.redirectToLogin(w, r)
return
}
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "could not resolve actor")
return
}
creds, err := u.db.ListAccessiblePGCreds(actor.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load credentials")
return
}
// Build the list of system accounts that have no credentials at all
// (not just those absent from this actor's accessible set) so the
// create form remains available even when the actor has no existing creds.
credAcctIDs, err := u.db.ListCredentialedAccountIDs()
if err != nil {
u.logger.Warn("list credentialed account ids", "error", err)
credAcctIDs = map[int64]struct{}{}
}
allAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcreds create form", "error", err)
}
var uncredentialed []*model.Account
for _, a := range allAccounts {
if a.AccountType == model.AccountTypeSystem {
if _, hasCredential := credAcctIDs[a.ID]; !hasCredential {
uncredentialed = append(uncredentialed, a)
}
}
}
// For each credential owned by the actor, load its access grants so the
// /pgcreds page can render inline grant management controls.
credGrants := make(map[int64][]*model.PGCredAccessGrant)
for _, c := range creds {
if c.OwnerID != nil && *c.OwnerID == actor.ID {
grants, err := u.db.ListPGCredAccess(c.ID)
if err != nil {
u.logger.Warn("list pg cred access for owned cred", "cred_id", c.ID, "error", err)
continue
}
credGrants[c.ID] = grants
}
}
u.render(w, "pgcreds", PGCredsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Creds: creds,
UncredentialedAccounts: uncredentialed,
CredGrants: credGrants,
AllAccounts: allAccounts,
ActorID: &actor.ID,
})
}
// handleCreatePGCreds creates a new PG credential set from the /pgcreds page.
// The submitter selects a system account from the uncredentialed list and
// provides connection details; on success they become the credential owner.
//
// Security: only system accounts may hold PG credentials; the submitted account
// UUID is validated server-side. Password is encrypted with AES-256-GCM before
// storage; the plaintext is never logged or included in any response.
// Audit event EventPGCredUpdated is recorded on success.
func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
accountUUID := strings.TrimSpace(r.FormValue("account_uuid"))
if accountUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "account is required")
return
}
acct, err := u.db.GetAccountByUUID(accountUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
return
}
host := strings.TrimSpace(r.FormValue("host"))
portStr := strings.TrimSpace(r.FormValue("port"))
dbName := strings.TrimSpace(r.FormValue("database"))
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if host == "" {
u.renderError(w, r, http.StatusBadRequest, "host is required")
return
}
if dbName == "" {
u.renderError(w, r, http.StatusBadRequest, "database is required")
return
}
if username == "" {
u.renderError(w, r, http.StatusBadRequest, "username is required")
return
}
// Security: password is required on every write — the UI never carries an
// existing password, so callers must supply it explicitly.
if password == "" {
u.renderError(w, r, http.StatusBadRequest, "password is required")
return
}
port := 5432
if portStr != "" {
port, err = strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
u.renderError(w, r, http.StatusBadRequest, "port must be an integer between 1 and 65535")
return
}
}
// Security: encrypt with AES-256-GCM; fresh nonce per call.
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
if err != nil {
u.logger.Error("encrypt pg password", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.WritePGCredentials(acct.ID, host, port, dbName, username, enc, nonce); err != nil {
u.logger.Error("write pg credentials", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to save credentials")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventPGCredUpdated, actorID, &acct.ID, "")
// Security: set the credential owner to the actor on creation.
pgCred, err := u.db.ReadPGCredentials(acct.ID)
if err != nil {
u.logger.Warn("re-read pg credentials after create", "error", err)
}
if pgCred != nil && pgCred.OwnerID == nil && actorID != nil {
if err := u.db.SetPGCredentialOwner(pgCred.ID, *actorID); err != nil {
u.logger.Warn("set pg credential owner", "error", err)
} else {
pgCred.OwnerID = actorID
}
}
// Redirect to the pgcreds list so the new entry appears in context.
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
}
// handleAdminResetPassword allows an admin to set a new password for any human
// account without requiring the current password. On success all active tokens
// for the target account are revoked so a compromised account is fully
// invalidated.
//
// Security: caller must hold the admin role; the check is performed server-side
// against the JWT claims so it cannot be bypassed by client-side tricks.
// New password is validated (minimum 12 chars) and hashed with Argon2id before
// storage. The plaintext is never logged or included in any response.
// Audit event EventPasswordChanged is recorded on success.
func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
// Security: admin role is enforced by the requireAdminRole middleware in
// the route registration (ui.go); no inline check needed here.
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if acct.AccountType != model.AccountTypeHuman {
u.renderError(w, r, http.StatusBadRequest, "password can only be reset for human accounts")
return
}
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if newPassword == "" {
u.renderError(w, r, http.StatusBadRequest, "new password is required")
return
}
// Server-side equality check mirrors the client-side guard; defends against
// direct POST requests that bypass the JavaScript confirmation.
if newPassword != confirmPassword {
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(newPassword); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,
Threads: u.cfg.Argon2.Threads,
})
if err != nil {
u.logger.Error("hash password (admin reset)", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
u.logger.Error("update password hash", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
return
}
// Security: revoke all active sessions for the target account so an
// attacker who held a valid token cannot continue to use it after reset.
// Render an error fragment rather than silently claiming success if
// revocation fails.
if err := u.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
u.logger.Error("revoke tokens on admin password reset", "account_id", acct.ID, "error", err)
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
// Return a success fragment so HTMX can display confirmation inline.
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "password_reset_result", AccountDetailData{
PageData: PageData{
CSRFToken: csrfToken,
Flash: "Password updated and all active sessions revoked.",
},
Account: acct,
})
}

View File

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

View File

@@ -1,13 +1,14 @@
package ui
import (
"fmt"
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/validate"
)
// handleLoginPage renders the login form.
@@ -58,7 +59,7 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
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"})
return
}
@@ -79,7 +80,9 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if locked {
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
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
}
@@ -129,7 +132,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
accountID, ok := u.consumeTOTPNonce(nonce)
if !ok {
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"})
return
}
@@ -148,7 +151,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
u.render(w, "login", LoginData{Error: "internal error"})
return
}
valid, err := auth.ValidateTOTP(secret, totpCode)
valid, totpCounter, err := auth.ValidateTOTP(secret, totpCode)
if err != nil || !valid {
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
_ = u.db.RecordLoginFailure(acct.ID)
@@ -165,6 +168,23 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
})
return
}
// Security (CRIT-01): reject replay of a code already used within its
// ±30-second validity window.
if err := u.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"totp_replay"}`)
_ = u.db.RecordLoginFailure(acct.ID)
newNonce, nonceErr := u.issueTOTPNonce(acct.ID)
if nonceErr != nil {
u.render(w, "login", LoginData{Error: "internal error"})
return
}
u.render(w, "totp_step", LoginData{
Error: "invalid TOTP code",
Username: username,
Nonce: newNonce,
})
return
}
u.finishLogin(w, r, acct)
}
@@ -220,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.EventTokenIssued, &acct.ID, nil,
fmt.Sprintf(`{"jti":%q,"via":"ui"}`, claims.JTI))
audit.JSON("jti", claims.JTI, "via", "ui"))
// Redirect to dashboard.
if isHTMX(r) {
@@ -241,7 +261,7 @@ func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
}
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)
@@ -250,8 +270,134 @@ func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
// writeAudit is a fire-and-forget audit log helper for the UI package.
func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
ip := clientIP(r)
ip := u.clientIP(r)
if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
u.logger.Warn("write audit event", "type", eventType, "error", err)
}
}
// handleProfilePage renders the profile page for the currently logged-in user.
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "profile", ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
},
})
}
// handleSelfChangePassword allows an authenticated human user to change their
// own password. The current password must be supplied to prevent a stolen
// session token from being used to take over an account.
//
// Security: current password is verified with Argon2id (constant-time) before
// the new hash is written. Lockout is checked first so the endpoint cannot
// be used to brute-force the existing password. On success all other active
// sessions are revoked; the caller's own session is preserved so they remain
// logged in. The plaintext passwords are never logged or returned.
func (u *UIServer) handleSelfChangePassword(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusUnauthorized, "account not found")
return
}
if acct.AccountType != model.AccountTypeHuman {
u.renderError(w, r, http.StatusBadRequest, "password change is only available for human accounts")
return
}
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if currentPassword == "" || newPassword == "" {
u.renderError(w, r, http.StatusBadRequest, "current and new password are required")
return
}
// Server-side confirmation check mirrors the client-side guard; defends
// against direct POST requests that bypass the JavaScript validation.
if newPassword != confirmPassword {
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
return
}
// Security: check lockout before running Argon2 to prevent brute-force.
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (UI self-service password change)", "error", lockErr)
}
if locked {
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
u.renderError(w, r, http.StatusTooManyRequests, "account temporarily locked, please try again later")
return
}
// Security: verify current password with constant-time Argon2id path used
// at login so this endpoint cannot serve as a timing oracle.
ok, verifyErr := auth.VerifyPassword(currentPassword, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = u.db.RecordLoginFailure(acct.ID)
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
u.renderError(w, r, http.StatusUnauthorized, "current password is incorrect")
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(newPassword); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,
Threads: u.cfg.Argon2.Threads,
})
if err != nil {
u.logger.Error("hash password (UI self-service)", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
u.logger.Error("update password hash", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
return
}
// Security: clear failure counter (user proved knowledge of current
// password), then revoke all sessions except the current one so stale
// tokens are invalidated while the caller stays logged in.
_ = u.db.ClearLoginFailures(acct.ID)
if err := u.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
u.logger.Error("revoke other tokens on UI password change", "account_id", acct.ID, "error", err)
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
return
}
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"ui_self_service"}`)
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "password_change_result", ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
Flash: "Password updated successfully. Other active sessions have been revoked.",
},
})
}

View File

@@ -7,7 +7,8 @@ import (
"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) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
@@ -16,30 +17,33 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
return
}
accounts, err := u.db.ListAccounts()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
return
admin := isAdmin(r)
data := DashboardData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: admin},
}
var total, active int
for _, a := range accounts {
total++
if a.Status == model.AccountStatusActive {
active++
if admin {
accounts, err := u.db.ListAccounts()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
return
}
for _, a := range accounts {
data.TotalAccounts++
if a.Status == model.AccountStatusActive {
data.ActiveAccounts++
}
}
events, _, err := u.db.ListAuditEventsPaged(db.AuditQueryParams{Limit: 10, Offset: 0})
if err != nil {
u.logger.Warn("load recent audit events", "error", err)
events = nil
}
data.RecentEvents = events
}
events, _, err := u.db.ListAuditEventsPaged(db.AuditQueryParams{Limit: 10, Offset: 0})
if err != nil {
u.logger.Warn("load recent audit events", "error", err)
events = nil
}
u.render(w, "dashboard", DashboardData{
PageData: PageData{CSRFToken: csrfToken},
TotalAccounts: total,
ActiveAccounts: active,
RecentEvents: events,
})
u.render(w, "dashboard", data)
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
@@ -60,7 +61,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
}
data := PoliciesData{
PageData: PageData{CSRFToken: csrfToken},
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Rules: views,
AllActions: allActionStrings,
}
@@ -70,7 +71,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
// policyRuleToView converts a DB record to a template-friendly view.
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
pretty := prettyJSONStr(rec.RuleJSON)
return &PolicyRuleView{
v := &PolicyRuleView{
ID: rec.ID,
Priority: rec.Priority,
Description: rec.Description,
@@ -79,6 +80,16 @@ func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
}
now := time.Now()
if rec.NotBefore != nil {
v.NotBefore = rec.NotBefore.UTC().Format("2006-01-02 15:04 UTC")
v.IsPending = now.Before(*rec.NotBefore)
}
if rec.ExpiresAt != nil {
v.ExpiresAt = rec.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
v.IsExpired = now.After(*rec.ExpiresAt)
}
return v
}
func prettyJSONStr(s string) string {
@@ -160,6 +171,29 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
return
}
// Parse optional time-scoped validity window from datetime-local inputs.
var notBefore, expiresAt *time.Time
if nbStr := strings.TrimSpace(r.FormValue("not_before")); nbStr != "" {
t, err := time.Parse("2006-01-02T15:04", nbStr)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid not_before time format")
return
}
notBefore = &t
}
if eaStr := strings.TrimSpace(r.FormValue("expires_at")); eaStr != "" {
t, err := time.Parse("2006-01-02T15:04", eaStr)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid expires_at time format")
return
}
expiresAt = &t
}
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
u.renderError(w, r, http.StatusBadRequest, "expires_at must be after not_before")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
@@ -168,7 +202,7 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
}
}
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID, notBefore, expiresAt)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
return

View File

@@ -22,6 +22,7 @@ import (
"html/template"
"io/fs"
"log/slog"
"net"
"net/http"
"strings"
"sync"
@@ -30,6 +31,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/web"
)
@@ -141,6 +143,22 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
return false
},
"not": func(b bool) bool { return !b },
// derefInt64 safely dereferences a *int64, returning 0 for nil.
// Used in templates to compare owner IDs without triggering nil panics.
"derefInt64": func(p *int64) int64 {
if p == nil {
return 0
}
return *p
},
// isPGCredOwner returns true when actorID and cred are both non-nil
// and actorID matches cred.OwnerID. Safe to call with nil arguments.
"isPGCredOwner": func(actorID *int64, cred *model.PGCredential) bool {
if actorID == nil || cred == nil || cred.OwnerID == nil {
return false
}
return *actorID == *cred.OwnerID
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"gt": func(a, b int) bool { return a > b },
@@ -174,6 +192,8 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"templates/fragments/tags_editor.html",
"templates/fragments/policy_row.html",
"templates/fragments/policy_form.html",
"templates/fragments/password_reset_form.html",
"templates/fragments/password_change_form.html",
}
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil {
@@ -190,6 +210,8 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"audit": "templates/audit.html",
"audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html",
"pgcreds": "templates/pgcreds.html",
"profile": "templates/profile.html",
}
tmpls := make(map[string]*template.Template, len(pageFiles))
for name, file := range pageFiles {
@@ -203,7 +225,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
tmpls[name] = clone
}
return &UIServer{
srv := &UIServer{
db: database,
cfg: cfg,
pubKey: pub,
@@ -212,7 +234,33 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
logger: logger,
csrf: csrf,
tmpls: tmpls,
}, nil
}
// Security (DEF-02): launch a background goroutine to evict expired TOTP
// nonces from pendingLogins. consumeTOTPNonce deletes entries on use, but
// entries abandoned by users who never complete step 2 would otherwise
// accumulate indefinitely, enabling a memory-exhaustion attack.
go srv.cleanupPendingLogins()
return srv, nil
}
// cleanupPendingLogins periodically evicts expired entries from pendingLogins.
// It runs every 5 minutes, which is well within the 90-second nonce TTL, so
// stale entries are removed before they can accumulate to any significant size.
func (u *UIServer) cleanupPendingLogins() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
u.pendingLogins.Range(func(key, value any) bool {
pl, ok := value.(*pendingLogin)
if !ok || now.After(pl.expiresAt) {
u.pendingLogins.Delete(key)
}
return true
})
}
}
// Register attaches all UI routes to mux, wrapped with security headers.
@@ -228,7 +276,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
if err != nil {
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.
uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
@@ -239,21 +290,35 @@ func (u *UIServer) Register(mux *http.ServeMux) {
http.NotFound(w, r)
})
// Security (DEF-01, DEF-03): apply the same per-IP rate limit as the REST
// /v1/auth/login endpoint, using the same proxy-aware IP extraction so
// the rate limit is applied to real client IPs behind a reverse proxy.
var trustedProxy net.IP
if u.cfg.Server.TrustedProxy != "" {
trustedProxy = net.ParseIP(u.cfg.Server.TrustedProxy)
}
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
// Auth routes (no session required).
uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.HandleFunc("POST /login", u.handleLoginPost)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
uiMux.HandleFunc("POST /logout", u.handleLogout)
// 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 {
return auth(u.requireCSRF(http.HandlerFunc(h)))
return authed(u.requireAdminRole(u.requireCSRF(http.HandlerFunc(h))))
}
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("POST /accounts", admin(u.handleCreateAccount))
uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
@@ -264,6 +329,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
uiMux.Handle("GET /pgcreds", adminGet(u.handlePGCredsList))
uiMux.Handle("POST /pgcreds", admin(u.handleCreatePGCreds))
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
@@ -272,6 +341,11 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
// Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -340,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 ----
// isHTMX reports whether the request was initiated by HTMX.
@@ -441,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).
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
// UI response.
//
@@ -456,6 +564,9 @@ const maxFormBytes = 1 << 20
// requests to this origin for two years, preventing TLS-strip on revisit.
// - Referrer-Policy: suppresses the Referer header on outbound navigations so
// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
@@ -465,17 +576,42 @@ func securityHeaders(next http.Handler) http.Handler {
h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
next.ServeHTTP(w, r)
})
}
// clientIP extracts the client IP from RemoteAddr (best effort).
func clientIP(r *http.Request) string {
addr := r.RemoteAddr
if idx := strings.LastIndex(addr, ":"); idx != -1 {
return addr[:idx]
// clientIP returns the real client IP for the request, respecting the
// server's trusted-proxy setting (DEF-03). Delegates to middleware.ClientIP
// so the same extraction logic is used for rate limiting and audit logging.
func (u *UIServer) clientIP(r *http.Request) string {
var proxyIP net.IP
if u.cfg.Server.TrustedProxy != "" {
proxyIP = net.ParseIP(u.cfg.Server.TrustedProxy)
}
return addr
return middleware.ClientIP(r, proxyIP)
}
// isAdmin reports whether the authenticated user holds the "admin" role.
// Returns false if claims are absent.
func isAdmin(r *http.Request) bool {
claims := claimsFromContext(r.Context())
return claims != nil && claims.HasRole("admin")
}
// actorName resolves the username of the currently authenticated user from the
// request context. Returns an empty string if claims are absent or the account
// cannot be found; callers should treat an empty string as "not logged in".
func (u *UIServer) actorName(r *http.Request) string {
claims := claimsFromContext(r.Context())
if claims == nil {
return ""
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
return ""
}
return acct.Username
}
// ---- Page data types ----
@@ -485,6 +621,13 @@ type PageData struct {
CSRFToken string
Flash string
Error string
// ActorName is the username of the currently logged-in user, populated by
// handlers so the base template can display it in the navigation bar.
ActorName string
// 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.
@@ -514,7 +657,16 @@ type AccountsData struct {
// AccountDetailData is the view model for the account detail page.
type AccountDetailData struct {
Account *model.Account
PGCred *model.PGCredential // nil if none stored or account is not a system account
// PGCred is nil if none stored or the account is not a system account.
PGCred *model.PGCredential
// PGCredGrants lists accounts that have been granted read access to PGCred.
// Only populated when the viewing actor is the credential owner.
PGCredGrants []*model.PGCredAccessGrant
// GrantableAccounts is the list of accounts the owner may grant access to.
GrantableAccounts []*model.Account
// ActorID is the DB id of the currently logged-in user; used in templates
// to decide whether to show the owner-only management controls.
ActorID *int64
PageData
Roles []string
AllRoles []string
@@ -545,9 +697,13 @@ type PolicyRuleView struct {
RuleJSON string
CreatedAt string
UpdatedAt string
NotBefore string // empty if not set
ExpiresAt string // empty if not set
ID int64
Priority int
Enabled bool
IsExpired bool // true if expires_at is in the past
IsPending bool // true if not_before is in the future
}
// PoliciesData is the view model for the policies list page.
@@ -556,3 +712,26 @@ type PoliciesData struct {
Rules []*PolicyRuleView
AllActions []string
}
// ProfileData is the view model for the profile/settings page.
type ProfileData struct {
PageData
}
// PGCredsData is the view model for the "My PG Credentials" list page.
// It shows all pg_credentials sets accessible to the currently logged-in user:
// those they own and those they have been granted access to.
// UncredentialedAccounts is the list of system accounts that have no credentials
// yet, populated to drive the "New Credentials" create form on the same page.
// CredGrants maps credential ID to the list of access grants for that credential;
// only populated for credentials the actor owns.
// AllAccounts is used to populate the grant-access dropdown for owned credentials.
// ActorID is the DB id of the currently logged-in user.
type PGCredsData struct {
CredGrants map[int64][]*model.PGCredAccessGrant
ActorID *int64
PageData
Creds []*model.PGCredential
UncredentialedAccounts []*model.Account
AllAccounts []*model.Account
}

View File

@@ -13,6 +13,7 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"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"},
{"Strict-Transport-Security", "max-age="},
{"Referrer-Policy", "no-referrer"},
{"Permissions-Policy", "camera=()"},
}
for _, c := range checks {
val := h.Get(c.header)
@@ -355,6 +357,34 @@ func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Requ
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
// endpoint returns 400 when the target account is a human (not system) account.
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")
}
}
// 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.
const MinPasswordLen = 12
// Password returns nil if the plaintext password meets the minimum length
// requirement, or a descriptive error if not.
// MaxPasswordLen is the maximum acceptable plaintext password length.
//
// 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 {
if len(password) < 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
}

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) {
valid := []string{
"alice",

View File

@@ -34,7 +34,7 @@ environment variable.
.It Fl server Ar url
Base URL of the mciassrv instance.
Default:
.Qq https://localhost:8443 .
.Qq https://mcias.metacircular.net:8443 .
Can also be set with the
.Ev MCIAS_SERVER
environment variable.

View File

@@ -1,4 +1,4 @@
.Dd March 11, 2026
.Dd March 12, 2026
.Dt MCIASGRPCCTL 1
.Os
.Sh NAME
@@ -37,7 +37,7 @@ gRPC server address in
.Ar host:port
format.
Default:
.Qq localhost:9443 .
.Qq mcias.metacircular.net:9443 .
.It Fl token Ar jwt
Bearer token for authentication.
Can also be set with the
@@ -58,6 +58,18 @@ and exits 0 if the server is healthy.
.It Nm Ic pubkey
Returns the server's Ed25519 public key as a JWK.
.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
.Bl -tag -width Ds
.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
Sets Postgres credentials for the account.
.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
.Bl -tag -width Ds
.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.
Use the base URL of your MCIAS server.
.It Sy default_expiry
.Pq optional, default 720h
.Pq optional, default 168h
Token expiry for interactive logins.
Go duration string.
.It Sy admin_expiry

View File

@@ -206,6 +206,24 @@ components:
enabled:
type: boolean
example: true
not_before:
type: string
format: date-time
nullable: true
description: |
Earliest time the rule becomes active. NULL means no constraint
(always active). Rules where `not_before > now()` are skipped
during evaluation.
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
nullable: true
description: |
Time after which the rule is no longer active. NULL means no
constraint (never expires). Rules where `expires_at <= now()` are
skipped during evaluation.
example: "2026-06-01T00:00:00Z"
created_at:
type: string
format: date-time
@@ -532,6 +550,17 @@ paths:
tags: [Auth]
security:
- 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:
"200":
description: TOTP secret generated.
@@ -582,6 +611,68 @@ paths:
"401":
$ref: "#/components/responses/Unauthorized"
/v1/auth/password:
put:
summary: Change own password (self-service)
description: |
Change the password of the currently authenticated human account.
The caller must supply the correct `current_password` to prevent
token-theft attacks: possession of a valid JWT alone is not sufficient.
On success:
- The stored Argon2id hash is replaced with the new password hash.
- All active sessions *except* the caller's current token are revoked.
- The lockout failure counter is cleared.
On failure (wrong current password):
- A login failure is recorded against the account, subject to the
same lockout rules as `POST /v1/auth/login`.
Only applies to human accounts. System accounts have no password.
operationId: changePassword
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [current_password, new_password]
properties:
current_password:
type: string
description: The account's current password (required for verification).
example: old-s3cr3t
new_password:
type: string
description: The new password. Minimum 12 characters.
example: new-s3cr3t-long
responses:
"204":
description: Password changed. Other active sessions revoked.
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Current password is incorrect.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: current password is incorrect
code: unauthorized
"429":
description: Account temporarily locked due to too many failed attempts.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: account temporarily locked
code: account_locked
# ── Admin ──────────────────────────────────────────────────────────────────
/v1/auth/totp:
@@ -915,6 +1006,76 @@ paths:
"404":
$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:
parameters:
- name: id
@@ -984,7 +1145,10 @@ paths:
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
`account_created`, `account_updated`, `account_deleted`,
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`,
`pgcred_accessed`, `pgcred_updated`.
`pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`,
`pgcred_access_revoked`, `tag_added`, `tag_removed`,
`policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`,
`policy_deny`.
operationId: listAudit
tags: [Admin — Audit]
security:
@@ -1118,12 +1282,63 @@ paths:
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/password:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
put:
summary: Admin password reset (admin)
description: |
Reset the password for a human account without requiring the current
password. This is intended for account recovery (e.g. a user forgot
their password).
On success:
- The stored Argon2id hash is replaced with the new password hash.
- All active sessions for the target account are revoked.
Only applies to human accounts. The new password must be at least
12 characters.
operationId: adminSetPassword
tags: [Admin — Accounts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [new_password]
properties:
new_password:
type: string
description: The new password. Minimum 12 characters.
example: new-s3cr3t-long
responses:
"204":
description: Password reset. All active sessions for the account revoked.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/policy/rules:
get:
summary: List policy rules (admin)
description: |
Return all operator-defined policy rules ordered by priority (ascending).
Built-in default rules (IDs -1 to -6) are not included.
Built-in default rules (IDs -1 to -7) are not included.
operationId: listPolicyRules
tags: [Admin — Policy]
security:
@@ -1169,6 +1384,16 @@ paths:
example: 50
rule:
$ref: "#/components/schemas/RuleBody"
not_before:
type: string
format: date-time
description: Earliest activation time (RFC3339, optional).
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
description: Expiry time (RFC3339, optional).
example: "2026-06-01T00:00:00Z"
responses:
"201":
description: Rule created.
@@ -1239,6 +1464,22 @@ paths:
example: false
rule:
$ref: "#/components/schemas/RuleBody"
not_before:
type: string
format: date-time
description: Set earliest activation time (RFC3339).
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
description: Set expiry time (RFC3339).
example: "2026-06-01T00:00:00Z"
clear_not_before:
type: boolean
description: Set to true to remove not_before constraint.
clear_expires_at:
type: boolean
description: Set to true to remove expires_at constraint.
responses:
"200":
description: Updated rule.

View File

@@ -6,5 +6,5 @@
//
// 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

Some files were not shown because too many files have changed in this diff Show More