# MCIAS Progress

Source of truth for current development state.
---
Phases 0–14 complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.

### 2026-03-16 — TOTP enrollment via web UI

**Task:** Add TOTP enrollment and management to the web UI profile page.

**Changes:**
- **Dependency:** `github.com/skip2/go-qrcode` for server-side QR code generation
- **Profile page:** TOTP section showing enabled status or enrollment form
- **Enrollment flow:** Password re-auth → generate secret → show QR code + manual entry → confirm with 6-digit code
- **QR code:** Generated server-side as `data:image/png;base64,...` URI (CSP-compliant)
- **Account detail:** Admin "Remove TOTP" button with HTMX delete + confirm
- **Enrollment nonces:** `pendingTOTPEnrolls sync.Map` with 5-minute TTL, single-use
- **Template fragments:** `totp_section.html`, `totp_enroll_qr.html`
- **Handler:** `internal/ui/handlers_totp.go` with `handleTOTPEnrollStart`, `handleTOTPConfirm`, `handleAdminTOTPRemove`
- **Security:** Password re-auth (SEC-01), lockout check, CSRF, single-use nonces, TOTP counter replay prevention (CRIT-01)

---

### 2026-03-16 — Phase 14: FIDO2/WebAuthn and Passkey Authentication

**Task:** Add FIDO2/WebAuthn support for passwordless passkey login and security key 2FA.

**Changes:**
- **Dependency:** `github.com/go-webauthn/webauthn v0.16.1`
- **Config:** `WebAuthnConfig` struct with RPID, RPOrigin, DisplayName; validation; `WebAuthnEnabled()` method
- **Model:** `WebAuthnCredential` struct with encrypted credential fields; 4 audit events; 2 policy actions
- **Migration 000009:** `webauthn_credentials` table with encrypted credential ID/pubkey, sign counter, discoverable flag
- **DB layer:** Full CRUD in `internal/db/webauthn.go` (create, get, delete with ownership, admin delete, delete all, sign count, last used, has, count)
- **Adapter:** `internal/webauthn/` package — library initialization, `AccountUser` interface, AES-256-GCM encrypt/decrypt round-trip
- **Policy:** Default rule -8 for self-service enrollment
- **REST API:** 6 endpoints (register begin/finish, login begin/finish, list credentials, delete credential) with `sync.Map` ceremony store
- **Web UI:** Profile page enrollment+management, login page passkey button, admin account detail passkeys section, CSP-compliant `webauthn.js`
- **gRPC:** `ListWebAuthnCredentials` and `RemoveWebAuthnCredential` RPCs with handler
- **mciasdb:** `webauthn list/delete/reset` subcommands and `account reset-webauthn` alias
- **OpenAPI:** All 6 endpoints documented; `WebAuthnCredentialInfo` schema; `webauthn_enabled`/`webauthn_count` on Account
- **Tests:** DB CRUD tests, adapter encrypt/decrypt round-trip, interface compliance, wrong-key rejection
- **Docs:** ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14, PROGRESS.md

---

### 2026-03-16 — Documentation sync (ARCHITECTURE.md, PROJECT_PLAN.md)

**Task:** Full documentation audit to sync ARCHITECTURE.md and PROJECT_PLAN.md with v1.0.0 implementation.

**ARCHITECTURE.md changes:**
- §8 Postgres Credential Endpoints: added missing `GET /v1/pgcreds`
- §12 Directory/Package Structure: added `internal/audit/`, `internal/vault/`, `web/embed.go`; added `clients/`, `test/`, `dist/`, `man/` top-level dirs; removed stale "(Phase N)" labels
- §17 Proto Package Layout: added `policy.proto`
- §17 Service Definitions: added `PolicyService` row
- §18 Makefile Targets: added `docker-clean`; corrected `docker` and `clean` descriptions

**PROJECT_PLAN.md changes:**
- All phases 0–9 marked `[COMPLETE]`
- Added status summary at top (v1.0.0, 2026-03-15)
- Phase 4.1: added `mciasctl pgcreds list` subcommand (implemented, was missing from plan)
- Phase 7.1: added `policy.proto` to proto file list
- Phase 8.5: added `docker-clean` target; corrected `docker` and `clean` target descriptions
- Added Phase 10: Web UI (HTMX)
- Added Phase 11: Authorization Policy Engine
- Added Phase 12: Vault Seal/Unseal Lifecycle
- Added Phase 13: Token Delegation and pgcred Access Grants
- Updated implementation order to include phases 10–13

**No code changes.** Documentation only.

---

### 2026-03-15 — Makefile: docker image cleanup

**Task:** Ensure `make clean` removes Docker build images; add dedicated `docker-clean` target.

**Changes:**
- `clean` target now runs `docker rmi mcias:$(VERSION) mcias:latest` (errors suppressed so clean works without Docker).
- New `docker-clean` target removes the versioned and `latest` tags and prunes dangling images with the mcias label.
- Header comment and `help` target updated to document `docker-clean`.

**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.

---

### 2026-03-15 — Fix Swagger server URLs

**Task:** Update Swagger `servers` section to use correct auth server URLs.

**Changes:**
- `openapi.yaml` and `web/static/openapi.yaml`: replaced `https://auth.example.com:8443` with `https://mcias.metacircular.net:8443` (Production) and `https://localhost:8443` (Local test server).

**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.

---

### 2026-03-15 — Fix /docs Swagger UI (bundle assets locally)

**Problem:** `/docs` was broken because `docs.html` loaded `swagger-ui-bundle.js` and `swagger-ui.css` from `unpkg.com` CDN, which is blocked by the server's `Content-Security-Policy: default-src 'self'` header.

**Solution:**
- Downloaded `swagger-ui-dist@5.32.0` via npm and copied `swagger-ui-bundle.js` and `swagger-ui.css` into `web/static/` (embedded at build time).
- Updated `docs.html` to reference `/static/swagger-ui-bundle.js` and `/static/swagger-ui.css`.
- Added `GET /static/swagger-ui-bundle.js` and `GET /static/swagger-ui.css` handlers in `server.go` serving the embedded bytes with correct `Content-Type` headers.
- No CSP changes required; strict `default-src 'self'` is preserved.

**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.

---

### 2026-03-15 — Checkpoint: lint fixes

**Task:** Checkpoint — lint clean, tests pass, commit.

**Lint fixes (13 issues resolved):**
- `errorlint`: `internal/vault/vault_test.go` — replaced `err != ErrSealed` with `errors.Is(err, ErrSealed)`.
- `gofmt`: `internal/config/config.go`, `internal/config/config_test.go`, `internal/middleware/middleware_test.go` — reformatted with `goimports`.
- `govet/fieldalignment`: `internal/vault/vault.go`, `internal/ui/csrf.go`, `internal/audit/detail_test.go`, `internal/middleware/middleware_test.go` — reordered struct fields for optimal alignment.
- `unused`: `internal/ui/csrf.go` — removed unused `newCSRFManager` function (superseded by `newCSRFManagerFromVault`).
- `revive/early-return`: `cmd/mciassrv/main.go` — inverted condition to eliminate else-after-return.

**Verification:** `golangci-lint run ./...` → 0 issues; `go test ./...` → all packages pass.

---

### 2026-03-15 — Documentation: ARCHITECTURE.md update + POLICY.md

**Task:** Ensure ARCHITECTURE.md is accurate; add POLICY.md describing the policy engine.

**ARCHITECTURE.md fix:**
- Corrected `Rule.ID` comment: built-in default rules use negative IDs (-1 … -7), not 0 (§20 Core Types code block).

**New file: POLICY.md**
- Operator reference guide for the ABAC policy engine.
- Covers: evaluation model (deny-wins, default-deny, stable priority sort), rule matching semantics, priority conventions, all built-in default rules (IDs -1 … -7) with conditions, full action and resource-type catalogue, rule schema (DB columns + RuleBody JSON), rule management via `mciasctl` / REST API / Web UI, account tag conventions, cache reload, six worked examples (named service delegation, machine-tag gating, blanket role, time-scoped access, per-account subject rule, incident-response deny), security recommendations, and audit events.

---

### 2026-03-15 — Service account token delegation and download

**Problem:** Only admins could issue tokens for service accounts, and the only way to retrieve the token was a flash message (copy-paste). There was no delegation mechanism for non-admin users.

**Solution:** Added token-issue delegation and a one-time secure file download flow.

**DB (`internal/db/`):**
- Migration `000008`: new `service_account_delegates` table — tracks which human accounts may issue tokens for a given system account
- `GrantTokenIssueAccess`, `RevokeTokenIssueAccess`, `ListTokenIssueDelegates`, `HasTokenIssueAccess`, `ListDelegatedServiceAccounts` functions

**Model (`internal/model/`):**
- New `ServiceAccountDelegate` type
- New audit event constants: `EventTokenDelegateGranted`, `EventTokenDelegateRevoked`

**UI (`internal/ui/`):**
- `handleIssueSystemToken`: now allows admins and delegates (not just admins); after issuance stores token in a short-lived (5 min) single-use download nonce; returns download link in the HTMX fragment
- `handleDownloadToken`: serves the token as `Content-Disposition: attachment` via the one-time nonce; nonce deleted on first use to prevent replay
- `handleGrantTokenDelegate` / `handleRevokeTokenDelegate`: admin-only endpoints to manage delegate access for a system account
- `handleServiceAccountsPage`: new `/service-accounts` page for non-admin delegates to see their assigned service accounts and issue tokens
- New `tokenDownloads sync.Map` in `UIServer` with background cleanup goroutine

**Routes:**
- `POST /accounts/{id}/token` — changed from admin-only to authed+CSRF, authorization checked in handler
- `GET /token/download/{nonce}` — new, authed
- `POST /accounts/{id}/token/delegates` — new, admin-only
- `DELETE /accounts/{id}/token/delegates/{grantee}` — new, admin-only
- `GET /service-accounts` — new, authed (delegates' token management page)

**Templates:**
- `token_list.html`: shows download link after issuance
- `token_delegates.html`: new fragment for admin delegate management
- `account_detail.html`: added "Token Issue Access" section for system accounts
- `service_accounts.html`: new page listing delegated service accounts with issue button
- `base.html`: non-admin nav now shows "Service Accounts" link

### 2026-03-14 — Vault seal/unseal lifecycle

**Problem:** `mciassrv` required the master passphrase at startup and refused to start without it. Operators needed a way to start the server in a degraded state and provide the passphrase at runtime, plus the ability to re-seal at runtime.

**Solution:** Implemented a `Vault` abstraction that manages key material lifecycle with seal/unseal state transitions.

**New package: `internal/vault/`**
- `vault.go`: Thread-safe `Vault` struct with `sync.RWMutex`-protected state. Methods: `IsSealed()`, `Unseal()`, `Seal()`, `MasterKey()`, `PrivKey()`, `PubKey()`. `Seal()` zeroes all key material before nilling.
- `derive.go`: Extracted `DeriveFromPassphrase()` and `DecryptSigningKey()` from `cmd/mciassrv/main.go` for reuse by unseal handlers.
- `vault_test.go`: Tests for state transitions, key zeroing, concurrent access.

**REST API (`internal/server/`):**
- `POST /v1/vault/unseal`: Accept passphrase, derive key, unseal (rate-limited 3/s burst 5)
- `POST /v1/vault/seal`: Admin-only, seals vault and zeroes key material
- `GET /v1/vault/status`: Returns `{"sealed": bool}`
- `GET /v1/health`: Now returns `{"status":"sealed"}` when sealed
- All other `/v1/*` endpoints return 503 `vault_sealed` when sealed

**Web UI (`internal/ui/`):**
- New unseal page at `/unseal` with passphrase form (same styling as login)
- All UI routes redirect to `/unseal` when sealed (except `/static/`)
- CSRF manager now derives key lazily from vault

**gRPC (`internal/grpcserver/`):**
- New `sealedInterceptor` first in interceptor chain — returns `codes.Unavailable` for all RPCs except Health
- Health RPC returns `status: "sealed"` when sealed

**Startup (`cmd/mciassrv/main.go`):**
- When passphrase env var is empty/unset (and not first run): starts in sealed state
- When passphrase is available: backward-compatible unsealed startup
- First run still requires passphrase to generate signing key

**Refactoring:**
- All three servers (REST, UI, gRPC) share a single `*vault.Vault` by pointer
- Replaced static `privKey`, `pubKey`, `masterKey` fields with vault accessor calls
- `middleware.RequireAuth` now reads pubkey from vault at request time
- New `middleware.RequireUnsealed` middleware wired before request logger

**Audit events:** Added `vault_sealed` and `vault_unsealed` event types.

**OpenAPI:** Updated `openapi.yaml` with vault endpoints and sealed health response.

**Files changed:** 19 files (3 new packages, 3 new handlers, 1 new template, extensive refactoring across all server packages and tests).

### 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/**
- `handlers_accounts.go`: added `handleSetPGCreds` — validates form fields,
  encrypts password via `crypto.SealAESGCM` with fresh nonce, calls
  `db.WritePGCredentials`, writes `EventPGCredUpdated` audit entry, re-reads and
  renders `pgcreds_form` fragment; password never echoed in response
- `handlers_accounts.go`: updated `handleAccountDetail` to load PG credentials
  for system accounts (non-fatal on `ErrNotFound`) and account tags for all
  accounts
- `handlers_policy.go`: fixed `handleSetAccountTags` to render with
  `AccountDetailData` (removed `AccountTagsData`); field ordering fixed for
  `fieldalignment` linter
- `ui.go`: added `PGCred *model.PGCredential` and `Tags []string` to
  `AccountDetailData`; added `pgcreds_form.html` and `tags_editor.html` to
  shared template set; registered `PUT /accounts/{id}/pgcreds` and
  `PUT /accounts/{id}/tags` routes; removed unused `AccountTagsData` struct;
  field alignment fixed on `PolicyRuleView`, `PoliciesData`, `AccountDetailData`
- `ui_test.go`: added 5 new PG credential tests:
  `TestSetPGCredsRejectsHumanAccount`, `TestSetPGCredsStoresAndDisplaysMetadata`,
  `TestSetPGCredsPasswordNotEchoed`, `TestSetPGCredsRequiresPassword`,
  `TestAccountDetailShowsPGCredsSection`

**web/templates/**
- `fragments/pgcreds_form.html` (new): displays current credential metadata
  (host:port, database, username, updated-at — no password); includes HTMX
  `hx-put` form for set/replace; system accounts only
- `fragments/tags_editor.html` (new): newline-separated tag textarea with HTMX
  `hx-put` for atomic replacement; uses `.Account.UUID` for URL
- `fragments/policy_form.html`: rewritten to use structured fields matching
  `handleCreatePolicyRule` parser: `description`, `priority`, `effect` (select),
  `roles`/`account_types`/`actions` (multi-select), `resource_type`, `subject_uuid`,
  `service_names`, `required_tags`, `owner_matches_subject` (checkbox)
- `policies.html` (new): policies management page with create-form toggle and
  rules table (`id="policies-tbody"`)
- `fragments/policy_row.html` (new): HTMX table row with enable/disable toggle
  (`hx-patch`) and delete button (`hx-delete`)
- `account_detail.html`: added Tags card (all accounts) and Postgres Credentials
  card (system accounts only)
- `base.html`: added Policies nav link

**internal/server/server.go**
- Removed ~220 lines of duplicate tag and policy handler code that had been
  inadvertently added; all real implementations live in `handlers_policy.go`

**internal/policy/engine_wrapper.go**
- Fixed corrupted source file (invisible character preventing `fmt` usage from
  being recognized); rewrote to use `errors.New` for the denial error

**internal/db/policy_test.go**
- Fixed `CreateAccount` call using string literal `"human"` → `model.AccountTypeHuman`

**cmd/mciasctl/main.go**
- Added `//nolint:gosec` to three `int(os.Stdin.Fd())` conversions (safe:
  uintptr == int on all target platforms; `term.ReadPassword` requires `int`)

**Linter fixes (all packages)**
- gofmt/goimports applied to `internal/db/policy_test.go`,
  `internal/policy/defaults.go`, `internal/policy/engine_test.go`, `internal/ui/ui.go`
- fieldalignment fixed on `model.PolicyRuleRecord`, `policy.Engine`,
  `policy.Rule`, `policy.RuleBody`, `ui.PolicyRuleView`

All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.

### 2026-03-11 — v1.0.0 release

- `Makefile`: `make docker` now tags image as both `mcias:$(VERSION)` and
  `mcias:latest` in a single build invocation
- Tagged `v1.0.0` — first stable release

---
- [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
- [x] Phase 1: Foundational packages (model, config, crypto, db)
- [x] Phase 2: Auth core (auth, token, middleware)
- [x] Phase 3: HTTP server (server, mciassrv binary)
- [x] Phase 4: Admin CLI (mciasctl binary)
- [x] Phase 5: E2E tests, security hardening, commit
- [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)
- [ ] 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)

**internal/policy/** (new package)
- `policy.go` — types: `Action`, `ResourceType`, `Effect`, `Resource`,
  `PolicyInput`, `Rule`, `RuleBody`; 22 Action constants covering all API
  operations
- `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` — 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*,
  SystemOwn*, DenyWins, ServiceNameGating, MachineTagGating,
  OwnerMatchesSubject, PriorityOrder, MultipleRequiredTags, AccountTypeGating

**internal/db/**
- `migrate.go`: migration id=4 — `account_tags` (account_id+tag PK, FK cascade)
  and `policy_rules` (id, priority, description, rule_json, enabled,
  created_by, timestamps) tables
- `tags.go` (new): `GetAccountTags`, `AddAccountTag`, `RemoveAccountTag`,
  `SetAccountTags` (atomic DELETE+INSERT transaction); sorted alphabetically
- `policy.go` (new): `CreatePolicyRule`, `GetPolicyRule`, `ListPolicyRules`,
  `UpdatePolicyRule`, `SetPolicyRuleEnabled`, `DeletePolicyRule`
- `tags_test.go`, `policy_test.go` (new): comprehensive DB-layer tests

**internal/model/**
- `PolicyRuleRecord` struct added
- New audit event constants: `EventTagAdded`, `EventTagRemoved`,
  `EventPolicyRuleCreated`, `EventPolicyRuleUpdated`, `EventPolicyRuleDeleted`,
  `EventPolicyDeny`

**internal/middleware/**
- `RequirePolicy` middleware: assembles `PolicyInput` from JWT claims +
  `AccountTypeLookup` closure (DB-backed, avoids JWT schema change) +
  `ResourceBuilder` closure; calls `engine.Evaluate`; logs deny via
  `PolicyDenyLogger`

**internal/server/**
- New REST endpoints (all require admin):
  - `GET/PUT /v1/accounts/{id}/tags`
  - `GET/POST /v1/policy/rules`
  - `GET/PATCH/DELETE /v1/policy/rules/{id}`
- `handlers_policy.go`: `handleGetTags`, `handleSetTags`, `handleListPolicyRules`,
  `handleCreatePolicyRule`, `handleGetPolicyRule`, `handleUpdatePolicyRule`,
  `handleDeletePolicyRule`, `policyRuleToResponse`, `loadPolicyRule`

**internal/ui/**
- `handlers_policy.go` (new): `handlePoliciesPage`, `handleCreatePolicyRule`,
  `handleTogglePolicyRule`, `handleDeletePolicyRule`, `handleSetAccountTags`
- `ui.go`: registered 5 policy UI routes; added `PolicyRuleView`, `PoliciesData`,
  `AccountTagsData` view types; added new fragment templates to shared set

**web/templates/**
- `policies.html` (new): policies management page
- `fragments/policy_row.html` (new): HTMX table row with enable/disable toggle
  and delete button
- `fragments/policy_form.html` (new): create form with JSON textarea and action
  reference chips
- `fragments/tags_editor.html` (new): newline-separated tag editor with HTMX
  PUT for atomic replacement
- `account_detail.html`: added Tags card section using tags_editor fragment
- `base.html`: added Policies nav link

**cmd/mciasctl/**
- `policy` subcommands: `list`, `create -description STR -json FILE [-priority N]`,
  `get -id ID`, `update -id ID [-priority N] [-enabled true|false]`,
  `delete -id ID`
- `tag` subcommands: `list -id UUID`, `set -id UUID -tags tag1,tag2,...`

**openapi.yaml**
- New schemas: `TagsResponse`, `RuleBody`, `PolicyRule`
- New paths: `GET/PUT /v1/accounts/{id}/tags`,
  `GET/POST /v1/policy/rules`, `GET/PATCH/DELETE /v1/policy/rules/{id}`
- New tag: `Admin — Policy`

**Design highlights:**
- Deny-wins + default-deny: explicit Deny beats any Allow; no match = Deny
- AccountType resolved via DB lookup (not JWT) to avoid breaking 29 IssueToken
  call sites
- `RequirePolicy` wired alongside `RequireRole("admin")` for belt-and-suspenders
  during migration; defaults reproduce current binary behavior exactly
- `policy.PolicyRecord` type avoids circular import between policy/db/model

All tests pass; `go test ./...` clean; `golangci-lint run ./...` clean.

### 2026-03-11 — Fix test failures and lockout logic

- `internal/db/accounts.go` (IsLockedOut): corrected window-expiry check from
  `LockoutWindow+LockoutDuration` to `LockoutWindow`; stale failures outside
  the rolling window now correctly return not-locked regardless of count
- `internal/grpcserver/grpcserver_test.go` (TestUpdateAccount,
  TestSetAndGetRoles): updated test passwords from 9-char "pass12345" to
  13-char "pass123456789" to satisfy the 12-character minimum (F-13)
- Reformatted import blocks in both files with goimports to resolve gci lint
  warnings

All 5 packages pass `go test ./...`; `golangci-lint run ./...` clean.

### 2026-03-11 — Phase 9: Client libraries (DESIGNED, NOT IMPLEMENTED)

**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 for Go integration tests
- `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use

---
**Makefile**
- Targets: build, test, lint, generate, man, install, clean, dist, docker
- build: compiles all four binaries to bin/ with CGO_ENABLED=1 and
  -trimpath -ldflags="-s -w"
- dist: cross-compiled tarballs for linux/amd64 and linux/arm64
- docker: builds image tagged mcias:$(git describe --tags --always)
- VERSION derived from git describe --tags --always
**Dockerfile** (multi-stage)
- Build stage: golang:1.26-bookworm with CGO_ENABLED=1
- Runtime stage: debian:bookworm-slim with only ca-certificates and libc6;
  no Go toolchain, no source, no build cache in final image
- Non-root user mcias (uid/gid 10001)
- EXPOSE 8443 (REST/TLS) and EXPOSE 9443 (gRPC/TLS)
- VOLUME /data for the SQLite database mount point
- ENTRYPOINT ["mciassrv"] CMD ["-config", "/etc/mcias/mcias.conf"]
**dist/ artifacts**
- dist/mcias.service: hardened systemd unit with ProtectSystem=strict,
  ProtectHome=true, PrivateTmp=true, NoNewPrivileges=true,
  CapabilityBoundingSet= (no capabilities), ReadWritePaths=/var/lib/mcias,
  EnvironmentFile=/etc/mcias/env, Restart=on-failure, LimitNOFILE=65536
- dist/mcias.env.example: passphrase env file template
- dist/mcias.conf.example: fully-commented production TOML config reference
- dist/mcias-dev.conf.example: local dev config (127.0.0.1, short expiry)
- dist/mcias.conf.docker.example: container config template
- dist/install.sh: idempotent POSIX sh installer; creates user/group,
  installs binaries, creates /etc/mcias and /var/lib/mcias, installs
  systemd unit and man pages; existing configs not overwritten (placed .new)
**man/ pages** (mdoc format)
- man/man1/mciassrv.1: synopsis, options, config, REST API, signals, files
- man/man1/mciasctl.1: all subcommands, env vars, examples
- man/man1/mciasdb.1: trust model warnings, all subcommands, examples
- man/man1/mciasgrpcctl.1: gRPC subcommands, grpcurl examples
**Documentation**
- README.md: replaced dev-workflow notes with user-facing docs; quick-start,
  first-run setup, build instructions, CLI references, Docker deployment,
  man page index, security notes
- .gitignore: added /bin/, dist/mcias_*.tar.gz, man/man1/*.gz
### 2026-03-11 — Phase 7: gRPC dual-stack

**proto/mcias/v1/**
- `common.proto` — shared types: Account, TokenInfo, PGCreds, Error
- `admin.proto` — AdminService: Health (public), GetPublicKey (public)
- `auth.proto` — AuthService: Login (public), Logout, RenewToken,
  EnrollTOTP, ConfirmTOTP, RemoveTOTP (admin)
- `token.proto` — TokenService: ValidateToken (public),
  IssueServiceToken (admin), RevokeToken (admin)
- `account.proto` — AccountService (CRUD + roles, all admin) +
  CredentialService (GetPGCreds, SetPGCreds, all admin)
- `proto/generate.go` — go:generate directive for protoc regeneration
- Generated Go stubs in `gen/mcias/v1/` via protoc + protoc-gen-go-grpc

**internal/grpcserver**
- `grpcserver.go` — Server struct, interceptor chain
  (loggingInterceptor → authInterceptor → rateLimitInterceptor),
  GRPCServer() / GRPCServerWithCreds(creds) / buildServer() helpers,
  per-IP token-bucket rate limiter (same parameters as REST: 10 req/s,
  burst 10), extractBearerFromMD, requireAdmin
- `admin.go` — Health, GetPublicKey implementations
- `auth.go` — Login (with dummy-Argon2 timing guard), Logout, RenewToken,
  EnrollTOTP, ConfirmTOTP, RemoveTOTP
- `tokenservice.go` — ValidateToken (returns valid=false on error, never
  an RPC error), IssueServiceToken, RevokeToken
- `accountservice.go` — ListAccounts, CreateAccount, GetAccount,
  UpdateAccount, DeleteAccount, GetRoles, SetRoles
- `credentialservice.go` — GetPGCreds (AES-GCM decrypt), SetPGCreds
  (AES-GCM encrypt)

**Security invariants (same as REST server):**
- Authorization metadata value never logged by any interceptor
- Credential fields (PasswordHash, TOTPSecret*, PGPassword) absent from
  all proto response messages by proto design + grpcserver enforcement
- JWT validation: alg-first, then signature, then revocation table lookup
- Public RPCs bypass auth: Health, GetPublicKey, ValidateToken, Login
- Admin-only RPCs checked in-handler via requireAdmin(ctx)
- Dummy Argon2 in Login for unknown users prevents timing enumeration

**internal/config additions**
- `GRPCAddr string` field in ServerConfig (optional; omit to disable gRPC)

**cmd/mciassrv updates**
- Dual-stack: starts both HTTPS (REST) and gRPC/TLS listeners when
  grpc_addr is configured in [server] section
- gRPC listener uses same TLS cert/key as REST; credentials passed at
  server-construction time via GRPCServerWithCreds
- Graceful shutdown drains both listeners within 15s window

**cmd/mciasgrpcctl**
- New companion CLI for gRPC management
- Global flags: -server (host:port), -token (or MCIAS_TOKEN), -cacert
- Commands: health, pubkey, account (list/create/get/update/delete),
  role (list/set), token (validate/issue/revoke),
  pgcreds (get/set)
- Connects with TLS; custom CA cert support for self-signed certs

**Tests**
- `internal/grpcserver/grpcserver_test.go`: 20 tests using bufconn
  (in-process, no network sockets); covers:
  - Health and GetPublicKey (public RPCs, no auth)
  - Auth interceptor: no token, invalid token, revoked token all → 401
  - Non-admin calling admin RPC → 403
  - Login: success, wrong password, unknown user
  - Logout and RenewToken
  - ValidateToken: good token → valid=true; garbage → valid=false (no error)
  - IssueServiceToken requires admin
  - ListAccounts: non-admin → 403, admin → OK
  - CreateAccount, GetAccount, UpdateAccount, SetRoles, GetRoles lifecycle
  - SetPGCreds + GetPGCreds with AES-GCM round-trip verification
  - PGCreds requires admin
  - Credential fields absent from account responses (structural enforcement)

**Dependencies added**
- `google.golang.org/grpc v1.68.0`
- `google.golang.org/protobuf v1.36.0`
- `google.golang.org/grpc/test/bufconn` (test only, included in grpc module)

Total: 137 tests, all pass, zero race conditions (go test -race ./...)

### 2026-03-11 — Phase 6: mciasdb

**cmd/mciasdb**
- Binary skeleton: config loading, master key derivation (identical to
  mciassrv for key compatibility), DB open + migrate on startup
- `schema verify` / `schema migrate` — reports and applies pending migrations
- `account list/get/create/set-password/set-status/reset-totp` — offline
  account management; set-password prompts interactively (no --password flag)
- `role list/grant/revoke` — direct role management
- `token list/revoke/revoke-all` + `prune tokens` — token maintenance
- `audit tail/query` — audit log inspection with --json output flag
- `pgcreds get/set` — decrypt/encrypt Postgres credentials with master key;
  set prompts interactively; get prints warning before sensitive output
- All write operations emit audit log entries tagged `actor:"mciasdb"`

**internal/db additions**
- `ListTokensForAccount(accountID)` — newest-first token list for an account
- `ListAuditEvents(AuditQueryParams)` — filtered audit query (account, type,
  since, limit)
- `TailAuditEvents(n)` — last n events, returned oldest-first
- `SchemaVersion(db)` / `LatestSchemaVersion` — exported for mciasdb verify

**Dependencies**
- Added `golang.org/x/term v0.29.0` for interactive password prompting
  (no-echo terminal reads); pinned to version compatible with local module cache
- `golang.org/x/crypto` pinned at v0.33.0 (compatible with term@v0.29.0)

**Tests**
- `internal/db/mciasdb_test.go`: 4 tests covering ListTokensForAccount,
  ListAuditEvents filtering, TailAuditEvents ordering, combined filters
- `cmd/mciasdb/mciasdb_test.go`: 20 tests covering all subcommands via
  in-memory SQLite and stdout capture

Total: 117 tests, all pass, zero race conditions (go test -race ./...)

### 2026-03-11 — Initial Full Implementation

#### Phase 0: Bootstrap

- Wrote ARCHITECTURE.md (security model, crypto choices, DB schema, API design)
- Wrote PROJECT_PLAN.md (5 phases, 12 steps with acceptance criteria)
- Created go.mod with dependencies (golang-jwt/jwt/v5, uuid, go-toml/v2,
  golang.org/x/crypto, modernc.org/sqlite)
- Created .gitignore

#### Phase 1: Foundational Packages

**internal/model**
- Account (human/system), Role, TokenRecord, SystemToken, PGCredential,
  AuditEvent structs
- All credential fields tagged `json:"-"` — never serialised to responses
- Audit event type constants

**internal/config**
- TOML config parsing with validation
- Enforces OWASP 2023 Argon2id minimums (time≥2, memory≥64MiB)
- Requires exactly one of passphrase_env or keyfile for master key
- NewTestConfig() for test use

**internal/crypto**
- Ed25519 key generation, PEM marshal/parse
- AES-256-GCM seal/open with random nonces
- Argon2id KDF (DeriveKey) with OWASP-exceeding parameters
- NewSalt(), RandomBytes()

**internal/db**
- SQLite with WAL mode, FK enforcement, busy timeout
- Idempotent migrations (schema_version table)
- Migration 1: full schema (server_config, accounts, account_roles,
  token_revocation, system_tokens, pg_credentials, audit_log)
- Migration 2: master_key_salt column in server_config
- Full CRUD: accounts, roles, tokens, PG credentials, audit log

#### Phase 2: Auth Core

**internal/auth**
- Argon2id password hashing in PHC format
- Constant-time password verification (crypto/subtle)
- TOTP generation and validation (RFC 6238 ±1 window, constant-time)
- HOTP per RFC 4226

**internal/token**
- Ed25519/EdDSA JWT issuance with UUID JTI
- alg header validated BEFORE signature verification (alg confusion defence)
- alg:none explicitly rejected
- ErrWrongAlgorithm, ErrExpiredToken, ErrInvalidSignature, ErrMissingClaim

**internal/middleware**
- RequestLogger — never logs Authorization header
- RequireAuth — validates JWT, checks revocation table
- RequireRole — checks claims for required role
- RateLimit — per-IP token bucket

#### Phase 3: HTTP Server

**internal/server**
- Full REST API wired to middleware
- Handlers: health, public-key, login (dummy Argon2 on unknown user for
  timing uniformity), logout, renew, token validate/issue/revoke,
  account CRUD, roles, TOTP enrol/confirm/remove, PG credentials
- Strict JSON decoding (DisallowUnknownFields)
- Credential fields never appear in any response

**cmd/mciassrv**
- Config loading, master key derivation (passphrase via Argon2id KDF or
  key file), signing key load/generate (AES-256-GCM encrypted in DB),
  HTTPS listener with graceful shutdown
- TLS 1.2+ minimum, X25519+P256 curves
- 30s read/write timeouts, 5s header timeout

#### Phase 4: Admin CLI

**cmd/mciasctl**
- Subcommands: account (list/create/get/update/delete), role (list/set),
  token (issue/revoke), pgcreds (get/set)
- Auth via -token flag or MCIAS_TOKEN env var
- Custom CA cert support for self-signed TLS

#### Phase 5: Tests and Hardening

**Test coverage:**
- internal/model: 5 tests
- internal/config: 8 tests
- internal/crypto: 12 tests
- internal/db: 13 tests
- internal/auth: 13 tests
- internal/token: 9 tests (including alg confusion and alg:none attacks)
- internal/middleware: 12 tests
- internal/server: 14 tests
- test/e2e: 11 tests

Total: 97 tests — all pass, zero race conditions (go test -race ./...)

**Security tests (adversarial):**
- JWT alg:HS256 confusion attack → 401
- JWT alg:none attack → 401
- Revoked token reuse → 401
- Non-admin calling admin endpoint → 403
- Wrong password → 401 (same response as unknown user)
- Credential material absent from all API responses

**Security hardening:**
- go vet ./... — zero issues
- gofmt applied to all files
- golangci-lint v2 config updated (note: v2.6.2 built with go1.25.3
  cannot analyse go1.26 source; go vet used as primary linter for now)

---

## Architecture Decisions

- **SQLite driver**: `modernc.org/sqlite` (pure Go, no CGo)
- **JWT**: `github.com/golang-jwt/jwt/v5`; alg validated manually before
  library dispatch to defeat algorithm confusion
- **No ORM**: `database/sql` with parameterized statements only
- **Master key salt**: stored in server_config table for stable KDF across
  restarts; generated on first run
- **Signing key**: stored AES-256-GCM encrypted in server_config; generated
  on first run, decrypted each startup using master key
- **Timing uniformity**: unknown user login runs dummy Argon2 to match
  timing of wrong-password path; all credential comparisons use
  `crypto/subtle.ConstantTimeCompare`
