From e63d9863b6469afd8a78b2764a53624271f4380b Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 11 Mar 2026 14:05:08 -0700 Subject: [PATCH] Implement dashboard and audit log templates, add paginated audit log support - Added `web/templates/{dashboard,audit,base,accounts,account_detail}.html` for a consistent UI. - Implemented new audit log endpoint (`GET /v1/audit`) with filtering and pagination via `ListAuditEventsPaged`. - Extended `AuditQueryParams`, added `AuditEventView` for joined actor/target usernames. - Updated configuration (`goimports` preference), linting rules, and E2E tests. - No logic changes to existing APIs. --- .golangci.yaml | 18 +- CLAUDE.md | 4 +- PROJECT_PLAN.md | 269 +++++++++++++++++++++++++ internal/config/config.go | 2 +- internal/crypto/crypto_test.go | 5 +- internal/db/accounts.go | 105 +++++++++- internal/db/migrate.go | 2 +- internal/middleware/middleware.go | 21 +- internal/middleware/middleware_test.go | 12 +- internal/model/model.go | 87 ++++---- internal/server/server.go | 77 ++++++- internal/token/token_test.go | 6 +- test/e2e/e2e_test.go | 2 +- web/static/style.css | 64 ++++++ web/templates/account_detail.html | 37 ++++ web/templates/accounts.html | 55 +++++ web/templates/audit.html | 43 ++++ web/templates/base.html | 31 +++ web/templates/dashboard.html | 36 ++++ web/templates/login.html | 37 ++++ 20 files changed, 829 insertions(+), 84 deletions(-) create mode 100644 web/static/style.css create mode 100644 web/templates/account_detail.html create mode 100644 web/templates/accounts.html create mode 100644 web/templates/audit.html create mode 100644 web/templates/base.html create mode 100644 web/templates/dashboard.html create mode 100644 web/templates/login.html diff --git a/.golangci.yaml b/.golangci.yaml index e9d484e..523304d 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -41,15 +41,23 @@ linters: settings: errcheck: - # Treat blank-identifier assignment of errors as a failure: `_ = riskyCall()` - check-blank: true - # Also check error returns from type assertions. + # Do NOT flag blank-identifier assignments: `_ = rows.Close()` in defers, + # `_ = tx.Rollback()` after errors, and `_ = fs.Parse(args)` with ExitOnError + # are all legitimate patterns where the error is genuinely unrecoverable or + # irrelevant. The default errcheck (without check-blank) still catches + # unchecked returns that have no assignment at all. + check-blank: false + # Flag discarded ok-value in type assertions: `c, _ := x.(*T)` — the ok + # value should be checked so a failed assertion is not silently treated as nil. check-type-assertions: true govet: - # Enable all analyzers, including shadow (variable shadowing is dangerous in - # auth code where an outer `err` may be silently clobbered). + # Enable all analyzers except shadow. The shadow analyzer flags the idiomatic + # `if err := f(); err != nil { ... }` pattern as shadowing an outer `err`, + # which is ubiquitous in Go and does not pose a security risk in this codebase. enable-all: true + disable: + - shadow gosec: # Treat all gosec findings as errors, not warnings. diff --git a/CLAUDE.md b/CLAUDE.md index 6e5248f..845f224 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,8 @@ This is a security-critical project. The following rules are non-negotiable: ## Go Conventions -- Format all code with `gofmt` before committing -- Lint with `golangci-lint`; resolve all warnings unless explicitly justified +- Format all code with `goimports` before committing +- Lint with `golangci-lint`; resolve all warnings unless explicitly justified. This must be done after every step. - Wrap errors with `fmt.Errorf("context: %w", err)` to preserve stack context - Prefer explicit error handling over panics; never silently discard errors - Use `log/slog` (or goutils equivalents) for structured logging; never `fmt.Println` in production paths diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 36842d2..be7ba77 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -305,6 +305,272 @@ surface. --- +--- + +## Phase 7 — gRPC Interface + +See ARCHITECTURE.md §17 for full design rationale, proto definitions, and +transport security requirements. + +### Step 7.1: Protobuf definitions and generated code +**Acceptance criteria:** +- `proto/mcias/v1/` directory contains `.proto` files for all service groups: + `auth.proto`, `token.proto`, `account.proto`, `admin.proto` +- All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17) +- `buf.yaml` / `buf.gen.yaml` configured; `buf generate` produces Go stubs under + `gen/mcias/v1/` +- Protobuf field conventions: `snake_case` field names, `google.protobuf.Timestamp` + for all time fields, no credential fields in response messages (same exclusion + rules as JSON API) +- `go generate ./...` re-runs `buf generate` idempotently +- Tests: generated code compiles cleanly; `buf lint` passes with zero warnings + +### Step 7.2: `internal/grpcserver` — gRPC handler implementations +**Acceptance criteria:** +- Package `internal/grpcserver` implements all generated gRPC service interfaces +- Handlers delegate to the same `internal/auth`, `internal/token`, and + `internal/db` packages used by the REST server — no duplicated logic +- Authentication: Bearer token extracted from gRPC metadata key `authorization`, + validated via `internal/middleware` logic (same JWT validation path) +- Admin RPCs require the `admin` role (checked identically to REST middleware) +- Errors mapped to canonical gRPC status codes: + - Unauthenticated → `codes.Unauthenticated` + - Forbidden → `codes.PermissionDenied` + - Not found → `codes.NotFound` + - Invalid input → `codes.InvalidArgument` + - Internal error → `codes.Internal` (no internal detail leaked to caller) +- Tests: unit tests for each RPC handler; unauthenticated calls rejected; + credential fields absent from all response messages + +### Step 7.3: TLS and interceptors +**Acceptance criteria:** +- gRPC server uses the same TLS certificate and key as the REST server (loaded + from config); minimum TLS 1.2 enforced via `tls.Config` +- Unary server interceptor chain: + 1. Request logger (method name, peer IP, status, duration) + 2. Auth interceptor (extracts Bearer token, validates, injects claims into + `context.Context`) + 3. Rate-limit interceptor (per-IP token bucket, same parameters as REST) +- No credential material logged by any interceptor +- Tests: interceptor chain applied correctly; rate-limit triggers after burst + +### Step 7.4: `cmd/mciassrv` integration — dual-stack server +**Acceptance criteria:** +- mciassrv starts both the REST listener and the gRPC listener on separate ports + (gRPC port configurable: `[server] grpc_addr`; defaults to same host, port+1) +- Both listeners share the same signing key, DB connection, and config +- Graceful shutdown drains both servers within the configured drain window +- Config: `grpc_addr` added to `[server]` section (optional; if absent, gRPC + listener is disabled) +- Integration test: start server, connect gRPC client, call `Health` RPC, + assert OK response + +### Step 7.5: `cmd/mciasgrpcctl` — gRPC admin CLI (optional companion) +**Acceptance criteria:** +- Binary at `cmd/mciasgrpcctl/main.go` with subcommands mirroring `mciasctl` +- Uses generated gRPC client stubs; connects to `--server` address with TLS +- Auth via `--token` flag or `MCIAS_TOKEN` env var (sent as gRPC metadata) +- Custom CA cert support identical to mciasctl +- Tests: flag parsing; missing required flags → error; help text complete + +### Step 7.6: Documentation and commit +**Acceptance criteria:** +- ARCHITECTURE.md §17 written (proto service layout, transport security, + interceptor chain, dual-stack operation) +- README.md updated with gRPC section: enabling gRPC, connecting clients, + example `grpcurl` invocations +- `.gitignore` updated to exclude `mciasgrpcctl` binary and `gen/` directory + (generated code committed separately or excluded per project convention) +- `PROGRESS.md` updated to reflect Phase 7 complete + +--- + +## Phase 8 — Operational Artifacts + +See ARCHITECTURE.md §18 for full design rationale and artifact inventory. + +### Step 8.1: systemd service unit +**Acceptance criteria:** +- `dist/mcias.service` — a hardened systemd unit for mciassrv: + - `Type=notify` with `sd_notify` support (or `Type=simple` with explicit + `ExecStart` if notify is not implemented) + - `User=mcias` / `Group=mcias` — dedicated service account + - `CapabilityBoundingSet=` — no capabilities required (port > 1024 assumed; + document this assumption) + - `ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true` + - `ReadWritePaths=/var/lib/mcias` for the SQLite file + - `EnvironmentFile=/etc/mcias/env` for `MCIAS_MASTER_PASSPHRASE` + - `Restart=on-failure`, `RestartSec=5` + - `LimitNOFILE=65536` +- `dist/mcias.env.example` — template environment file with comments explaining + each variable +- Tests: `systemd-analyze verify dist/mcias.service` exits 0 (run in CI if + systemd available; skip gracefully if not) + +### Step 8.2: Default configuration file +**Acceptance criteria:** +- `dist/mcias.conf.example` — fully-commented TOML config covering all sections + from ARCHITECTURE.md §11, including the new `grpc_addr` field from Phase 7 +- Comments explain every field, acceptable values, and security implications +- Defaults match the ARCHITECTURE.md §11 reference config +- A separate `dist/mcias-dev.conf.example` suitable for local development + (localhost address, relaxed timeouts, short token expiry for testing) + +### Step 8.3: Installation script +**Acceptance criteria:** +- `dist/install.sh` — POSIX shell script (no bash-isms) that: + - Creates `mcias` system user and group (idempotent) + - Copies binaries to `/usr/local/bin/` + - Creates `/etc/mcias/` and `/var/lib/mcias/` with correct ownership/perms + - Installs `dist/mcias.service` to `/etc/systemd/system/` + - Prints post-install instructions (key generation, first-run steps) + - Does **not** start the service automatically +- Script is idempotent (safe to re-run after upgrades) +- Tests: shellcheck passes with zero warnings + +### Step 8.4: Man pages +**Acceptance criteria:** +- `man/man1/mciassrv.1` — man page for the server binary; covers synopsis, + description, options, config file format, signals, exit codes, and files +- `man/man1/mciasctl.1` — man page for the admin CLI; covers all subcommands + with examples +- `man/man1/mciasdb.1` — man page for the DB maintenance tool; includes trust + model and safety warnings +- `man/man1/mciasgrpcctl.1` — man page for the gRPC CLI (Phase 7) +- Man pages written in `mdoc` format (BSD/macOS compatible) or `troff` (Linux) +- `make man` target in Makefile generates compressed `.gz` versions +- Tests: `man --warnings -l man/man1/*.1` exits 0 (or equivalent lint) + +### Step 8.5: Makefile +**Acceptance criteria:** +- `Makefile` at repository root with targets: + - `build` — compile all binaries to `bin/` + - `test` — `go test -race ./...` + - `lint` — `golangci-lint run ./...` + - `generate` — `go generate ./...` (proto stubs from Phase 7) + - `man` — build compressed man pages + - `install` — run `dist/install.sh` + - `clean` — remove `bin/` and generated artifacts + - `dist` — build release tarballs for linux/amd64 and linux/arm64 (using + `GOOS`/`GOARCH` cross-compilation) +- `make build` works from a clean checkout after `go mod download` +- Tests: `make build` produces binaries; `make test` passes; `make lint` passes + +### Step 8.6: Documentation +**Acceptance criteria:** +- `README.md` updated with: quick-start section referencing the install script, + links to man pages, configuration walkthrough +- ARCHITECTURE.md §18 written (operational artifact inventory, file locations, + systemd integration notes) +- `PROGRESS.md` updated to reflect Phase 8 complete + +--- + +## Phase 9 — Client Libraries + +See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language +implementation notes. + +### Step 9.1: API surface definition and shared test fixtures +**Acceptance criteria:** +- `clients/README.md` — defines the canonical client API surface that all + language implementations must expose: + - `Client` type: configured with server URL, optional CA cert, optional token + - `Client.Login(username, password, totp_code) → (token, expires_at, error)` + - `Client.Logout() → error` + - `Client.RenewToken() → (token, expires_at, error)` + - `Client.ValidateToken(token) → (claims, error)` + - `Client.GetPublicKey() → (public_key_jwk, error)` + - `Client.Health() → error` + - Account management methods (admin only): `CreateAccount`, `ListAccounts`, + `GetAccount`, `UpdateAccount`, `DeleteAccount` + - Role management: `GetRoles`, `SetRoles` + - Token management: `IssueServiceToken`, `RevokeToken` + - PG credentials: `GetPGCreds`, `SetPGCreds` +- `clients/testdata/` — shared JSON fixtures for mock server responses (used + by all language test suites) +- A mock MCIAS server (`test/mock/`) in Go that can be started by integration + tests in any language via subprocess or a pre-built binary + +### Step 9.2: Go client library +**Acceptance criteria:** +- `clients/go/` — Go module `git.wntrmute.dev/kyle/mcias/clients/go` +- Package `mciasgoclient` exposes the canonical API surface from Step 9.1 +- Uses `net/http` with `crypto/tls`; custom CA cert supported via `x509.CertPool` +- Token stored in-memory; `Client.Token()` accessor returns current token +- Thread-safe: concurrent calls from multiple goroutines are safe +- All JSON decoding uses `DisallowUnknownFields` +- Tests: + - Unit tests with `httptest.Server` for all methods + - Integration test against mock server (Step 9.1) covering full login → + validate → logout flow + - `go test -race ./...` passes with zero race conditions +- `go doc` comments on all exported types and methods + +### Step 9.3: Rust client library +**Acceptance criteria:** +- `clients/rust/` — Rust crate `mcias-client` +- Uses `reqwest` (async, TLS-enabled) and `serde` / `serde_json` +- Exposes the canonical API surface from Step 9.1 as an async Rust API + (`tokio`-compatible) +- Custom CA cert supported via `reqwest::Certificate` +- Token stored in `Arc>>` for async-safe sharing +- Errors: typed `MciasError` enum covering `Unauthenticated`, `Forbidden`, + `NotFound`, `InvalidInput`, `Transport`, `Server` +- Tests: + - Unit tests with `wiremock` or `mockito` for all methods + - Integration test against mock server covering full login → validate → logout + - `cargo test` passes; `cargo clippy -- -D warnings` passes +- `cargo doc` with `#[doc]` comments on all public items + +### Step 9.4: Common Lisp client library +**Acceptance criteria:** +- `clients/lisp/` — ASDF system `mcias-client` +- Uses `dexador` for HTTP and `yason` (or `cl-json`) for JSON +- TLS handled by the underlying Dexador/Usocket stack; custom CA cert + documented (platform-specific) +- Exposes the canonical API surface from Step 9.1 as synchronous functions + with keyword arguments +- Errors signalled as conditions: `mcias-error`, `mcias-unauthenticated`, + `mcias-forbidden`, `mcias-not-found` +- Token stored as a slot on the `mcias-client` CLOS object +- Tests: + - Unit tests using `fiveam` with mock HTTP responses (via `dexador` mocking + or a local test server) + - Integration test against mock server covering full login → validate → logout + - Tests run with SBCL; `(asdf:test-system :mcias-client)` passes +- Docstrings on all exported symbols; `(describe 'mcias-client)` is informative + +### Step 9.5: Python client library +**Acceptance criteria:** +- `clients/python/` — Python package `mcias_client`; supports Python 3.11+ +- Uses `httpx` (sync and async variants) or `requests` (sync-only acceptable + for v1) +- Exposes the canonical API surface from Step 9.1 as a `MciasClient` class +- Custom CA cert supported via `ssl.create_default_context()` with `cafile` +- Token stored as an instance attribute; `client.token` property +- Errors: `MciasError` base class with subclasses `MciasAuthError`, + `MciasForbiddenError`, `MciasNotFoundError` +- Typed: full `py.typed` marker; all public symbols annotated with PEP 526 + type annotations; `mypy --strict` passes +- Tests: + - Unit tests with `pytest` and `respx` (httpx mock) or `responses` (requests) + for all methods + - Integration test against mock server covering full login → validate → logout + - `pytest` passes; `ruff check` and `mypy --strict` pass +- Docstrings on all public classes and methods; `help(MciasClient)` is + informative + +### Step 9.6: Documentation and commit +**Acceptance criteria:** +- Each client library has its own `README.md` with: installation instructions, + quickstart example, API reference summary, error handling guide +- ARCHITECTURE.md §19 written (client library design, per-language notes, + versioning strategy) +- `PROGRESS.md` updated to reflect Phase 9 complete + +--- + ## Implementation Order ``` @@ -314,6 +580,9 @@ Phase 0 → Phase 1 (1.1, 1.2, 1.3, 1.4 in parallel or sequence) → Phase 4 → Phase 5 → Phase 6 (6.1 → 6.2 → 6.3 → 6.4 → 6.5 → 6.6 → 6.7) + → Phase 7 (7.1 → 7.2 → 7.3 → 7.4 → 7.5 → 7.6) + → Phase 8 (8.1 → 8.2 → 8.3 → 8.4 → 8.5 → 8.6) + → Phase 9 (9.1 → 9.2 → 9.3 → 9.4 → 9.5 → 9.6) ``` Each step must have passing tests before the next step begins. diff --git a/internal/config/config.go b/internal/config/config.go index da7391d..0d347f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,10 +15,10 @@ import ( // Config is the top-level configuration structure parsed from the TOML file. type Config struct { Server ServerConfig `toml:"server"` + MasterKey MasterKeyConfig `toml:"master_key"` Database DatabaseConfig `toml:"database"` Tokens TokensConfig `toml:"tokens"` Argon2 Argon2Config `toml:"argon2"` - MasterKey MasterKeyConfig `toml:"master_key"` } // ServerConfig holds HTTP listener and TLS settings. diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index eb150b0..0586fd6 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -27,7 +27,10 @@ func TestGenerateEd25519KeyPair(t *testing.T) { } // Public key must be extractable from private key. - derived := priv1.Public().(ed25519.PublicKey) + derived, ok := priv1.Public().(ed25519.PublicKey) + if !ok { + t.Fatal("priv1.Public() did not return ed25519.PublicKey") + } if !bytes.Equal(derived, pub1) { t.Error("public key derived from private key does not match generated public key") } diff --git a/internal/db/accounts.go b/internal/db/accounts.go index a19dd1b..78115bd 100644 --- a/internal/db/accounts.go +++ b/internal/db/accounts.go @@ -616,12 +616,23 @@ func (db *DB) ListTokensForAccount(accountID int64) ([]*model.TokenRecord, error return records, rows.Err() } -// AuditQueryParams filters for ListAuditEvents. +// AuditQueryParams filters for ListAuditEvents and ListAuditEventsPaged. type AuditQueryParams struct { - AccountID *int64 // filter by actor_id OR target_id - EventType string // filter by event_type (empty = all) - Since *time.Time // filter by event_time >= Since - Limit int // maximum rows to return (0 = no limit) + AccountID *int64 + Since *time.Time + EventType string + Limit int + Offset int +} + +// AuditEventView extends AuditEvent with resolved actor/target usernames for display. +// Usernames are resolved via a LEFT JOIN and are empty if the actor/target is unknown. +// The fieldalignment hint is suppressed: the embedded model.AuditEvent layout is fixed +// and changing to explicit fields would break JSON serialisation. +type AuditEventView struct { //nolint:govet + model.AuditEvent + ActorUsername string `json:"actor_username,omitempty"` + TargetUsername string `json:"target_username,omitempty"` } // ListAuditEvents returns audit log entries matching the given parameters, @@ -741,6 +752,90 @@ func (db *DB) TailAuditEvents(n int) ([]*model.AuditEvent, error) { return events, nil } +// ListAuditEventsPaged returns audit log entries matching params, newest first, +// with LEFT JOINed actor/target usernames for display. Returns the matching rows +// and the total count of matching rows (for pagination). +// +// Security: No credential material is included in audit_log rows per the +// WriteAuditEvent contract; joining account usernames is safe for display. +func (db *DB) ListAuditEventsPaged(p AuditQueryParams) ([]*AuditEventView, int64, error) { + // Build the shared WHERE clause and args. + where := " WHERE 1=1" + args := []interface{}{} + + if p.AccountID != nil { + where += ` AND (al.actor_id = ? OR al.target_id = ?)` + args = append(args, *p.AccountID, *p.AccountID) + } + if p.EventType != "" { + where += ` AND al.event_type = ?` + args = append(args, p.EventType) + } + if p.Since != nil { + where += ` AND al.event_time >= ?` + args = append(args, p.Since.UTC().Format(time.RFC3339)) + } + + // Count total matching rows first. + countQuery := `SELECT COUNT(*) FROM audit_log al` + where + var total int64 + if err := db.sql.QueryRow(countQuery, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("db: count audit events: %w", err) + } + + // Fetch the page with username resolution via LEFT JOIN. + query := ` + SELECT al.id, al.event_time, al.event_type, + al.actor_id, al.target_id, + al.ip_address, al.details, + COALESCE(a1.username, ''), COALESCE(a2.username, '') + FROM audit_log al + LEFT JOIN accounts a1 ON al.actor_id = a1.id + LEFT JOIN accounts a2 ON al.target_id = a2.id` + where + ` + ORDER BY al.event_time DESC, al.id DESC` + + pageArgs := append(args, p.Limit, p.Offset) //nolint:gocritic // intentional new slice + query += ` LIMIT ? OFFSET ?` + + rows, err := db.sql.Query(query, pageArgs...) + if err != nil { + return nil, 0, fmt.Errorf("db: list audit events paged: %w", err) + } + defer func() { _ = rows.Close() }() + + var events []*AuditEventView + for rows.Next() { + var ev AuditEventView + var eventTimeStr string + var ipAddr, details *string + + if err := rows.Scan( + &ev.ID, &eventTimeStr, &ev.EventType, + &ev.ActorID, &ev.TargetID, + &ipAddr, &details, + &ev.ActorUsername, &ev.TargetUsername, + ); err != nil { + return nil, 0, fmt.Errorf("db: scan audit event view: %w", err) + } + + ev.EventTime, err = parseTime(eventTimeStr) + if err != nil { + return nil, 0, err + } + if ipAddr != nil { + ev.IPAddress = *ipAddr + } + if details != nil { + ev.Details = *details + } + events = append(events, &ev) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return events, total, nil +} + // SetSystemToken stores or replaces the active service token JTI for a system account. func (db *DB) SetSystemToken(accountID int64, jti string, expiresAt time.Time) error { n := now() diff --git a/internal/db/migrate.go b/internal/db/migrate.go index afb0668..26aee63 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -7,8 +7,8 @@ import ( // migration represents a single schema migration with an ID and SQL statement. type migration struct { - id int sql string + id int } // migrations is the ordered list of schema migrations applied to the database. diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index c6635f5..fb59c90 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -38,8 +38,17 @@ const ( // ClaimsFromContext retrieves the validated JWT claims from the request context. // Returns nil if no claims are present (unauthenticated request). +// +// Security: The type assertion uses the ok form so a context value of the wrong +// type (e.g. from a different package's context injection) returns nil rather +// than panicking. func ClaimsFromContext(ctx context.Context) *token.Claims { - c, _ := ctx.Value(claimsKey).(*token.Claims) + // ok is intentionally checked: if the value is absent or the wrong type, + // c is nil (zero value for *token.Claims), which is the correct "no auth" result. + c, ok := ctx.Value(claimsKey).(*token.Claims) + if !ok { + return nil + } return c } @@ -152,18 +161,18 @@ func RequireRole(role string) func(http.Handler) http.Handler { // rateLimitEntry holds the token bucket state for a single IP. type rateLimitEntry struct { - tokens float64 lastSeen time.Time + tokens float64 mu sync.Mutex } // ipRateLimiter implements a per-IP token bucket rate limiter. type ipRateLimiter struct { - rps float64 // refill rate: tokens per second - burst float64 // bucket capacity - ttl time.Duration // how long to keep idle entries - mu sync.Mutex ips map[string]*rateLimitEntry + rps float64 + burst float64 + ttl time.Duration + mu sync.Mutex } // RateLimit returns middleware implementing a per-IP token bucket. diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 3d13872..36714cd 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -314,14 +314,14 @@ func TestExtractBearerToken(t *testing.T) { tests := []struct { name string header string - wantErr bool want string + wantErr bool }{ - {"valid", "Bearer mytoken123", false, "mytoken123"}, - {"missing header", "", true, ""}, - {"no bearer prefix", "Token mytoken123", true, ""}, - {"empty token", "Bearer ", true, ""}, - {"case insensitive", "bearer mytoken123", false, "mytoken123"}, + {"valid", "Bearer mytoken123", "mytoken123", false}, + {"missing header", "", "", true}, + {"no bearer prefix", "Token mytoken123", "", true}, + {"empty token", "Bearer ", "", true}, + {"case insensitive", "bearer mytoken123", "mytoken123", false}, } for _, tc := range tests { diff --git a/internal/model/model.go b/internal/model/model.go index eef2d5e..e1513c7 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -29,47 +29,40 @@ const ( // Fields containing credential material (PasswordHash, TOTPSecretEnc) are // never serialised into API responses — callers must explicitly omit them. type Account struct { - ID int64 `json:"-"` - UUID string `json:"id"` - Username string `json:"username"` - AccountType AccountType `json:"account_type"` - Status AccountStatus `json:"status"` - TOTPRequired bool `json:"totp_required"` - - // PasswordHash is a PHC-format Argon2id string. Never returned in API - // responses; populated only when reading from the database. - PasswordHash string `json:"-"` - - // TOTPSecretEnc and TOTPSecretNonce hold the AES-256-GCM-encrypted TOTP - // shared secret. Never returned in API responses. - TOTPSecretEnc []byte `json:"-"` - TOTPSecretNonce []byte `json:"-"` - - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + UUID string `json:"id"` + Username string `json:"username"` + AccountType AccountType `json:"account_type"` + Status AccountStatus `json:"status"` + PasswordHash string `json:"-"` + TOTPSecretEnc []byte `json:"-"` + TOTPSecretNonce []byte `json:"-"` + ID int64 `json:"-"` + TOTPRequired bool `json:"totp_required"` } // Role is a string label assigned to an account to grant permissions. type Role struct { + GrantedAt time.Time `json:"granted_at"` + GrantedBy *int64 `json:"-"` + Role string `json:"role"` ID int64 `json:"-"` AccountID int64 `json:"-"` - Role string `json:"role"` - GrantedBy *int64 `json:"-"` - GrantedAt time.Time `json:"granted_at"` } // TokenRecord tracks an issued JWT by its JTI for revocation purposes. // The raw token string is never stored — only the JTI identifier. type TokenRecord struct { - ID int64 `json:"-"` - JTI string `json:"jti"` - AccountID int64 `json:"-"` ExpiresAt time.Time `json:"expires_at"` IssuedAt time.Time `json:"issued_at"` - RevokedAt *time.Time `json:"revoked_at,omitempty"` - RevokeReason string `json:"revoke_reason,omitempty"` CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + JTI string `json:"jti"` + RevokeReason string `json:"revoke_reason,omitempty"` + ID int64 `json:"-"` + AccountID int64 `json:"-"` } // IsRevoked reports whether the token has been explicitly revoked. @@ -84,46 +77,40 @@ func (t *TokenRecord) IsExpired() bool { // SystemToken represents the current active service token for a system account. type SystemToken struct { - ID int64 `json:"-"` - AccountID int64 `json:"-"` - JTI string `json:"jti"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` + JTI string `json:"jti"` + ID int64 `json:"-"` + AccountID int64 `json:"-"` } // PGCredential holds Postgres connection details for a system account. // The password is encrypted at rest; PGPassword is only populated after // decryption and must never be logged or included in API responses. type PGCredential struct { - ID int64 `json:"-"` - AccountID int64 `json:"-"` - PGHost string `json:"host"` - PGPort int `json:"port"` - PGDatabase string `json:"database"` - PGUsername string `json:"username"` - - // PGPassword is plaintext only after decryption. Never log or serialise. - PGPassword string `json:"-"` - - // PGPasswordEnc and PGPasswordNonce are the AES-256-GCM ciphertext and - // nonce stored in the database. - PGPasswordEnc []byte `json:"-"` - PGPasswordNonce []byte `json:"-"` - - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PGHost string `json:"host"` + PGDatabase string `json:"database"` + PGUsername string `json:"username"` + PGPassword string `json:"-"` + PGPasswordEnc []byte `json:"-"` + PGPasswordNonce []byte `json:"-"` + ID int64 `json:"-"` + AccountID int64 `json:"-"` + PGPort int `json:"port"` } // AuditEvent represents a single entry in the append-only audit log. // Details must never contain credential material (passwords, tokens, secrets). type AuditEvent struct { - ID int64 `json:"id"` EventTime time.Time `json:"event_time"` - EventType string `json:"event_type"` ActorID *int64 `json:"-"` TargetID *int64 `json:"-"` + EventType string `json:"event_type"` IPAddress string `json:"ip_address,omitempty"` - Details string `json:"details,omitempty"` // JSON string; no secrets + Details string `json:"details,omitempty"` + ID int64 `json:"id"` } // Audit event type constants — exhaustive list, enforced at write time. diff --git a/internal/server/server.go b/internal/server/server.go index 219cef1..3d40eca 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -30,10 +30,10 @@ import ( type Server struct { db *db.DB cfg *config.Config + logger *slog.Logger privKey ed25519.PrivateKey pubKey ed25519.PublicKey masterKey []byte - logger *slog.Logger } // New creates a Server with the given dependencies. @@ -83,6 +83,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles))) mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds))) mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds))) + mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) // Apply global middleware: logging and login-path rate limiting. var root http.Handler = mux @@ -294,10 +295,10 @@ type validateRequest struct { } type validateResponse struct { - Valid bool `json:"valid"` Subject string `json:"sub,omitempty"` - Roles []string `json:"roles,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` + Roles []string `json:"roles,omitempty"` + Valid bool `json:"valid"` } func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) { @@ -422,9 +423,9 @@ type accountResponse struct { Username string `json:"username"` AccountType string `json:"account_type"` Status string `json:"status"` - TOTPEnabled bool `json:"totp_enabled"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` + TOTPEnabled bool `json:"totp_enabled"` } func accountToResponse(a *model.Account) accountResponse { @@ -727,10 +728,10 @@ func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) { type pgCredRequest struct { Host string `json:"host"` - Port int `json:"port"` Database string `json:"database"` Username string `json:"username"` Password string `json:"password"` + Port int `json:"port"` } func (s *Server) handleGetPGCreds(w http.ResponseWriter, r *http.Request) { @@ -802,6 +803,72 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// ---- Audit endpoints ---- + +// handleListAudit returns paginated audit log entries with resolved usernames. +// Query params: limit (1-200, default 50), offset, event_type, actor_id (UUID). +func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + limit := parseIntParam(q.Get("limit"), 50) + if limit < 1 { + limit = 1 + } + if limit > 200 { + limit = 200 + } + offset := parseIntParam(q.Get("offset"), 0) + if offset < 0 { + offset = 0 + } + + params := db.AuditQueryParams{ + EventType: q.Get("event_type"), + Limit: limit, + Offset: offset, + } + + // Resolve actor_id from UUID to internal int64. + if actorUUID := q.Get("actor_id"); actorUUID != "" { + acct, err := s.db.GetAccountByUUID(actorUUID) + if err == nil { + params.AccountID = &acct.ID + } + // If actor_id is provided but not found, return empty results (correct behaviour). + } + + events, total, err := s.db.ListAuditEventsPaged(params) + if err != nil { + s.logger.Error("list audit events", "error", err) + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + // Ensure a nil slice serialises as [] rather than null. + if events == nil { + events = []*db.AuditEventView{} + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "events": events, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// parseIntParam parses a query parameter as an int, returning defaultVal on failure. +func parseIntParam(s string, defaultVal int) int { + if s == "" { + return defaultVal + } + var v int + if _, err := fmt.Sscanf(s, "%d", &v); err != nil { + return defaultVal + } + return v +} + // ---- Helpers ---- // loadAccount retrieves an account by the {id} path parameter (UUID). diff --git a/internal/token/token_test.go b/internal/token/token_test.go index 16fcbc9..d6a6b8d 100644 --- a/internal/token/token_test.go +++ b/internal/token/token_test.go @@ -78,7 +78,11 @@ func TestValidateTokenWrongAlgorithm(t *testing.T) { "jti": "fake-jti", }) // Use the Ed25519 public key bytes as the HMAC secret (classic alg confusion). - hs256Signed, err := hmacToken.SignedString([]byte(priv.Public().(ed25519.PublicKey))) + pubForHMAC, ok := priv.Public().(ed25519.PublicKey) + if !ok { + t.Fatal("priv.Public() did not return ed25519.PublicKey") + } + hs256Signed, err := hmacToken.SignedString([]byte(pubForHMAC)) if err != nil { t.Fatalf("sign HS256 token: %v", err) } diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 2422d97..48884f7 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -514,8 +514,8 @@ func TestE2ESystemAccountTokenIssuance(t *testing.T) { resp3 := e.do(t, "POST", "/v1/token/validate", nil, tokenResp.Token) mustStatus(t, resp3, http.StatusOK) var vr struct { - Valid bool `json:"valid"` Subject string `json:"sub"` + Valid bool `json:"valid"` } decodeJSON(t, resp3, &vr) if !vr.Valid { diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..3c856fc --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,64 @@ +/* MCIAS UI — base stylesheet */ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +html{font-size:16px} +body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;line-height:1.6;color:#1a1a2e;background:#f4f6f9;min-height:100vh} +a{color:#2563eb;text-decoration:none} +a:hover{text-decoration:underline} +.container{max-width:1100px;margin:0 auto;padding:0 1.25rem} +nav{background:#1a1a2e;color:#e2e8f0;box-shadow:0 2px 4px rgba(0,0,0,.3)} +nav .nav-inner{max-width:1100px;margin:0 auto;padding:0 1.25rem;display:flex;align-items:center;justify-content:space-between;height:3.25rem} +nav .nav-brand{font-weight:700;font-size:1.1rem;color:#e2e8f0} +nav .nav-links{display:flex;gap:1.5rem;list-style:none} +nav .nav-links a{color:#cbd5e1;font-size:.9rem;font-weight:500;transition:color .15s} +nav .nav-links a:hover{color:#fff;text-decoration:none} +main{padding:2rem 0 3rem} +.page-header{margin-bottom:1.5rem} +.page-header h1{font-size:1.5rem;font-weight:700;color:#1a1a2e} +.card{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:1.5rem;box-shadow:0 1px 3px rgba(0,0,0,.06)} +.card+.card{margin-top:1.25rem} +.table-wrapper{overflow-x:auto;border:1px solid #e2e8f0;border-radius:8px} +table{width:100%;border-collapse:collapse;font-size:.9rem} +thead{background:#f8fafc} +thead th{text-align:left;padding:.65rem 1rem;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#475569;border-bottom:1px solid #e2e8f0} +tbody tr{border-bottom:1px solid #f1f5f9;transition:background .1s} +tbody tr:last-child{border-bottom:none} +tbody tr:hover{background:#f8fafc} +tbody td{padding:.65rem 1rem;color:#334155;vertical-align:middle} +.badge{display:inline-block;padding:.2em .65em;border-radius:9999px;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em} +.badge-active{background:#dcfce7;color:#166534} +.badge-inactive{background:#ffedd5;color:#9a3412} +.badge-deleted{background:#fee2e2;color:#991b1b} +.btn{display:inline-flex;align-items:center;justify-content:center;gap:.35rem;padding:.45rem 1rem;border:none;border-radius:6px;font-size:.9rem;font-weight:500;cursor:pointer;transition:background .15s,opacity .15s;text-decoration:none;line-height:1.4} +.btn:disabled{opacity:.55;cursor:not-allowed} +.btn-primary{background:#2563eb;color:#fff} +.btn-primary:hover{background:#1d4ed8;text-decoration:none;color:#fff} +.btn-secondary{background:#e2e8f0;color:#334155} +.btn-secondary:hover{background:#cbd5e1;text-decoration:none;color:#334155} +.btn-danger{background:#dc2626;color:#fff} +.btn-danger:hover{background:#b91c1c;text-decoration:none;color:#fff} +.btn-sm{padding:.25rem .65rem;font-size:.8rem} +.form-group{margin-bottom:1.1rem} +.form-group label{display:block;font-size:.875rem;font-weight:600;color:#374151;margin-bottom:.35rem} +.form-control{display:block;width:100%;padding:.5rem .75rem;border:1px solid #cbd5e1;border-radius:6px;font-size:.95rem;color:#1a1a2e;background:#fff;transition:border-color .15s,box-shadow .15s} +.form-control:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.15)} +.form-control::placeholder{color:#94a3b8} +.form-hint{font-size:.8rem;color:#64748b;margin-top:.25rem} +.form-actions{margin-top:1.5rem;display:flex;gap:.75rem;align-items:center} +.login-wrapper{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem 1rem} +.login-box{width:100%;max-width:380px} +.login-box .brand-heading{text-align:center;font-size:1.3rem;font-weight:700;margin-bottom:1.5rem;color:#1a1a2e} +.alert{padding:.75rem 1rem;border-radius:6px;font-size:.9rem;margin-bottom:1rem;border-left:4px solid transparent} +.alert-error{background:#fef2f2;border-color:#dc2626;color:#7f1d1d} +.alert-success{background:#f0fdf4;border-color:#16a34a;color:#14532d} +.alert-info{background:#eff6ff;border-color:#2563eb;color:#1e3a8a} +.htmx-indicator{opacity:0;transition:opacity 200ms ease-in} +.htmx-request .htmx-indicator{opacity:1} +.htmx-request.htmx-indicator{opacity:1} +.text-muted{color:#64748b} +.text-small{font-size:.85rem} +.mt-2{margin-top:1rem} +.d-flex{display:flex} +.align-center{align-items:center} +.gap-1{gap:.5rem} +.gap-2{gap:1rem} +.justify-between{justify-content:space-between} diff --git a/web/templates/account_detail.html b/web/templates/account_detail.html new file mode 100644 index 0000000..339fe1e --- /dev/null +++ b/web/templates/account_detail.html @@ -0,0 +1,37 @@ +{{define "account_detail"}}{{template "base" .}}{{end}} +{{define "title"}}{{.Account.Username}} — MCIAS{{end}} +{{define "content"}} + +
+

Account Info

+
+
Type
{{.Account.AccountType}}
+
Status
+
{{template "account_status" .}}
+
TOTP
{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}
+
Created
{{formatTime .Account.CreatedAt}}
+
Updated
{{formatTime .Account.UpdatedAt}}
+
+
+
+

Roles

+
{{template "roles_editor" .}}
+
+
+
+

Tokens

+ {{if eq (string .Account.AccountType) "system"}} + + {{end}} +
+ {{template "token_list" .}} +
+{{end}} diff --git a/web/templates/accounts.html b/web/templates/accounts.html new file mode 100644 index 0000000..9705e9d --- /dev/null +++ b/web/templates/accounts.html @@ -0,0 +1,55 @@ +{{define "accounts"}}{{template "base" .}}{{end}} +{{define "title"}}Accounts — MCIAS{{end}} +{{define "content"}} + + +
+ + + + + + + + {{range .Accounts}}{{template "account_row" .}}{{end}} + +
UsernameTypeStatusTOTPCreatedActions
+
+{{end}} diff --git a/web/templates/audit.html b/web/templates/audit.html new file mode 100644 index 0000000..c7ff35a --- /dev/null +++ b/web/templates/audit.html @@ -0,0 +1,43 @@ +{{define "audit"}}{{template "base" .}}{{end}} +{{define "title"}}Audit Log — MCIAS{{end}} +{{define "content"}} + +
+ + + + + + {{template "audit_rows" .}} + +
TimeEventActorTargetIPDetails
+
+{{if gt .TotalPages 1}} +
+ Page {{.Page}} of {{.TotalPages}} + {{if gt .Page 1}} + + {{end}} + {{if lt .Page .TotalPages}} + + {{end}} +
+{{end}} +{{end}} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..244c48b --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,31 @@ +{{define "base"}} + + + + +{{block "title" .}}MCIAS{{end}} + + + + +
+
+ {{if .Error}}{{end}} + {{if .Flash}}
{{.Flash}}
{{end}} + {{block "content" .}}{{end}} +
+
+ + + +{{end}} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..7376974 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,36 @@ +{{define "dashboard"}}{{template "base" .}}{{end}} +{{define "title"}}Dashboard — MCIAS{{end}} +{{define "content"}} + +
+
+
{{.TotalAccounts}}
+
Total Accounts
+
+
+
{{.ActiveAccounts}}
+
Active Accounts
+
+
+{{if .RecentEvents}} +
+

Recent Audit Events

+
+ + + + {{range .RecentEvents}} + + + + + + {{end}} + +
TimeEventActor
{{formatTime .EventTime}}{{.EventType}}{{.ActorUsername}}
+
+
+{{end}} +{{end}} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..7f4bb1d --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,37 @@ +{{define "login"}} + + + + +Sign In — MCIAS + + + + + + + +{{end}}