- Replace stale "service" role option with correct set: admin, user, guest, viewer, editor, commenter (matches model.go) - Add Form/JSON tab toggle to policy create form - JSON tab accepts raw RuleBody JSON with description/priority - Handler detects rule_json field and parses/validates it directly, falling back to field-by-field form mode otherwise
51 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 (pre-existing warnings only).
2026-03-16 — Documentation sync (ARCHITECTURE.md, PROJECT_PLAN.md)
Task: Full documentation audit to sync ARCHITECTURE.md and PROJECT_PLAN.md with v1.0.0 implementation.
ARCHITECTURE.md changes:
- §8 Postgres Credential Endpoints: added missing
GET /v1/pgcreds - §12 Directory/Package Structure: added
internal/audit/,internal/vault/,web/embed.go; addedclients/,test/,dist/,man/top-level dirs; removed stale "(Phase N)" labels - §17 Proto Package Layout: added
policy.proto - §17 Service Definitions: added
PolicyServicerow - §18 Makefile Targets: added
docker-clean; correcteddockerandcleandescriptions
PROJECT_PLAN.md changes:
- All phases 0–9 marked
[COMPLETE] - Added status summary at top (v1.0.0, 2026-03-15)
- Phase 4.1: added
mciasctl pgcreds listsubcommand (implemented, was missing from plan) - Phase 7.1: added
policy.prototo proto file list - Phase 8.5: added
docker-cleantarget; correcteddockerandcleantarget descriptions - Added Phase 10: Web UI (HTMX)
- Added Phase 11: Authorization Policy Engine
- Added Phase 12: Vault Seal/Unseal Lifecycle
- Added Phase 13: Token Delegation and pgcred Access Grants
- Updated implementation order to include phases 10–13
No code changes. Documentation only.
2026-03-15 — Makefile: docker image cleanup
Task: Ensure make clean removes Docker build images; add dedicated docker-clean target.
Changes:
cleantarget now runsdocker rmi mcias:$(VERSION) mcias:latest(errors suppressed so clean works without Docker).- New
docker-cleantarget removes the versioned andlatesttags and prunes dangling images with the mcias label. - Header comment and
helptarget updated to documentdocker-clean.
Verification: go build ./..., go test ./..., golangci-lint run ./... all clean.
2026-03-15 — Fix Swagger server URLs
Task: Update Swagger servers section to use correct auth server URLs.
Changes:
openapi.yamlandweb/static/openapi.yaml: replacedhttps://auth.example.com:8443withhttps://mcias.metacircular.net:8443(Production) andhttps://localhost:8443(Local test server).
Verification: go build ./..., go test ./..., golangci-lint run ./... all clean.
2026-03-15 — Fix /docs Swagger UI (bundle assets locally)
Problem: /docs was broken because docs.html loaded swagger-ui-bundle.js and swagger-ui.css from unpkg.com CDN, which is blocked by the server's Content-Security-Policy: default-src 'self' header.
Solution:
- Downloaded
swagger-ui-dist@5.32.0via npm and copiedswagger-ui-bundle.jsandswagger-ui.cssintoweb/static/(embedded at build time). - Updated
docs.htmlto reference/static/swagger-ui-bundle.jsand/static/swagger-ui.css. - Added
GET /static/swagger-ui-bundle.jsandGET /static/swagger-ui.csshandlers inserver.goserving the embedded bytes with correctContent-Typeheaders. - No CSP changes required; strict
default-src 'self'is preserved.
Verification: go build ./..., go test ./..., golangci-lint run ./... all clean.
2026-03-15 — Checkpoint: lint fixes
Task: Checkpoint — lint clean, tests pass, commit.
Lint fixes (13 issues resolved):
errorlint:internal/vault/vault_test.go— replacederr != ErrSealedwitherrors.Is(err, ErrSealed).gofmt:internal/config/config.go,internal/config/config_test.go,internal/middleware/middleware_test.go— reformatted withgoimports.govet/fieldalignment:internal/vault/vault.go,internal/ui/csrf.go,internal/audit/detail_test.go,internal/middleware/middleware_test.go— reordered struct fields for optimal alignment.unused:internal/ui/csrf.go— removed unusednewCSRFManagerfunction (superseded bynewCSRFManagerFromVault).revive/early-return:cmd/mciassrv/main.go— inverted condition to eliminate else-after-return.
Verification: golangci-lint run ./... → 0 issues; go test ./... → all packages pass.
2026-03-15 — Documentation: ARCHITECTURE.md update + POLICY.md
Task: Ensure ARCHITECTURE.md is accurate; add POLICY.md describing the policy engine.
ARCHITECTURE.md fix:
- Corrected
Rule.IDcomment: built-in default rules use negative IDs (-1 … -7), not 0 (§20 Core Types code block).
New file: POLICY.md
- Operator reference guide for the ABAC policy engine.
- Covers: evaluation model (deny-wins, default-deny, stable priority sort), rule matching semantics, priority conventions, all built-in default rules (IDs -1 … -7) with conditions, full action and resource-type catalogue, rule schema (DB columns + RuleBody JSON), rule management via
mciasctl/ REST API / Web UI, account tag conventions, cache reload, six worked examples (named service delegation, machine-tag gating, blanket role, time-scoped access, per-account subject rule, incident-response deny), security recommendations, and audit events.
2026-03-15 — Service account token delegation and download
Problem: Only admins could issue tokens for service accounts, and the only way to retrieve the token was a flash message (copy-paste). There was no delegation mechanism for non-admin users.
Solution: Added token-issue delegation and a one-time secure file download flow.
DB (internal/db/):
- Migration
000008: newservice_account_delegatestable — tracks which human accounts may issue tokens for a given system account GrantTokenIssueAccess,RevokeTokenIssueAccess,ListTokenIssueDelegates,HasTokenIssueAccess,ListDelegatedServiceAccountsfunctions
Model (internal/model/):
- New
ServiceAccountDelegatetype - New audit event constants:
EventTokenDelegateGranted,EventTokenDelegateRevoked
UI (internal/ui/):
handleIssueSystemToken: now allows admins and delegates (not just admins); after issuance stores token in a short-lived (5 min) single-use download nonce; returns download link in the HTMX fragmenthandleDownloadToken: serves the token asContent-Disposition: attachmentvia the one-time nonce; nonce deleted on first use to prevent replayhandleGrantTokenDelegate/handleRevokeTokenDelegate: admin-only endpoints to manage delegate access for a system accounthandleServiceAccountsPage: new/service-accountspage for non-admin delegates to see their assigned service accounts and issue tokens- New
tokenDownloads sync.MapinUIServerwith background cleanup goroutine
Routes:
POST /accounts/{id}/token— changed from admin-only to authed+CSRF, authorization checked in handlerGET /token/download/{nonce}— new, authedPOST /accounts/{id}/token/delegates— new, admin-onlyDELETE /accounts/{id}/token/delegates/{grantee}— new, admin-onlyGET /service-accounts— new, authed (delegates' token management page)
Templates:
token_list.html: shows download link after issuancetoken_delegates.html: new fragment for admin delegate managementaccount_detail.html: added "Token Issue Access" section for system accountsservice_accounts.html: new page listing delegated service accounts with issue buttonbase.html: non-admin nav now shows "Service Accounts" link
2026-03-14 — Vault seal/unseal lifecycle
Problem: mciassrv required the master passphrase at startup and refused to start without it. Operators needed a way to start the server in a degraded state and provide the passphrase at runtime, plus the ability to re-seal at runtime.
Solution: Implemented a Vault abstraction that manages key material lifecycle with seal/unseal state transitions.
New package: internal/vault/
vault.go: Thread-safeVaultstruct withsync.RWMutex-protected state. Methods:IsSealed(),Unseal(),Seal(),MasterKey(),PrivKey(),PubKey().Seal()zeroes all key material before nilling.derive.go: ExtractedDeriveFromPassphrase()andDecryptSigningKey()fromcmd/mciassrv/main.gofor reuse by unseal handlers.vault_test.go: Tests for state transitions, key zeroing, concurrent access.
REST API (internal/server/):
POST /v1/vault/unseal: Accept passphrase, derive key, unseal (rate-limited 3/s burst 5)POST /v1/vault/seal: Admin-only, seals vault and zeroes key materialGET /v1/vault/status: Returns{"sealed": bool}GET /v1/health: Now returns{"status":"sealed"}when sealed- All other
/v1/*endpoints return 503vault_sealedwhen sealed
Web UI (internal/ui/):
- New unseal page at
/unsealwith passphrase form (same styling as login) - All UI routes redirect to
/unsealwhen sealed (except/static/) - CSRF manager now derives key lazily from vault
gRPC (internal/grpcserver/):
- New
sealedInterceptorfirst in interceptor chain — returnscodes.Unavailablefor all RPCs except Health - Health RPC returns
status: "sealed"when sealed
Startup (cmd/mciassrv/main.go):
- When passphrase env var is empty/unset (and not first run): starts in sealed state
- When passphrase is available: backward-compatible unsealed startup
- First run still requires passphrase to generate signing key
Refactoring:
- All three servers (REST, UI, gRPC) share a single
*vault.Vaultby pointer - Replaced static
privKey,pubKey,masterKeyfields with vault accessor calls middleware.RequireAuthnow reads pubkey from vault at request time- New
middleware.RequireUnsealedmiddleware wired before request logger
Audit events: Added vault_sealed and vault_unsealed event types.
OpenAPI: Updated openapi.yaml with vault endpoints and sealed health response.
Files changed: 19 files (3 new packages, 3 new handlers, 1 new template, extensive refactoring across all server packages and tests).
2026-03-13 — Make pgcreds discoverable via CLI and UI
Problem: Users had no way to discover which pgcreds were available to them or what their credential IDs were, making it functionally impossible to use the system without manual database inspection.
Solution: Added two complementary discovery paths:
REST API:
- New
GET /v1/pgcredsendpoint (requires authentication) returns all accessible credentials (owned + explicitly granted) with their IDs, host, port, database, username, and timestamps - Response includes
idfield so users can then fetch full credentials viaGET /v1/accounts/{id}/pgcreds
CLI (cmd/mciasctl/main.go):
- New
pgcreds listsubcommand callsGET /v1/pgcredsand displays accessible credentials with IDs - Updated usage documentation to include
pgcreds list
Web UI (web/templates/pgcreds.html):
- Credential ID now displayed in a
<code>element at the top of each credential's metadata block - Styled with monospace font for easy copying and reference
Files modified:
internal/server/server.go: Added routeGET /v1/pgcreds(requires auth, not admin) + handlerhandleListAccessiblePGCredscmd/mciasctl/main.go: AddedpgCredsListfunction and switch caseweb/templates/pgcreds.html: Display credential ID in the credentials list- Struct field alignment fixed in
pgCredResponseto passgo vet
All tests pass; go vet ./... 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