Files
mcias/PROGRESS.md
Kyle Isom 4e544665d2 Update docs for recent changes
- ARCHITECTURE.md: add gRPC listener, mciasgrpcctl, new roles,
  granular role endpoints, profile page, audit events, policy actions,
  trusted_proxy config, validate package, schema force command
- PROGRESS.md: document role expansion and UI privilege escalation fix
- PROJECT_PLAN.md: align mciasctl subcommands with implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:07:41 -07:00

40 KiB

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.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