# MCIAS Progress Source of truth for current development state. --- All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` 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" `
` 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`