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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
269
PROJECT_PLAN.md
269
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<Mutex<Option<String>>>` 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
64
web/static/style.css
Normal file
64
web/static/style.css
Normal file
@@ -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}
|
||||
37
web/templates/account_detail.html
Normal file
37
web/templates/account_detail.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "account_detail"}}{{template "base" .}}{{end}}
|
||||
{{define "title"}}{{.Account.Username}} — MCIAS{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-header d-flex align-center justify-between">
|
||||
<div>
|
||||
<h1>{{.Account.Username}}</h1>
|
||||
<p class="text-muted text-small">{{.Account.UUID}}</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary" href="/accounts">← Accounts</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Account Info</h2>
|
||||
<dl style="display:grid;grid-template-columns:140px 1fr;gap:.5rem .75rem;font-size:.9rem">
|
||||
<dt class="text-muted">Type</dt><dd>{{.Account.AccountType}}</dd>
|
||||
<dt class="text-muted">Status</dt>
|
||||
<dd id="status-cell">{{template "account_status" .}}</dd>
|
||||
<dt class="text-muted">TOTP</dt><dd>{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}</dd>
|
||||
<dt class="text-muted">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd>
|
||||
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Roles</h2>
|
||||
<div id="roles-editor">{{template "roles_editor" .}}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="d-flex align-center justify-between" style="margin-bottom:1rem">
|
||||
<h2 style="font-size:1rem;font-weight:600">Tokens</h2>
|
||||
{{if eq (string .Account.AccountType) "system"}}
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-post="/accounts/{{.Account.UUID}}/token"
|
||||
hx-target="#token-list" hx-swap="outerHTML">Issue Token</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "token_list" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
55
web/templates/accounts.html
Normal file
55
web/templates/accounts.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{{define "accounts"}}{{template "base" .}}{{end}}
|
||||
{{define "title"}}Accounts — MCIAS{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-header d-flex align-center justify-between">
|
||||
<div>
|
||||
<h1>Accounts</h1>
|
||||
<p class="text-muted text-small">Manage user and service accounts</p>
|
||||
</div>
|
||||
<button class="btn btn-primary"
|
||||
onclick="var f=document.getElementById('create-form');f.style.display=f.style.display==='none'?'block':'none'">
|
||||
+ New Account
|
||||
</button>
|
||||
</div>
|
||||
<div id="create-form" class="card mt-2" style="display:none">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Create Account</h2>
|
||||
<form hx-post="/accounts" hx-target="#accounts-tbody" hx-swap="afterbegin">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="new-username">Username</label>
|
||||
<input class="form-control" type="text" id="new-username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">Password</label>
|
||||
<input class="form-control" type="password" id="new-password" name="password">
|
||||
<span class="form-hint">Required for human accounts</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-type">Type</label>
|
||||
<select class="form-control" id="new-type" name="account_type">
|
||||
<option value="human">Human</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Create</button>
|
||||
<button class="btn btn-secondary" type="button"
|
||||
onclick="document.getElementById('create-form').style.display='none'">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrapper mt-2">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th><th>Type</th><th>Status</th><th>TOTP</th><th>Created</th><th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accounts-tbody">
|
||||
{{range .Accounts}}{{template "account_row" .}}{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
43
web/templates/audit.html
Normal file
43
web/templates/audit.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "audit"}}{{template "base" .}}{{end}}
|
||||
{{define "title"}}Audit Log — MCIAS{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-header d-flex align-center justify-between">
|
||||
<div>
|
||||
<h1>Audit Log</h1>
|
||||
<p class="text-muted text-small">{{.Total}} total events</p>
|
||||
</div>
|
||||
<form class="d-flex gap-1 align-center" style="font-size:.9rem">
|
||||
<select class="form-control" name="event_type" style="width:auto"
|
||||
hx-get="/audit/rows" hx-target="#audit-tbody" hx-swap="innerHTML"
|
||||
hx-trigger="change">
|
||||
<option value="">All events</option>
|
||||
{{range .EventTypes}}<option value="{{.}}"{{if eq $.FilterType .}} selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Event</th><th>Actor</th><th>Target</th><th>IP</th><th>Details</th></tr>
|
||||
</thead>
|
||||
<tbody id="audit-tbody">
|
||||
{{template "audit_rows" .}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if gt .TotalPages 1}}
|
||||
<div class="d-flex gap-1 align-center mt-2" style="font-size:.9rem;color:#64748b">
|
||||
<span>Page {{.Page}} of {{.TotalPages}}</span>
|
||||
{{if gt .Page 1}}
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-get="/audit/rows?page={{sub .Page 1}}&event_type={{.FilterType}}"
|
||||
hx-target="#audit-tbody" hx-swap="innerHTML">← Prev</button>
|
||||
{{end}}
|
||||
{{if lt .Page .TotalPages}}
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-get="/audit/rows?page={{add .Page 1}}&event_type={{.FilterType}}"
|
||||
hx-target="#audit-tbody" hx-swap="innerHTML">Next →</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
31
web/templates/base.html
Normal file
31
web/templates/base.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{block "title" .}}MCIAS{{end}}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<span class="nav-brand">MCIAS</span>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/dashboard">Dashboard</a></li>
|
||||
<li><a href="/accounts">Accounts</a></li>
|
||||
<li><a href="/audit">Audit</a></li>
|
||||
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="container">
|
||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||
{{if .Flash}}<div class="alert alert-success" role="status">{{.Flash}}</div>{{end}}
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
</main>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
36
web/templates/dashboard.html
Normal file
36
web/templates/dashboard.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{{define "dashboard"}}{{template "base" .}}{{end}}
|
||||
{{define "title"}}Dashboard — MCIAS{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem">
|
||||
<div class="card" style="text-align:center">
|
||||
<div style="font-size:2rem;font-weight:700;color:#2563eb">{{.TotalAccounts}}</div>
|
||||
<div class="text-muted text-small">Total Accounts</div>
|
||||
</div>
|
||||
<div class="card" style="text-align:center">
|
||||
<div style="font-size:2rem;font-weight:700;color:#16a34a">{{.ActiveAccounts}}</div>
|
||||
<div class="text-muted text-small">Active Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .RecentEvents}}
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Recent Audit Events</h2>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Event</th><th>Actor</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .RecentEvents}}
|
||||
<tr>
|
||||
<td class="text-small text-muted">{{formatTime .EventTime}}</td>
|
||||
<td><code style="font-size:.8rem">{{.EventType}}</code></td>
|
||||
<td class="text-small text-muted">{{.ActorUsername}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
37
web/templates/login.html
Normal file
37
web/templates/login.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "login"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign In — MCIAS</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrapper">
|
||||
<div class="login-box">
|
||||
<div class="brand-heading">MCIAS</div>
|
||||
<div class="card">
|
||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||
<form id="login-form" method="POST" action="/login"
|
||||
hx-post="/login" hx-target="#login-form" hx-swap="outerHTML">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input class="form-control" type="text" id="username" name="username"
|
||||
autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input class="form-control" type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit" style="width:100%">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user