- 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>
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: addedRoleGuest,RoleViewer,RoleEditor, andRoleCommenterconstants; updatedallowedRolesmap andValidateRoleerror message to include the full set of recognised roles.internal/ui/: updatedknownRolesto 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
requireAdminRolemiddleware that checksclaims.HasRole("admin")and returns 403 if absent - Updated
adminandadminGetmiddleware wrappers to includerequireAdminRolein the chain — previously onlyrequireCookieAuthwas 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 userequireAuth → RequireRole("admin")— correct - gRPC (all service files): every admin RPC calls
requireAdmin(ctx)as first statement — correct - UI: was vulnerable, now fixed with
requireAdminRolemiddleware
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/profilepage 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
ProfileDataview model. - Registered
GET /profileandPUT /profile/passwordroutes (cookie auth + CSRF; no admin role required). - Added
password_change_form.htmlto shared template list; addedprofilepage 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_passwordfields. - 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)whenschema_migrationsis 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 equalsLatestSchemaVersion, 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 Nsubcommand backed bydb.ForceSchemaVersion.
cmd/mciasdb/main.go
schemacommands now open the database viaopenDBRaw(no auto-migration) so the tool stays usable when the database is in a dirty migration state.openDBrefactored to callopenDBRawthendb.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; requirescurrent_passwordfor 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; renderspassword_reset_resultfragment on success.
internal/ui/ui.go
PUT /accounts/{id}/passwordroute registered with admin+CSRF middleware.templates/fragments/password_reset_form.htmladded to shared template list.
web/templates/fragments/password_reset_form.html (new)
- HTMX form fragment for the admin password reset UI.
password_reset_resulttemplate 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:-passwordflag removed; password always prompted.account create:-passwordflag 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
PolicyRecordfields:*time.Timepointer 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 NULLALTER TABLE policy_rules ADD COLUMN expires_at TEXT DEFAULT NULL
internal/db/migrate.go
LatestSchemaVersionbumped from 5 to 6
internal/model/model.go
- Added
NotBefore *time.TimeandExpiresAt *time.TimetoPolicyRuleRecord
internal/db/policy.go
policyRuleColsupdated withnot_before, expires_atCreatePolicyRule: new paramsnotBefore, expiresAt *time.TimeUpdatePolicyRule: new paramsnotBefore, expiresAt **time.Time(double-pointer for three-state semantics: nil=no change, non-nil→nil=clear, non-nil→value=set)finishPolicyRuleScan: extended to populateNotBefore/ExpiresAtvianullableTime()- Added
formatNullableTime(*time.Time) *stringhelper
internal/policy/engine_wrapper.go
- Added
NotBefore *time.TimeandExpiresAt *time.TimetoPolicyRecord SetRules: filters out rules wherenot_before > now()orexpires_at <= now()after the existingEnabledcheck
internal/server/handlers_policy.go
policyRuleResponse: addednot_beforeandexpires_at(RFC3339, omitempty)createPolicyRuleRequest: addednot_beforeandexpires_atupdatePolicyRuleRequest: addednot_before,expires_at,clear_not_before,clear_expires_athandleCreatePolicyRule: parses/validates RFC3339 times; rejectsexpires_at <= not_beforehandleUpdatePolicyRule: parses times, handles clear booleans via double-pointer pattern
internal/ui/
PolicyRuleView: addedNotBefore,ExpiresAt,IsExpired,IsPendingpolicyRuleToView: populates time fields and computes expired/pending statushandleCreatePolicyRule: parsesdatetime-localform inputs for time fields
web/templates/fragments/
policy_form.html: addeddatetime-localinputs for not_before and expires_atpolicy_row.html: shows time info and expired/scheduled badges
cmd/mciasctl/main.go
policyCreate: added-not-beforeand-expires-atflags (RFC3339)policyUpdate: added-not-before,-expires-at,-clear-not-before,-clear-expires-atflags
openapi.yaml
PolicyRuleschema: addednot_beforeandexpires_at(nullable date-time)- Create request: added
not_beforeandexpires_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 newCreatePolicyRule/UpdatePolicyRulesignaturesinternal/policy/engine_test.go: 4 new tests —SkipsExpiredRule,SkipsNotYetActiveRule,IncludesActiveWindowRule,NilTimesAlwaysActive
ARCHITECTURE.md
- Schema: added
not_beforeandexpires_atcolumns topolicy_rulesDDL - 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— addsmaster_key_saltto server_config000003_failed_logins.up.sql—failed_loginstable for brute-force lockout000004_tags_and_policy.up.sql—account_tagsandpolicy_rulestables000005_pgcred_access.up.sql—owner_idcolumn +pg_credential_accesstable- 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
migrationstruct andmigrations []migrationslice - Uses
github.com/golang-migrate/migrate/v4with thedatabase/sqlitedriver (modernc.org/sqlite, pure Go, no CGO) andsource/iofsfor embedded SQL files LatestSchemaVersionchanged fromvartoconst = 5Migrate(db *DB) error: compatibility shim reads legacyschema_versiontable; if version > 0, callsm.Force(legacyVersion)beforem.Up()so existing databases are not re-migrated. Returns nil on ErrNoChange.SchemaVersion(db *DB) (int, error): delegates tom.Version(); returns 0 on ErrNilVersionnewMigrate(*DB): opens a dedicated*sql.DBfor the migrator so thatm.Close()(which closes the underlying connection) does not affect the caller's shared connectionlegacySchemaVersion(*DB): reads old schema_version table; returns 0 if absent (fresh DB or already on golang-migrate only)
internal/db/db.go
- Added
path stringfield toDBstruct for the migrator's dedicated connection Open(":memory:")now translates to a named shared-cache URIfile: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.DBhandle 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
UncredentialedAccountswas empty.
internal/ui/ui.go
- Added
ActorName stringfield toPageData(embedded in every page view struct) - Added
actorName(r *http.Request) stringhelper — resolves username from JWT claims via a DB lookup; returns""if unauthenticated
internal/ui/handlers_{accounts,audit,dashboard,policy}.go
- All full-page
PageDataconstructors now passActorName: 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-actorrule (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 buildUncredentialedAccounts— system accounts that have no credentials yet, passed to the template for the create form; filters fromListAccounts()by type and excludes accounts already in the accessible-credentials sethandleCreatePGCreds:POST /pgcreds— validates selected account UUID (must be a system account), host, port, database, username, password; encrypts password with AES-256-GCM; callsWritePGCredentialsthenSetPGCredentialOwner; writesEventPGCredUpdatedaudit event; redirects toGET /pgcredson success
internal/ui/ui.go
- Registered
POST /pgcredsroute - Added
UncredentialedAccounts []*model.Accountfield toPGCredsData
web/templates/pgcreds.html
- New "New Credentials" card shown when
UncredentialedAccountsis 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
- Service Account dropdown populated from
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 storedowner_idwith the logged-in actor; grantee resolved via UUID lookup (not raw ID); writesEventPGCredAccessGrantedaudit event; re-renderspgcreds_formfragmenthandleRevokePGCredAccess:DELETE /accounts/{id}/pgcreds/access/{grantee}— removes a specific grantee's read access; same ownership check as grant; writesEventPGCredAccessRevokedaudit event; re-renders fragmenthandlePGCredsList: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
pgcredsto the page template map (renderspgcreds.html) - Added
isPGCredOwner(*int64, *model.PGCredential) booltemplate function — nil-safe ownership check used inpgcreds_formto gate owner-only controls - Added
derefInt64(*int64) int64template function (nil-safe dereference)
internal/model/model.go
- Added
ServiceAccountUUID stringfield toPGCredential— 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 fetcha.uuid; updatedscanPGCredWithUsernameto populateServiceAccountUUID
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: addedhandleSetPGCreds— validates form fields, encrypts password viacrypto.SealAESGCMwith fresh nonce, callsdb.WritePGCredentials, writesEventPGCredUpdatedaudit entry, re-reads and renderspgcreds_formfragment; password never echoed in responsehandlers_accounts.go: updatedhandleAccountDetailto load PG credentials for system accounts (non-fatal onErrNotFound) and account tags for all accountshandlers_policy.go: fixedhandleSetAccountTagsto render withAccountDetailData(removedAccountTagsData); field ordering fixed forfieldalignmentlinterui.go: addedPGCred *model.PGCredentialandTags []stringtoAccountDetailData; addedpgcreds_form.htmlandtags_editor.htmlto shared template set; registeredPUT /accounts/{id}/pgcredsandPUT /accounts/{id}/tagsroutes; removed unusedAccountTagsDatastruct; field alignment fixed onPolicyRuleView,PoliciesData,AccountDetailDataui_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 HTMXhx-putform for set/replace; system accounts onlyfragments/tags_editor.html(new): newline-separated tag textarea with HTMXhx-putfor atomic replacement; uses.Account.UUIDfor URLfragments/policy_form.html: rewritten to use structured fields matchinghandleCreatePolicyRuleparser: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
fmtusage from being recognized); rewrote to useerrors.Newfor the denial error
internal/db/policy_test.go
- Fixed
CreateAccountcall using string literal"human"→model.AccountTypeHuman
cmd/mciasctl/main.go
- Added
//nolint:gosecto threeint(os.Stdin.Fd())conversions (safe: uintptr == int on all target platforms;term.ReadPasswordrequiresint)
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 dockernow tags image as bothmcias:$(VERSION)andmcias:latestin 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 operationsengine.go—Evaluate(input, operatorRules) (Effect, *Rule): pure function; merges operator rules with default rules, sorts by priority, deny-wins, then first allow, then default-denydefaults.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 endpointsengine_wrapper.go—Enginestruct withsync.RWMutex;SetRules()decodes DB records;PolicyRecordtype avoids import cycleengine_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) andpolicy_rules(id, priority, description, rule_json, enabled, created_by, timestamps) tablestags.go(new):GetAccountTags,AddAccountTag,RemoveAccountTag,SetAccountTags(atomic DELETE+INSERT transaction); sorted alphabeticallypolicy.go(new):CreatePolicyRule,GetPolicyRule,ListPolicyRules,UpdatePolicyRule,SetPolicyRuleEnabled,DeletePolicyRuletags_test.go,policy_test.go(new): comprehensive DB-layer tests
internal/model/
PolicyRuleRecordstruct added- New audit event constants:
EventTagAdded,EventTagRemoved,EventPolicyRuleCreated,EventPolicyRuleUpdated,EventPolicyRuleDeleted,EventPolicyDeny
internal/middleware/
RequirePolicymiddleware: assemblesPolicyInputfrom JWT claims +AccountTypeLookupclosure (DB-backed, avoids JWT schema change) +ResourceBuilderclosure; callsengine.Evaluate; logs deny viaPolicyDenyLogger
internal/server/
- New REST endpoints (all require admin):
GET/PUT /v1/accounts/{id}/tagsGET/POST /v1/policy/rulesGET/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,handleSetAccountTagsui.go: registered 5 policy UI routes; addedPolicyRuleView,PoliciesData,AccountTagsDataview types; added new fragment templates to shared set
web/templates/
policies.html(new): policies management pagefragments/policy_row.html(new): HTMX table row with enable/disable toggle and delete buttonfragments/policy_form.html(new): create form with JSON textarea and action reference chipsfragments/tags_editor.html(new): newline-separated tag editor with HTMX PUT for atomic replacementaccount_detail.html: added Tags card section using tags_editor fragmentbase.html: added Policies nav link
cmd/mciasctl/
policysubcommands:list,create -description STR -json FILE [-priority N],get -id ID,update -id ID [-priority N] [-enabled true|false],delete -id IDtagsubcommands: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
RequirePolicywired alongsideRequireRole("admin")for belt-and-suspenders during migration; defaults reproduce current binary behavior exactlypolicy.PolicyRecordtype 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 fromLockoutWindow+LockoutDurationtoLockoutWindow; stale failures outside the rolling window now correctly return not-locked regardless of countinternal/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
Serverstruct withsync.RWMutex; used for Go integration testsNewServer(),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, Erroradmin.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, requireAdminadmin.go— Health, GetPublicKey implementationsauth.go— Login (with dummy-Argon2 timing guard), Logout, RenewToken, EnrollTOTP, ConfirmTOTP, RemoveTOTPtokenservice.go— ValidateToken (returns valid=false on error, never an RPC error), IssueServiceToken, RevokeTokenaccountservice.go— ListAccounts, CreateAccount, GetAccount, UpdateAccount, DeleteAccount, GetRoles, SetRolescredentialservice.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 stringfield 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.0google.golang.org/protobuf v1.36.0google.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 migrationsaccount 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 managementtoken list/revoke/revoke-all+prune tokens— token maintenanceaudit tail/query— audit log inspection with --json output flagpgcreds 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 accountListAuditEvents(AuditQueryParams)— filtered audit query (account, type, since, limit)TailAuditEvents(n)— last n events, returned oldest-firstSchemaVersion(db)/LatestSchemaVersion— exported for mciasdb verify
Dependencies
- Added
golang.org/x/term v0.29.0for interactive password prompting (no-echo terminal reads); pinned to version compatible with local module cache golang.org/x/cryptopinned 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 filterscmd/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/sqlwith 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