Commit Graph

25 Commits

Author SHA1 Message Date
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
8840317cce Merge SEC-10: add Permissions-Policy header 2026-03-13 00:49:34 -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
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
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
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
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
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
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
614c83c803 Add PG creds + policy/tags UI; fix lint and build
- internal/ui/ui.go: add PGCred, Tags to AccountDetailData; register
  PUT /accounts/{id}/pgcreds and PUT /accounts/{id}/tags routes; add
  pgcreds_form.html and tags_editor.html to shared template set; remove
  unused AccountTagsData; fix fieldalignment on PolicyRuleView, PoliciesData
- internal/ui/handlers_accounts.go: add handleSetPGCreds — encrypts
  password via crypto.SealAESGCM, writes audit EventPGCredUpdated, renders
  pgcreds_form fragment; password never echoed; load PG creds and tags in
  handleAccountDetail
- internal/ui/handlers_policy.go: fix handleSetAccountTags to render with
  AccountDetailData instead of removed AccountTagsData
- internal/ui/ui_test.go: add 5 PG credential UI tests
- web/templates/fragments/pgcreds_form.html: new fragment — metadata display
  + set/replace form; system accounts only; password write-only
- web/templates/fragments/tags_editor.html: new fragment — textarea editor
  with HTMX PUT for atomic tag replacement
- web/templates/fragments/policy_form.html: rewrite to use structured fields
  matching handleCreatePolicyRule (roles/account_types/actions multi-select,
  resource_type, subject_uuid, service_names, required_tags, checkbox)
- web/templates/policies.html: new policies management page
- web/templates/fragments/policy_row.html: new HTMX table row with toggle
  and delete
- web/templates/account_detail.html: add Tags card and PG Credentials card
- web/templates/base.html: add Policies nav link
- internal/server/server.go: remove ~220 lines of duplicate tag/policy
  handler code (real implementations are in handlers_policy.go)
- internal/policy/engine_wrapper.go: fix corrupted source; use errors.New
- internal/db/policy_test.go: use model.AccountTypeHuman constant
- cmd/mciasctl/main.go: add nolint:gosec to int(os.Stdin.Fd()) calls
- gofmt/goimports: db/policy_test.go, policy/defaults.go,
  policy/engine_test.go, ui/ui.go, cmd/mciasctl/main.go
- fieldalignment: model.PolicyRuleRecord, policy.Engine, policy.Rule,
  policy.RuleBody, ui.PolicyRuleView
Security: PG password encrypted AES-256-GCM with fresh random nonce before
storage; plaintext never logged or returned in any response; audit event
written on every credential write.
2026-03-11 23:24:03 -07:00
fdcc117c89 Fix UI: install real HTMX, add PG creds and roles UI
- web/static/htmx.min.js: replace placeholder stub with
  htmx 2.0.4 (downloaded from unpkg.com). The placeholder
  only logged a console warning; no HTMX features worked,
  so form submissions fell back to native POSTs and the
  account_row fragment was returned as a raw HTML body
  rather than spliced into the table. This was the root
  cause of account creation appearing to 'do nothing'.
- internal/ui/ui.go: add pgcreds_form.html to shared
  template list; add PUT /accounts/{id}/pgcreds route;
  reorder AccountDetailData fields so embedded PageData
  does not shadow Account.
- internal/ui/handlers_accounts.go: add handleSetPGCreds
  handler — encrypts the submitted password with AES-256-GCM
  using the server master key before storage, validates
  system-account-only constraint, re-reads and re-renders
  the fragment after save. Add PGCred field population to
  handleAccountDetail.
- internal/ui/ui_test.go: add tests for account creation,
  role management, and PG credential handlers.
- web/templates/account_detail.html: add Postgres
  Credentials card for system accounts.
- web/templates/fragments/pgcreds_form.html: new fragment
  for the PG credentials form; CSRF token is supplied via
  the body-level hx-headers attribute in base.html.
Security: PG password is encrypted with AES-256-GCM
(crypto.SealAESGCM) before storage; a fresh nonce is
generated per call; the plaintext is never logged or
returned in responses.
2026-03-11 22:30:13 -07:00
3c94c948e2 Fix F-08, F-12, F-13: Implement account lockout, username validation, and password minimum length enforcement
- Added failed login tracking for account lockout enforcement in `db` and `ui` layers; introduced `failed_logins` table to store attempts, window start, and attempt count.
- Updated login checks in `grpcserver/auth.go` and `ui/handlers_auth.go` to reject requests if the account is locked.
- Added immediate failure counter reset on successful login.
- Implemented username length and character set validation (F-12) and minimum password length enforcement (F-13) in shared `validate` package.
- Updated account creation and edit flows in `ui` and `grpcserver` layers to apply validation before hashing/processing.
- Added comprehensive unit tests for lockout, validation, and related edge cases.
- Updated `AUDIT.md` to mark F-08, F-12, and F-13 as fixed.
- Updated `openapi.yaml` to reflect new validation and lockout behaviors.

Security: Prevents brute-force attacks via lockout mechanism and strengthens defenses against weak and invalid input.
2026-03-11 20:59:26 -07:00
2dbc553abe Fix F-07: pre-compute real Argon2 dummy hash via sync.Once
- auth/auth.go: add DummyHash() which uses sync.Once to compute
  HashPassword("dummy-password-for-timing-only", DefaultArgonParams())
  on first call; subsequent calls return the cached PHC string;
  add sync to imports
- auth/auth_test.go: TestDummyHashIsValidPHC verifies the hash
  parses and verifies correctly; TestDummyHashIsCached verifies
  sync.Once behaviour; TestDummyHashMatchesDefaultParams verifies
  embedded m/t/p match DefaultArgonParams()
- server/server.go, grpcserver/auth.go, ui/ui.go: replace five
  hardcoded PHC strings with auth.DummyHash() calls
- AUDIT.md: mark F-07 as fixed
Security: the previous hardcoded hash used a 6-byte salt and
  6-byte output ("testsalt"/"testhash" in base64), which Argon2id
  verifies faster than a real 16-byte-salt / 32-byte-output hash.
  This timing gap was measurable and could aid user enumeration.
  auth.DummyHash() uses identical parameters and full-length salt
  and output, so dummy verification timing matches real timing
  exactly, regardless of future parameter changes.
2026-03-11 20:37:27 -07:00
06ec8be1c9 Fix F-16: revoke old system token before issuing new one
- ui/handlers_accounts.go (handleIssueSystemToken): call
  GetSystemToken before issuing; if one exists, call
  RevokeToken(existing.JTI, "rotated") before TrackToken
  and SetSystemToken for the new token; mirrors the pattern
  in REST handleTokenIssue and gRPC IssueServiceToken
- db/db_test.go: TestSystemTokenRotationRevokesOld verifies
  the full rotation flow: old JTI revoked with reason
  "rotated", new JTI tracked and active, GetSystemToken
  returns the new JTI
- AUDIT.md: mark F-16 as fixed
Security: without this fix an old system token remained valid
  after rotation until its natural expiry, giving a leaked or
  stolen old token extra lifetime. With the revocation the old
  JTI is immediately marked in token_revocation so any validator
  checking revocation status rejects it.
2026-03-11 20:34:57 -07:00
e20b66d6f6 Fix F-02: replace password-in-hidden-field with nonce
- ui/ui.go: add pendingLogin struct and pendingLogins sync.Map
  to UIServer; add issueTOTPNonce (generates 128-bit random nonce,
  stores accountID with 90s TTL) and consumeTOTPNonce (single-use,
  expiry-checked LoadAndDelete); add dummyHash() method
- ui/handlers_auth.go: split handleLoginPost into step 1
  (password verify → issue nonce) and step 2 (handleTOTPStep,
  consume nonce → validate TOTP) via a new finishLogin helper;
  password never transmitted or stored after step 1
- ui/ui_test.go: refactor newTestMux to reuse new
  newTestUIServer; add TestTOTPNonceIssuedAndConsumed,
  TestTOTPNonceUnknownRejected, TestTOTPNonceExpired, and
  TestLoginPostPasswordNotInTOTPForm; 11/11 tests pass
- web/templates/fragments/totp_step.html: replace
  'name=password' hidden field with 'name=totp_nonce'
- db/accounts.go: add GetAccountByID for TOTP step lookup
- AUDIT.md: mark F-02 as fixed
Security: the plaintext password previously survived two HTTP
  round-trips and lived in the browser DOM during the TOTP step.
  The nonce approach means the password is verified once and
  immediately discarded; only an opaque random token tied to an
  account ID (never a credential) crosses the wire on step 2.
  Nonces are single-use and expire after 90 seconds to limit
  the window if one is captured.
2026-03-11 20:33:04 -07:00
47847a4312 Fix F-04 + F-11; add AUDIT.md
- AUDIT.md: security audit report with 16 findings (F-01..F-16)
- F-04 (server.go): wire loginRateLimit (10 req/s, burst 10) to
  POST /v1/auth/login and POST /v1/token/validate; no limit on
  /v1/health or public-key endpoints
- F-04 (server_test.go): TestLoginRateLimited uses concurrent
  goroutines (sync.WaitGroup) to fire burst+1 requests before
  Argon2id completes, sidestepping token-bucket refill timing;
  TestTokenValidateRateLimited; TestHealthNotRateLimited
- F-11 (ui.go): refactor Register() so all UI routes are mounted
  on a child mux wrapped with securityHeaders middleware; five
  headers set on every response: Content-Security-Policy,
  X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy
- F-11 (ui_test.go): 7 new tests covering login page, dashboard
  redirect, root redirect, static assets, CSP directives,
  HSTS min-age, and middleware unit behaviour
Security: rate limiter on login prevents brute-force credential
  stuffing; security headers mitigate clickjacking (X-Frame-Options
  DENY), MIME sniffing (nosniff), and protocol downgrade (HSTS)
2026-03-11 20:18:09 -07:00
f2903ca103 Fix grpcserver rate limiter: move to Server field
The package-level defaultRateLimiter drained its token bucket
across all test cases, causing later tests to hit ResourceExhausted.
Move rateLimiter from a package-level var to a *grpcRateLimiter field
on Server; New() allocates a fresh instance (10 req/s, burst 10) per
server. Each test's newTestEnv() constructs its own Server, so tests
no longer share limiter state.

Production behaviour is unchanged: a single Server is constructed at
startup and lives for the process lifetime.
2026-03-11 19:23:34 -07:00
4d140886ca Add HTMX-based UI templates and handlers for account and audit management
- Introduced `web/templates/` for HTMX-fragmented pages (`dashboard`, `accounts`, `account_detail`, `error_fragment`, etc.).
- Implemented UI routes for account CRUD, audit log display, and login/logout with CSRF protection.
- Added `internal/ui/` package for handlers, CSRF manager, session validation, and token issuance.
- Updated documentation to include new UI features and templates directory structure.
- Security: Double-submit CSRF cookies, constant-time HMAC validation, login password/Argon2id re-verification at all steps to prevent bypass.
2026-03-11 18:02:53 -07:00