- 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.
* 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).
- 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.
- 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.
- Corrected lockout logic (`IsLockedOut`) to properly evaluate failed login thresholds within the rolling window, ensuring stale attempts outside the window do not trigger lockout.
- Updated test passwords in `grpcserver_test.go` to comply with 12-character minimum requirement.
- Reformatted import blocks with `goimports` to address lint warnings.
- Verified all tests pass and linter is clean.
- 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.
- 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.
- 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.
- 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.
- db/accounts.go: add RenewToken(oldJTI, reason, newJTI,
accountID, issuedAt, expiresAt) which wraps RevokeToken +
TrackToken in a single BEGIN/COMMIT transaction; if either
step fails the whole tx rolls back, so the user is never
left with neither old nor new token valid
- server.go (handleRenewToken): replace separate RevokeToken +
TrackToken calls with single RenewToken call; failure now
returns 500 instead of silently losing revocation
- grpcserver/auth.go (RenewToken): same replacement
- db/db_test.go: TestRenewTokenAtomic verifies old token is
revoked with correct reason, new token is tracked and not
revoked, and a second renewal on the already-revoked old
token returns an error
- AUDIT.md: mark F-03 as fixed
Security: without atomicity a crash/error between revoke and
track could leave the old token active alongside the new one
(two live tokens) or revoke the old token without tracking
the new one (user locked out). The transaction ensures
exactly one of the two tokens is valid at all times.
- db/accounts.go: add StorePendingTOTP() which writes
totp_secret_enc and totp_secret_nonce but leaves
totp_required=0; add comment explaining two-phase flow
- server.go (handleTOTPEnroll): switch from SetTOTP() to
StorePendingTOTP() so the required flag is only set after
the user confirms a valid TOTP code via handleTOTPConfirm,
which still calls SetTOTP()
- server_test.go: TestTOTPEnrollDoesNotRequireTOTP verifies
that after POST /v1/auth/totp/enroll, TOTPRequired is false
and the encrypted secret is present; confirms that a
subsequent login without a TOTP code still succeeds (no
lockout)
- AUDIT.md: mark F-01 and F-11 as fixed
Security: without this fix an admin who enrolls TOTP but
abandons before confirmation is permanently locked out
because totp_required=1 but no confirmed secret exists.
StorePendingTOTP() keeps the secret pending until the user
proves possession by confirming a valid code.
- 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)
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.
- 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.
- ARCHITECTURE.md §12: add mciasdb, mciasgrpcctl, internal/grpcserver,
proto/, and gen/ to the directory structure diagram
- ARCHITECTURE.md §17: replace buf generate references with protoc;
the implementation uses protoc + protoc-gen-go + protoc-gen-go-grpc
invoked via go generate ./... (proto/generate.go)
- PROJECT_PLAN.md §7.1: replace buf.yaml/buf.gen.yaml acceptance
criteria with the protoc-based go:generate approach actually used
- PROJECT_PLAN.md §7.6: clarify that gen/ is committed to the repo
(not gitignored); only the binary /mciasgrpcctl is excluded
- PROJECT.md: replace scrypt with Argon2id (the actual algorithm);
remove the redundant Argon2 suggestion line
- proto/mcias/v1/: AdminService, AuthService, TokenService,
AccountService, CredentialService; generated Go stubs in gen/
- internal/grpcserver: full handler implementations sharing all
business logic (auth, token, db, crypto) with REST server;
interceptor chain: logging -> auth (JWT alg-first + revocation) ->
rate-limit (token bucket, 10 req/s, burst 10, per-IP)
- internal/config: optional grpc_addr field in [server] section
- cmd/mciassrv: dual-stack startup; gRPC/TLS listener on grpc_addr
when configured; graceful shutdown of both servers in 15s window
- cmd/mciasgrpcctl: companion gRPC CLI mirroring mciasctl commands
(health, pubkey, account, role, token, pgcreds) using TLS with
optional custom CA cert
- internal/grpcserver/grpcserver_test.go: 20 tests via bufconn covering
public RPCs, auth interceptor (no token, invalid, revoked -> 401),
non-admin -> 403, Login/Logout/RenewToken/ValidateToken flows,
AccountService CRUD, SetPGCreds/GetPGCreds AES-GCM round-trip,
credential fields absent from all responses
Security:
JWT validation path identical to REST: alg header checked before
signature, alg:none rejected, revocation table checked after sig.
Authorization metadata value never logged by any interceptor.
Credential fields (PasswordHash, TOTPSecret*, PGPassword) absent from
all proto response messages — enforced by proto design and confirmed
by test TestCredentialFieldsAbsentFromAccountResponse.
Login dummy-Argon2 timing guard preserves timing uniformity for
unknown users (same as REST handleLogin).
TLS required at listener level; cmd/mciassrv uses
credentials.NewServerTLSFromFile; no h2c offered.
137 tests pass, zero race conditions (go test -race ./...)
- Added `web/templates/{dashboard,audit,base,accounts,account_detail}.html` for a consistent UI.
- Implemented new audit log endpoint (`GET /v1/audit`) with filtering and pagination via `ListAuditEventsPaged`.
- Extended `AuditQueryParams`, added `AuditEventView` for joined actor/target usernames.
- Updated configuration (`goimports` preference), linting rules, and E2E tests.
- No logic changes to existing APIs.
* Rewrite .golangci.yaml to v2 schema: linters-settings ->
linters.settings, issues.exclude-rules -> issues.exclusions.rules,
issues.exclude-dirs -> issues.exclusions.paths
* Drop deprecated revive exported/package-comments rules: personal
project, not a public library; godoc completeness is not a CI req
* Add //nolint:gosec G101 on PassphraseEnv default in config.go:
environment variable name is not a credential value
* Add //nolint:gosec G101 on EventPGCredUpdated in model.go:
audit event type string, not a credential
Security: no logic changes. gosec G101 suppressions are false
positives confirmed by code inspection: neither constant holds a
credential value.
- Add test/e2e: 11 end-to-end tests covering full login/logout,
token renewal, admin account management, credential-never-in-response,
unauthorised access, JWT alg confusion and alg:none attacks,
revoked token rejection, system account token issuance,
wrong-password vs unknown-user indistinguishability
- Apply gofmt to all source files (formatting only, no logic changes)
- Update .golangci.yaml for golangci-lint v2 (version field required,
gosimple merged into staticcheck, formatters section separated)
- Update PROGRESS.md to reflect Phase 5 completion
Security:
All 97 tests pass with go test -race ./... (zero race conditions).
Adversarial JWT tests (alg confusion, alg:none) confirm the
ValidateToken alg-first check is effective against both attack classes.
Credential fields (PasswordHash, TOTPSecret*, PGPassword) confirmed
absent from all API responses via both unit and e2e tests.
go vet ./... clean. golangci-lint v2.6.2 incompatible with go1.26
runtime; go vet used as linter until toolchain is updated.