Files
mcias/PROGRESS.md
Kyle Isom b2f2f04646 UI: pgcreds create button; show logged-in user
* web/templates/pgcreds.html: New Credentials card is now always
  rendered; Add Credentials toggle button reveals the create form
  (hidden by default). Shows a message when all system accounts
  already have credentials. Previously the card was hidden when
  UncredentialedAccounts was empty.
* internal/ui/ui.go: added ActorName string field to PageData;
  added actorName(r) helper resolving username from JWT claims
  via DB lookup, returns empty string if unauthenticated.
* internal/ui/handlers_*.go: all full-page PageData constructors
  now pass ActorName: u.actorName(r).
* web/templates/base.html: nav bar renders actor username as a
  muted label before the Logout button when logged in.
* web/static/style.css: added .nav-actor rule (muted grey, 0.85rem).
2026-03-12 11:38:57 -07:00

613 lines
28 KiB
Markdown

# 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 — 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
---
- [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
- [x] Phase 1: Foundational packages (model, config, crypto, db)
- [x] Phase 2: Auth core (auth, token, middleware)
- [x] Phase 3: HTTP server (server, mciassrv binary)
- [x] Phase 4: Admin CLI (mciasctl binary)
- [x] Phase 5: E2E tests, security hardening, commit
- [x] Phase 6: mciasdb — direct SQLite maintenance tool
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
- [x] Phase 10: Policy engine — ABAC with machine/service gating
---
### 2026-03-11 — Phase 10: Policy engine (ABAC + machine/service gating)
**internal/policy/** (new package)
- `policy.go` — types: `Action`, `ResourceType`, `Effect`, `Resource`,
`PolicyInput`, `Rule`, `RuleBody`; 22 Action constants covering all API
operations
- `engine.go``Evaluate(input, operatorRules) (Effect, *Rule)`: pure function;
merges operator rules with default rules, sorts by priority, deny-wins,
then first allow, then default-deny
- `defaults.go` — 6 compiled-in rules (IDs -1 to -6, Priority 0): admin
wildcard, self-service logout/renew, self-service TOTP, system account own
pgcreds, system account own service token, public login/validate endpoints
- `engine_wrapper.go``Engine` struct with `sync.RWMutex`; `SetRules()`
decodes DB records; `PolicyRecord` type avoids import cycle
- `engine_test.go` — 11 tests: DefaultDeny, AdminWildcard, SelfService*,
SystemOwn*, DenyWins, ServiceNameGating, MachineTagGating,
OwnerMatchesSubject, PriorityOrder, MultipleRequiredTags, AccountTypeGating
**internal/db/**
- `migrate.go`: migration id=4 — `account_tags` (account_id+tag PK, FK cascade)
and `policy_rules` (id, priority, description, rule_json, enabled,
created_by, timestamps) tables
- `tags.go` (new): `GetAccountTags`, `AddAccountTag`, `RemoveAccountTag`,
`SetAccountTags` (atomic DELETE+INSERT transaction); sorted alphabetically
- `policy.go` (new): `CreatePolicyRule`, `GetPolicyRule`, `ListPolicyRules`,
`UpdatePolicyRule`, `SetPolicyRuleEnabled`, `DeletePolicyRule`
- `tags_test.go`, `policy_test.go` (new): comprehensive DB-layer tests
**internal/model/**
- `PolicyRuleRecord` struct added
- New audit event constants: `EventTagAdded`, `EventTagRemoved`,
`EventPolicyRuleCreated`, `EventPolicyRuleUpdated`, `EventPolicyRuleDeleted`,
`EventPolicyDeny`
**internal/middleware/**
- `RequirePolicy` middleware: assembles `PolicyInput` from JWT claims +
`AccountTypeLookup` closure (DB-backed, avoids JWT schema change) +
`ResourceBuilder` closure; calls `engine.Evaluate`; logs deny via
`PolicyDenyLogger`
**internal/server/**
- New REST endpoints (all require admin):
- `GET/PUT /v1/accounts/{id}/tags`
- `GET/POST /v1/policy/rules`
- `GET/PATCH/DELETE /v1/policy/rules/{id}`
- `handlers_policy.go`: `handleGetTags`, `handleSetTags`, `handleListPolicyRules`,
`handleCreatePolicyRule`, `handleGetPolicyRule`, `handleUpdatePolicyRule`,
`handleDeletePolicyRule`, `policyRuleToResponse`, `loadPolicyRule`
**internal/ui/**
- `handlers_policy.go` (new): `handlePoliciesPage`, `handleCreatePolicyRule`,
`handleTogglePolicyRule`, `handleDeletePolicyRule`, `handleSetAccountTags`
- `ui.go`: registered 5 policy UI routes; added `PolicyRuleView`, `PoliciesData`,
`AccountTagsData` view types; added new fragment templates to shared set
**web/templates/**
- `policies.html` (new): policies management page
- `fragments/policy_row.html` (new): HTMX table row with enable/disable toggle
and delete button
- `fragments/policy_form.html` (new): create form with JSON textarea and action
reference chips
- `fragments/tags_editor.html` (new): newline-separated tag editor with HTMX
PUT for atomic replacement
- `account_detail.html`: added Tags card section using tags_editor fragment
- `base.html`: added Policies nav link
**cmd/mciasctl/**
- `policy` subcommands: `list`, `create -description STR -json FILE [-priority N]`,
`get -id ID`, `update -id ID [-priority N] [-enabled true|false]`,
`delete -id ID`
- `tag` subcommands: `list -id UUID`, `set -id UUID -tags tag1,tag2,...`
**openapi.yaml**
- New schemas: `TagsResponse`, `RuleBody`, `PolicyRule`
- New paths: `GET/PUT /v1/accounts/{id}/tags`,
`GET/POST /v1/policy/rules`, `GET/PATCH/DELETE /v1/policy/rules/{id}`
- New tag: `Admin — Policy`
**Design highlights:**
- Deny-wins + default-deny: explicit Deny beats any Allow; no match = Deny
- AccountType resolved via DB lookup (not JWT) to avoid breaking 29 IssueToken
call sites
- `RequirePolicy` wired alongside `RequireRole("admin")` for belt-and-suspenders
during migration; defaults reproduce current binary behavior exactly
- `policy.PolicyRecord` type avoids circular import between policy/db/model
All tests pass; `go test ./...` clean; `golangci-lint run ./...` clean.
### 2026-03-11 — Fix test failures and lockout logic
- `internal/db/accounts.go` (IsLockedOut): corrected window-expiry check from
`LockoutWindow+LockoutDuration` to `LockoutWindow`; stale failures outside
the rolling window now correctly return not-locked regardless of count
- `internal/grpcserver/grpcserver_test.go` (TestUpdateAccount,
TestSetAndGetRoles): updated test passwords from 9-char "pass12345" to
13-char "pass123456789" to satisfy the 12-character minimum (F-13)
- Reformatted import blocks in both files with goimports to resolve gci lint
warnings
All 5 packages pass `go test ./...`; `golangci-lint run ./...` clean.
### 2026-03-11 — Phase 9: Client libraries
**clients/testdata/** — shared JSON fixtures
- login_response.json, account_response.json, accounts_list_response.json
- validate_token_response.json, public_key_response.json, pgcreds_response.json
- error_response.json, roles_response.json
**clients/go/** — Go client library
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`; package `mciasgoclient`
- Typed errors: `MciasAuthError`, `MciasForbiddenError`, `MciasNotFoundError`,
`MciasInputError`, `MciasConflictError`, `MciasServerError`
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
- Token state guarded by `sync.RWMutex` for concurrent safety
- JSON decoded with `DisallowUnknownFields` on all responses
- 25 tests in `client_test.go`; all pass with `go test -race`
**clients/rust/** — Rust async client library
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
- 23 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
**clients/lisp/** — Common Lisp client library
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
- CLOS class `mcias-client`; plain functions for all operations
- Conditions: `mcias-error` base + 6 typed subclasses
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
- 37 fiveam checks; all pass on SBCL 2.6.1
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
to `t`/`nil` before returning
**clients/python/** — Python 3.11+ client library
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
- `Client` context manager; `py.typed` marker; all symbols fully annotated
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
- 32 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
**test/mock/mockserver.go** — Go in-memory mock server
- `Server` struct with `sync.RWMutex`; used by Go client integration test
- `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`