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

53 KiB
Raw Blame History

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.sqlfailed_logins table for brute-force lockout
  • 000004_tags_and_policy.up.sqlaccount_tags and policy_rules tables
  • 000005_pgcred_access.up.sqlowner_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

  • Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
  • Phase 1: Foundational packages (model, config, crypto, db)
  • Phase 2: Auth core (auth, token, middleware)
  • Phase 3: HTTP server (server, mciassrv binary)
  • Phase 4: Admin CLI (mciasctl binary)
  • Phase 5: E2E tests, security hardening, commit
  • Phase 6: mciasdb — direct SQLite maintenance tool
  • Phase 7: gRPC interface (alternate transport; dual-stack with REST)
  • 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
  • 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.goEvaluate(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.goEngine 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