Files
mcias/PROGRESS.md
Kyle Isom 37afc68287 Add TOTP enrollment to web UI
- Profile page TOTP section with enrollment flow:
  password re-auth → QR code + manual entry → 6-digit confirm
- Server-side QR code generation (go-qrcode, data: URI PNG)
- Admin "Remove TOTP" button on account detail page
- Enrollment nonces: sync.Map with 5-minute TTL, single-use
- Template fragments: totp_section.html, totp_enroll_qr.html
- Handler: handlers_totp.go (enroll start, confirm, admin remove)

Security: Password re-auth before secret generation (SEC-01).
Lockout checked before Argon2. CSRF on all endpoints. Single-use
enrollment nonces with expiry. TOTP counter replay prevention
(CRIT-01). Self-removal not permitted (admin only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:39:45 -07:00

1104 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MCIAS Progress
Source of truth for current development state.
---
Phases 014 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 09 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 1013
**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`