diff --git a/.junie/memory/feedback.md b/.junie/memory/feedback.md index a9eaf69..f5814d4 100644 --- a/.junie/memory/feedback.md +++ b/.junie/memory/feedback.md @@ -22,3 +22,19 @@ "NEW INSTRUCTION": "WHEN implementing download endpoints THEN return non-200 on failure with an explanatory message" } +[2026-03-15 13:52] - Updated by Junie +{ + "TYPE": "negative", + "CATEGORY": "tarball download", + "EXPECTATION": "If the cert/key tgz cannot be generated correctly, the response should not be a 200 attachment; it should clearly show an error in the browser.", + "NEW INSTRUCTION": "WHEN download content is invalid or generation fails THEN return non-200 with visible error page" +} + +[2026-03-15 13:55] - Updated by Junie +{ + "TYPE": "correction", + "CATEGORY": "root cause update", + "EXPECTATION": "The tgz download failure was caused by the aria2 download manager, not the server.", + "NEW INSTRUCTION": "WHEN user reports aria2 caused the issue THEN acknowledge client-side cause and reassess server changes" +} + diff --git a/.junie/memory/language.json b/.junie/memory/language.json index 8cc035d..d12aedc 100644 --- a/.junie/memory/language.json +++ b/.junie/memory/language.json @@ -1 +1 @@ -[{"lang":"en","usageCount":40}] \ No newline at end of file +[{"lang":"en","usageCount":49}] \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b3be617..b4617e3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,6 +71,7 @@ internal/ policy/ Priority-based ACL engine engine/ Pluggable engine registry & interface ca/ CA (PKI) engine — X.509 certificate issuance + acme/ ACME protocol handler (RFC 8555); EAB, accounts, orders server/ REST API HTTP server, routes, middleware grpcserver/ gRPC server, interceptors, per-service handlers webserver/ Web UI HTTP server, routes, HTMX handlers @@ -78,6 +79,7 @@ proto/metacrypt/ v1/ Original gRPC proto definitions (generic Execute RPC) v2/ Typed gRPC proto definitions (per-operation RPCs, Timestamp fields) gen/metacrypt/v1/ Generated Go gRPC/protobuf code (v1) +gen/metacrypt/v2/ Generated Go gRPC/protobuf code (v2) web/ templates/ Go HTML templates (layout, init, unseal, login, dashboard, PKI) static/ CSS, HTMX @@ -384,10 +386,10 @@ Certificate generation uses the `certgen` package from | `create-issuer` | Admin | Generate intermediate CA signed by root | | `delete-issuer` | Admin | Remove issuer and zeroize its key | | `list-issuers` | Any auth | List issuer names | -| `issue` | User/Admin | Issue leaf cert from named issuer | -| `get-cert` | Any auth | Get cert record by serial | -| `list-certs` | Any auth | List issued cert summaries | -| `renew` | User/Admin | Re-issue cert with same attributes | +| `issue` | Admin | Issue leaf cert from named issuer | +| `get-cert` | User/Admin | Get cert record by serial | +| `list-certs` | User/Admin | List issued cert summaries | +| `renew` | Admin | Re-issue cert with same attributes | #### Certificate Profiles @@ -472,6 +474,16 @@ kept in sync — every operation available via REST has a corresponding gRPC RPC | POST | `/v1/engine/unmount` | Remove engine mount | Admin | | POST | `/v1/engine/request` | Route request to engine | User | +### Policy (Admin Only) + +| Method | Path | Description | +|--------|-----------------------|------------------------------------| +| GET | `/v1/policy/rules` | List all policy rules | +| POST | `/v1/policy/rules` | Create a new policy rule | +| GET | `/v1/policy/rule?id=` | Get a policy rule by ID | +| PUT | `/v1/policy/rule?id=` | Update a policy rule by ID | +| DELETE | `/v1/policy/rule?id=` | Delete a policy rule by ID | + The mount endpoint accepts `{name, type, config}` where `config` is an engine-type-specific configuration object. The request endpoint accepts `{mount, operation, path, data}` and populates `CallerInfo` from the @@ -482,21 +494,56 @@ authenticated user's token. | Method | Path | Description | |--------|-------------------------------------|-------------------------------| | GET | `/v1/pki/{mount}/ca` | Root CA certificate (PEM) | -| GET | `/v1/pki/{mount}/ca/chain?issuer=` | Full chain: issuer + root (PEM) | +| GET | `/v1/pki/{mount}/ca/chain` | Full chain: issuer + root (PEM) | | GET | `/v1/pki/{mount}/issuer/{name}` | Issuer certificate (PEM) | These routes serve certificates with `Content-Type: application/x-pem-file`, allowing systems to bootstrap TLS trust without authentication. The mount must be of type `ca`; returns 404 otherwise. -### Policy (Admin Only) +### CA Certificate Management (Authenticated) -| Method | Path | Description | -|--------|-----------------------|---------------------| -| GET | `/v1/policy/rules` | List all rules | -| POST | `/v1/policy/rules` | Create a rule | -| GET | `/v1/policy/rule?id=` | Get rule by ID | -| DELETE | `/v1/policy/rule?id=` | Delete rule by ID | +| Method | Path | Description | Auth | +|--------|---------------------------------------|------------------------------------|---------| +| GET | `/v1/ca/{mount}/cert/{serial}` | Get certificate record by serial | User | +| POST | `/v1/ca/{mount}/cert/{serial}/revoke` | Revoke a certificate | Admin | +| DELETE | `/v1/ca/{mount}/cert/{serial}` | Delete a certificate record | Admin | + +### Policy (Authenticated) + +| Method | Path | Description | Auth | +|--------|-----------------------|---------------------|-------| +| GET | `/v1/policy/rules` | List all rules | User | +| POST | `/v1/policy/rules` | Create a rule | User | +| GET | `/v1/policy/rule?id=` | Get rule by ID | User | +| DELETE | `/v1/policy/rule?id=` | Delete rule by ID | User | + +### ACME (RFC 8555) + +ACME protocol endpoints are mounted per CA engine instance and require no +authentication (per the ACME spec). External Account Binding (EAB) is +supported. + +| Method | Path | Description | Auth | +|--------|-----------------------------------|--------------------------------------|---------| +| GET | `/acme/{mount}/directory` | ACME directory object | None | +| HEAD/GET | `/acme/{mount}/new-nonce` | Obtain a fresh replay nonce | None | +| POST | `/acme/{mount}/new-account` | Register or retrieve an account | None | +| POST | `/acme/{mount}/new-order` | Create a new certificate order | None | +| POST | `/acme/{mount}/authz/{id}` | Fetch/respond to an authorization | None | +| POST | `/acme/{mount}/challenge/{type}/{id}` | Respond to a challenge | None | +| POST | `/acme/{mount}/finalize/{id}` | Finalize an order (submit CSR) | None | +| POST | `/acme/{mount}/cert/{id}` | Download issued certificate | None | +| POST | `/acme/{mount}/revoke-cert` | Revoke a certificate via ACME | None | + +ACME management endpoints require MCIAS authentication: + +| Method | Path | Description | Auth | +|--------|-------------------------------|--------------------------------------|---------| +| POST | `/v1/acme/{mount}/eab` | Create EAB credentials for a user | User | +| PUT | `/v1/acme/{mount}/config` | Set default issuer for ACME mount | Admin | +| GET | `/v1/acme/{mount}/accounts` | List ACME accounts | Admin | +| GET | `/v1/acme/{mount}/orders` | List ACME orders | Admin | ### Error Responses @@ -517,9 +564,11 @@ HTTP status codes: Metacrypt also exposes a gRPC API defined in `proto/metacrypt/`. Two API versions exist: -#### v1 (current implementation) +#### v1 (legacy proto definitions) -The v1 API uses a generic `Execute` RPC for all engine operations: +The v1 API uses a generic `Execute` RPC for all engine operations. The v1 +proto definitions are retained for reference; the active server implementation +uses v2. ```protobuf rpc Execute(ExecuteRequest) returns (ExecuteResponse); @@ -540,15 +589,16 @@ Timestamps are represented as RFC3339 strings within the `Struct` payload. The `EngineService` also provides `Mount`, `Unmount`, and `ListMounts` RPCs for engine lifecycle management. -#### v2 (defined, not yet implemented) +#### v2 (implemented) The v2 API (`proto/metacrypt/v2/`) replaces the generic `Execute` RPC with strongly-typed, per-operation RPCs and uses `google.protobuf.Timestamp` for -all time fields. Key changes: +all time fields. The gRPC server is fully implemented against v2. Key changes +from v1: -- **`CAService`**: 11 typed RPCs — `ImportRoot`, `GetRoot`, `CreateIssuer`, +- **`CAService`**: 14 typed RPCs — `ImportRoot`, `GetRoot`, `CreateIssuer`, `DeleteIssuer`, `ListIssuers`, `GetIssuer`, `GetChain`, `IssueCert`, - `GetCert`, `ListCerts`, `RenewCert`. + `GetCert`, `ListCerts`, `RenewCert`, `SignCSR`, `RevokeCert`, `DeleteCert`. - **`EngineService`**: Retains `Mount`, `Unmount`, `ListMounts`; drops the generic `Execute` RPC. `MountRequest.config` is `map` instead of `google.protobuf.Struct`. @@ -556,13 +606,13 @@ all time fields. Key changes: `google.protobuf.Timestamp` instead of RFC3339 strings. - **Message types**: `CertRecord` (full certificate data) and `CertSummary` (lightweight, for list responses) replace the generic struct maps. -- **`ACMEService`** and **`AuthService`**: String timestamps replaced by - `google.protobuf.Timestamp`. +- **`ACMEService`**: `CreateEAB`, `SetConfig`, `ListAccounts`, `ListOrders`. +- **`AuthService`**: String timestamps replaced by `google.protobuf.Timestamp`. -The v2 proto definitions pass `buf lint` with no warnings. Server-side -implementation of v2 is planned as a future milestone. +The v2 proto definitions pass `buf lint` with no warnings. Generated Go code +lives in `gen/metacrypt/v2/`. -#### gRPC Interceptors (v1) +#### gRPC Interceptors (v2) The gRPC server (`internal/grpcserver/`) uses three interceptor maps to gate access: @@ -573,8 +623,9 @@ access: | `authRequiredMethods` | Validates MCIAS bearer token; populates caller info | | `adminRequiredMethods`| Requires `IsAdmin == true` on the caller | -All three maps include the `Execute` RPC, ensuring engine operations are -always authenticated and gated on unseal state. +All CA write operations, engine lifecycle RPCs, policy mutations, and ACME +management RPCs are gated on unseal state, authentication, and (where +appropriate) admin privilege. --- @@ -582,22 +633,33 @@ always authenticated and gated on unseal state. Metacrypt includes an HTMX-powered web UI for basic operations: -| Route | Purpose | -|--------------------------|-------------------------------------------------------| -| `/` | Redirects based on service state | -| `/init` | Password setup form (first-time only) | -| `/unseal` | Password entry to unseal | -| `/login` | MCIAS login form (username, password, TOTP) | -| `/dashboard` | Engine mounts, service state, admin controls | -| `/pki` | PKI overview: list issuers, download CA/issuer PEMs | -| `/pki/issuer/{name}` | Issuer detail: certificates issued by that issuer | +| Route | Purpose | +|-------------------------------|-------------------------------------------------------| +| `/` | Redirects based on service state | +| `/init` | Password setup form (first-time only) | +| `/unseal` | Password entry to unseal | +| `/login` | MCIAS login form (username, password, TOTP) | +| `/dashboard` | Engine mounts, service state, admin controls | +| `/dashboard/mount-ca` | Mount a new CA engine (POST, admin) | +| `/pki` | PKI overview: list issuers, download CA/issuer PEMs | +| `/pki/import-root` | Import an existing root CA (POST) | +| `/pki/create-issuer` | Create a new intermediate issuer (POST) | +| `/pki/issue` | Issue a leaf certificate (POST) | +| `/pki/download/{token}` | Download issued cert bundle as .tar.gz | +| `/pki/issuer/{name}` | Issuer detail: certificates issued by that issuer | +| `/pki/cert/{serial}` | Certificate detail page | +| `/pki/cert/{serial}/download` | Download certificate files | +| `/pki/cert/{serial}/revoke` | Revoke a certificate (POST) | +| `/pki/cert/{serial}/delete` | Delete a certificate record (POST) | +| `/pki/{issuer}` | Issuer detail (alternate path) | The dashboard shows mounted engines, the service state, and (for admins) a seal button. Templates use Go's `html/template` with a shared layout. HTMX provides form submission without full page reloads. The PKI pages communicate with the backend via the internal gRPC client -(`internal/webserver/client.go`), which wraps the v1 gRPC `Execute` RPC. +(`internal/webserver/client.go`), which uses the v2 typed gRPC stubs +(`CAService`, `EngineService`, `SystemService`, etc.). The issuer detail page supports filtering certificates by common name (case-insensitive substring match) and sorting by common name (default) or expiry date. @@ -649,9 +711,15 @@ TOML configuration with environment variable overrides (`METACRYPT_*`). ```toml [server] listen_addr = ":8443" # required +grpc_addr = ":8444" # optional; gRPC server disabled if unset tls_cert = "/path/cert.pem" # required tls_key = "/path/key.pem" # required +[web] +listen_addr = "127.0.0.1:8080" # optional; web UI server address +vault_grpc = "127.0.0.1:9443" # gRPC address of the vault server +vault_ca_cert = "/path/ca.pem" # optional; CA cert to verify vault TLS + [database] path = "/path/metacrypt.db" # required @@ -764,9 +832,6 @@ closing connections before exit. ### Planned Capabilities -- **gRPC v2 server implementation** — The v2 typed proto definitions are - complete; the server-side handlers and generated Go code remain to be - implemented - **Post-quantum readiness** — Hybrid key exchange (ML-KEM + ECDH); the versioned ciphertext format and engine interface are designed for algorithm agility diff --git a/POLICY.md b/POLICY.md new file mode 100644 index 0000000..0e3ad5e --- /dev/null +++ b/POLICY.md @@ -0,0 +1,206 @@ +# Metacrypt Policy Engine + +Metacrypt includes a priority-based access control policy engine that governs +which authenticated users may perform which operations on which engine +resources. This document explains the policy model, rule structure, evaluation +algorithm, and how to manage rules via the API and web UI. + +## Overview + +Every request to `POST /v1/engine/request` is evaluated against the policy +engine before being dispatched to the underlying cryptographic engine. The +policy engine enforces a **default-deny** posture: unless a matching allow rule +exists, non-admin users are denied. + +**Admin bypass**: Users with the `admin` role in MCIAS always pass policy +evaluation unconditionally. Policy rules only affect non-admin users. + +## Rule Structure + +A policy rule is a JSON object with the following fields: + +| Field | Type | Required | Description | +|-------------|-----------------|----------|----------------------------------------------------------| +| `id` | string | Yes | Unique identifier for the rule | +| `priority` | integer | Yes | Evaluation order; lower number = higher priority | +| `effect` | `"allow"` or `"deny"` | Yes | Decision when this rule matches | +| `usernames` | []string | No | Match specific usernames (case-insensitive). Empty = any | +| `roles` | []string | No | Match any of these roles (case-insensitive). Empty = any | +| `resources` | []string | No | Glob patterns for the resource path. Empty = any | +| `actions` | []string | No | `"read"` or `"write"`. Empty = any | + +### Resource Paths + +Resources follow the pattern `engine//`. For example: + +- `engine/pki/list-certs` — list certificates on the `pki` mount +- `engine/pki/issue` — issue a certificate on the `pki` mount +- `engine/transit/*` — any operation on the `transit` mount + +Glob patterns use standard filepath matching (`*` matches within a path +segment; `**` is not supported — use `engine/pki/*` to match all operations on +a mount). + +### Actions + +Operations are classified as either `read` or `write`: + +| Action | Operations | +|---------|-------------------------------------------------------------------| +| `read` | `list-issuers`, `list-certs`, `get-cert`, `get-root`, `get-chain`, `get-issuer` | +| `write` | All other operations (`issue`, `renew`, `sign-csr`, `create-issuer`, `delete-issuer`, etc.) | + +## Evaluation Algorithm + +1. If the caller has the `admin` role → **allow** immediately (bypass all rules). +2. Load all rules from the barrier. +3. Sort rules by `priority` ascending (lower number = evaluated first). +4. Iterate rules in order; for each rule, check: + - If `usernames` is non-empty, the caller's username must match one entry. + - If `roles` is non-empty, the caller must have at least one matching role. + - If `resources` is non-empty, the resource must match at least one glob pattern. + - If `actions` is non-empty, the action must match one entry. + - All specified conditions must match (AND logic within a rule). +5. The **first matching rule** wins; return its `effect`. +6. If no rule matches → **deny** (default deny). + +## Managing Rules via the REST API + +All policy endpoints require an authenticated admin token +(`Authorization: Bearer `). + +### List Rules + +``` +GET /v1/policy/rules +``` + +Returns a JSON array of all policy rules. + +### Create a Rule + +``` +POST /v1/policy/rules +Content-Type: application/json + +{ + "id": "allow-users-read-pki", + "priority": 10, + "effect": "allow", + "roles": ["user"], + "resources": ["engine/pki/*"], + "actions": ["read"] +} +``` + +Returns `201 Created` with the created rule on success. + +### Get a Rule + +``` +GET /v1/policy/rule?id=allow-users-read-pki +``` + +### Update a Rule + +``` +PUT /v1/policy/rule?id=allow-users-read-pki +Content-Type: application/json + +{ ... updated rule fields ... } +``` + +### Delete a Rule + +``` +DELETE /v1/policy/rule?id=allow-users-read-pki +``` + +## Managing Rules via the Web UI + +Navigate to **Policy** in the top navigation bar (visible to admin users only). + +The policy page shows: + +- A table of all active rules with their ID, priority, effect, and match + conditions. +- A **Create Rule** form (expandable) for adding new rules. +- A **Delete** button on each row to remove a rule. + +## Managing Rules via gRPC + +The `PolicyService` gRPC service (defined in `proto/metacrypt/v2/policy.proto`) +provides equivalent operations: + +| RPC | Description | +|------------------|--------------------------| +| `CreatePolicy` | Create a new rule | +| `ListPolicies` | List all rules | +| `GetPolicy` | Get a rule by ID | +| `DeletePolicy` | Delete a rule by ID | + +## Common Patterns + +### Allow users to read PKI resources + +```json +{ + "id": "allow-users-read-pki", + "priority": 10, + "effect": "allow", + "roles": ["user"], + "resources": ["engine/pki/*"], + "actions": ["read"] +} +``` + +### Allow a specific user to issue certificates + +```json +{ + "id": "allow-alice-issue", + "priority": 5, + "effect": "allow", + "usernames": ["alice"], + "resources": ["engine/pki/issue"], + "actions": ["write"] +} +``` + +### Deny guests all access to a mount + +```json +{ + "id": "deny-guests-transit", + "priority": 1, + "effect": "deny", + "roles": ["guest"], + "resources": ["engine/transit/*"] +} +``` + +### Allow users read-only access to all mounts + +```json +{ + "id": "allow-users-read-all", + "priority": 50, + "effect": "allow", + "roles": ["user"], + "actions": ["read"] +} +``` + +## Role Summary + +| Role | Default access | +|---------|-----------------------------------------------------| +| `admin` | Full access to everything (policy bypass) | +| `user` | Denied by default; grant access via policy rules | +| `guest` | Denied by default; grant access via policy rules | + +## Storage + +Policy rules are stored in the encrypted barrier under the prefix +`policy/rules/`. They are encrypted at rest with the master encryption key +and are only accessible when the service is unsealed. diff --git a/go.mod b/go.mod index cbe9a3e..7d6af03 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ replace git.wntrmute.dev/kyle/mcias/clients/go => /Users/kyle/src/mcias/clients/ replace git.wntrmute.dev/kyle/goutils => /Users/kyle/src/goutils require ( - git.wntrmute.dev/kyle/goutils v0.0.0-00010101000000-000000000000 + git.wntrmute.dev/kyle/goutils v1.21.1 git.wntrmute.dev/kyle/mcias/clients/go v0.0.0-00010101000000-000000000000 github.com/go-chi/chi/v5 v5.2.5 github.com/pelletier/go-toml/v2 v2.2.4 diff --git a/internal/config/config.go b/internal/config/config.go index c523cfc..b8a9e5b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,8 +48,9 @@ type DatabaseConfig struct { // MCIASConfig holds MCIAS integration settings. type MCIASConfig struct { - ServerURL string `toml:"server_url"` - CACert string `toml:"ca_cert"` + ServerURL string `toml:"server_url"` + CACert string `toml:"ca_cert"` + ServiceToken string `toml:"service_token"` } // SealConfig holds Argon2id parameters for the seal process. diff --git a/internal/engine/ca/ca.go b/internal/engine/ca/ca.go index dd89a38..d3af560 100644 --- a/internal/engine/ca/ca.go +++ b/internal/engine/ca/ca.go @@ -637,6 +637,9 @@ func (e *CAEngine) handleListIssuers(_ context.Context, req *engine.Request) (*e if req.CallerInfo == nil { return nil, ErrUnauthorized } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } e.mu.RLock() defer e.mu.RUnlock() @@ -661,6 +664,9 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin if req.CallerInfo == nil { return nil, ErrUnauthorized } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } issuerName, _ := req.Data["issuer"].(string) if issuerName == "" { @@ -810,6 +816,9 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng if req.CallerInfo == nil { return nil, ErrUnauthorized } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } serial, _ := req.Data["serial"].(string) if serial == "" { @@ -858,6 +867,9 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e if req.CallerInfo == nil { return nil, ErrUnauthorized } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } e.mu.RLock() defer e.mu.RUnlock() @@ -902,6 +914,9 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin if req.CallerInfo == nil { return nil, ErrUnauthorized } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } serial, _ := req.Data["serial"].(string) if serial == "" { @@ -1028,6 +1043,9 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng if req.CallerInfo == nil { return nil, ErrUnauthorized } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } issuerName, _ := req.Data["issuer"].(string) if issuerName == "" { diff --git a/internal/engine/ca/ca_test.go b/internal/engine/ca/ca_test.go index 6e63286..2b80579 100644 --- a/internal/engine/ca/ca_test.go +++ b/internal/engine/ca/ca_test.go @@ -76,6 +76,10 @@ func userCaller() *engine.CallerInfo { return &engine.CallerInfo{Username: "user", Roles: []string{"user"}, IsAdmin: false} } +func guestCaller() *engine.CallerInfo { + return &engine.CallerInfo{Username: "guest", Roles: []string{"guest"}, IsAdmin: false} +} + func setupEngine(t *testing.T) (*CAEngine, *memBarrier) { t.Helper() b := newMemBarrier() @@ -166,7 +170,7 @@ func TestInitializeWithImportedRoot(t *testing.T) { _, err = eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", - CallerInfo: userCaller(), + CallerInfo: adminCaller(), Data: map[string]interface{}{ "issuer": "infra", "common_name": "imported.example.com", @@ -313,7 +317,7 @@ func TestIssueCertificate(t *testing.T) { resp, err := eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", Path: "infra", - CallerInfo: userCaller(), + CallerInfo: adminCaller(), Data: map[string]interface{}{ "issuer": "infra", "common_name": "web.example.com", @@ -375,7 +379,7 @@ func TestIssueCertificateWithOverrides(t *testing.T) { // Issue with custom TTL and key usages. resp, err := eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", - CallerInfo: userCaller(), + CallerInfo: adminCaller(), Data: map[string]interface{}{ "issuer": "infra", "common_name": "peer.example.com", @@ -433,6 +437,169 @@ func TestIssueRejectsNilCallerInfo(t *testing.T) { } } +func TestIssueRejectsNonAdmin(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer: %v", err) + } + + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "test.example.com", + "profile": "server", + }, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden, got: %v", err) + } +} + +func TestRenewRejectsNonAdmin(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer: %v", err) + } + + issueResp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "test.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("issue: %v", err) + } + + serial := issueResp.Data["serial"].(string) //nolint:errcheck + + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "renew", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "serial": serial, + }, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden, got: %v", err) + } +} + +func TestSignCSRRejectsNonAdmin(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer: %v", err) + } + + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-csr", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "issuer": "infra", + "csr_pem": "dummy", + }, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for user, got: %v", err) + } + + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-csr", + CallerInfo: guestCaller(), + Data: map[string]interface{}{ + "issuer": "infra", + "csr_pem": "dummy", + }, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest, got: %v", err) + } +} + +func TestListIssuersRejectsGuest(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-issuers", + CallerInfo: guestCaller(), + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest, got: %v", err) + } + + // user and admin should succeed + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-issuers", + CallerInfo: userCaller(), + }) + if err != nil { + t.Errorf("expected user to list issuers, got: %v", err) + } +} + +func TestGetCertRejectsGuest(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-cert", + CallerInfo: guestCaller(), + Data: map[string]interface{}{"serial": "abc123"}, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest, got: %v", err) + } +} + +func TestListCertsRejectsGuest(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-certs", + CallerInfo: guestCaller(), + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest, got: %v", err) + } + + // user should succeed + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-certs", + CallerInfo: userCaller(), + }) + if err != nil { + t.Errorf("expected user to list certs, got: %v", err) + } +} + func TestPrivateKeyNotStoredInBarrier(t *testing.T) { eng, b := setupEngine(t) ctx := context.Background() @@ -448,7 +615,7 @@ func TestPrivateKeyNotStoredInBarrier(t *testing.T) { resp, err := eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", - CallerInfo: userCaller(), + CallerInfo: adminCaller(), Data: map[string]interface{}{ "issuer": "infra", "common_name": "test.example.com", @@ -487,7 +654,7 @@ func TestRenewCertificate(t *testing.T) { // Issue original cert. issueResp, err := eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", - CallerInfo: userCaller(), + CallerInfo: adminCaller(), Data: map[string]interface{}{ "issuer": "infra", "common_name": "renew.example.com", @@ -504,7 +671,7 @@ func TestRenewCertificate(t *testing.T) { // Renew. renewResp, err := eng.HandleRequest(ctx, &engine.Request{ Operation: "renew", - CallerInfo: userCaller(), + CallerInfo: adminCaller(), Data: map[string]interface{}{ "serial": origSerial, }, @@ -545,7 +712,7 @@ func TestGetAndListCerts(t *testing.T) { for _, cn := range []string{"a.example.com", "b.example.com"} { _, err := eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", - CallerInfo: userCaller(), + CallerInfo: adminCaller(), Data: map[string]interface{}{ "issuer": "infra", "common_name": cn, @@ -622,7 +789,7 @@ func TestUnsealRestoresIssuers(t *testing.T) { // Verify we can issue from the restored issuer. _, err = eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", - CallerInfo: userCaller(), + CallerInfo: adminCaller(), Data: map[string]interface{}{ "issuer": "infra", "common_name": "after-unseal.example.com", diff --git a/internal/engine/engine.go b/internal/engine/engine.go index a059a78..2f4296e 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -37,6 +37,19 @@ type CallerInfo struct { IsAdmin bool } +// IsUser returns true if the caller has the "user" or "admin" role (i.e. not guest-only). +func (c *CallerInfo) IsUser() bool { + if c.IsAdmin { + return true + } + for _, r := range c.Roles { + if r == "user" { + return true + } + } + return false +} + // Request is a request to an engine. type Request struct { Data map[string]interface{} diff --git a/internal/server/routes.go b/internal/server/routes.go index b1935e5..e1bc823 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -269,6 +269,25 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { } info := TokenInfoFromContext(r.Context()) + + // Evaluate policy before dispatching to the engine. + policyReq := &policy.Request{ + Username: info.Username, + Roles: info.Roles, + Resource: "engine/" + req.Mount + "/" + req.Operation, + Action: operationAction(req.Operation), + } + effect, err := s.policy.Evaluate(r.Context(), policyReq) + if err != nil { + s.logger.Error("policy evaluation failed", "error", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + if effect != policy.EffectAllow { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + engReq := &engine.Request{ Operation: req.Operation, Path: req.Path, @@ -547,6 +566,16 @@ func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) { return caEng, nil } +// operationAction maps an engine operation name to a policy action ("read" or "write"). +func operationAction(op string) string { + switch op { + case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer": + return "read" + default: + return "write" + } +} + func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 544208c..b58c177 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -163,6 +163,100 @@ func TestRootNotFound(t *testing.T) { } } +func unsealServer(t *testing.T, sealMgr *seal.Manager, _ interface{}) { + t.Helper() + params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} + if err := sealMgr.Initialize(context.Background(), []byte("password"), params); err != nil { + t.Fatalf("initialize: %v", err) + } +} + +func makeEngineRequest(mount, operation string) string { + return `{"mount":"` + mount + `","operation":"` + operation + `","data":{}}` +} + +func withTokenInfo(r *http.Request, info *auth.TokenInfo) *http.Request { + return r.WithContext(context.WithValue(r.Context(), tokenInfoKey, info)) +} + +// TestEngineRequestPolicyDeniesNonAdmin verifies that a non-admin user without +// an explicit allow rule is denied by the policy engine. +func TestEngineRequestPolicyDeniesNonAdmin(t *testing.T) { + srv, sealMgr, _ := setupTestServer(t) + unsealServer(t, sealMgr, nil) + + body := makeEngineRequest("pki", "list-issuers") + req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) + req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}) + w := httptest.NewRecorder() + srv.handleEngineRequest(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 Forbidden for non-admin without policy rule, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestEngineRequestPolicyAllowsAdmin verifies that admin users bypass policy. +func TestEngineRequestPolicyAllowsAdmin(t *testing.T) { + srv, sealMgr, _ := setupTestServer(t) + unsealServer(t, sealMgr, nil) + + body := makeEngineRequest("pki", "list-issuers") + req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) + req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true}) + w := httptest.NewRecorder() + srv.handleEngineRequest(w, req) + + // Admin bypasses policy; will fail with mount-not-found (404), not forbidden (403). + if w.Code == http.StatusForbidden { + t.Errorf("admin should not be forbidden by policy, got 403: %s", w.Body.String()) + } +} + +// TestEngineRequestPolicyAllowsWithRule verifies that a non-admin user with an +// explicit allow rule is permitted to proceed. +func TestEngineRequestPolicyAllowsWithRule(t *testing.T) { + srv, sealMgr, _ := setupTestServer(t) + unsealServer(t, sealMgr, nil) + + ctx := context.Background() + _ = srv.policy.CreateRule(ctx, &policy.Rule{ + ID: "allow-user-read", + Priority: 100, + Effect: policy.EffectAllow, + Roles: []string{"user"}, + Resources: []string{"engine/*/*"}, + Actions: []string{"read"}, + }) + + body := makeEngineRequest("pki", "list-issuers") + req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) + req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}) + w := httptest.NewRecorder() + srv.handleEngineRequest(w, req) + + // Policy allows; will fail with mount-not-found (404), not forbidden (403). + if w.Code == http.StatusForbidden { + t.Errorf("user with allow rule should not be forbidden, got 403: %s", w.Body.String()) + } +} + +// TestOperationAction verifies the read/write classification of operations. +func TestOperationAction(t *testing.T) { + readOps := []string{"list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer"} + for _, op := range readOps { + if got := operationAction(op); got != "read" { + t.Errorf("operationAction(%q) = %q, want %q", op, got, "read") + } + } + writeOps := []string{"issue", "renew", "create-issuer", "delete-issuer", "sign-csr", "revoke"} + for _, op := range writeOps { + if got := operationAction(op); got != "write" { + t.Errorf("operationAction(%q) = %q, want %q", op, got, "write") + } + } +} + func TestTokenInfoFromContext(t *testing.T) { ctx := context.Background() if info := TokenInfoFromContext(ctx); info != nil { diff --git a/internal/webserver/cert_detail_test.go b/internal/webserver/cert_detail_test.go index 1b7c51e..c6f0a29 100644 --- a/internal/webserver/cert_detail_test.go +++ b/internal/webserver/cert_detail_test.go @@ -110,6 +110,20 @@ func (m *mockVault) DeleteCert(ctx context.Context, token, mount, serial string) return nil } +func (m *mockVault) ListPolicies(ctx context.Context, token string) ([]PolicyRule, error) { + return nil, nil +} + +func (m *mockVault) GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error) { + return nil, nil +} + +func (m *mockVault) CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error) { + return nil, nil +} + +func (m *mockVault) DeletePolicy(ctx context.Context, token, id string) error { return nil } + func (m *mockVault) Close() error { return nil } // ---- handleTGZDownload tests ---- diff --git a/internal/webserver/client.go b/internal/webserver/client.go index 18be91b..fb1c29b 100644 --- a/internal/webserver/client.go +++ b/internal/webserver/client.go @@ -23,6 +23,7 @@ type VaultClient struct { engine pb.EngineServiceClient pki pb.PKIServiceClient ca pb.CAServiceClient + policy pb.PolicyServiceClient } // NewVaultClient dials the vault gRPC server and returns a client. @@ -60,6 +61,7 @@ func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient, engine: pb.NewEngineServiceClient(conn), pki: pb.NewPKIServiceClient(conn), ca: pb.NewCAServiceClient(conn), + policy: pb.NewPolicyServiceClient(conn), }, nil } @@ -379,6 +381,85 @@ func (c *VaultClient) DeleteCert(ctx context.Context, token, mount, serial strin return err } +// PolicyRule holds a policy rule for display and management. +type PolicyRule struct { + ID string + Priority int + Effect string + Usernames []string + Roles []string + Resources []string + Actions []string +} + +// ListPolicies returns all policy rules from the vault. +func (c *VaultClient) ListPolicies(ctx context.Context, token string) ([]PolicyRule, error) { + resp, err := c.policy.ListPolicies(withToken(ctx, token), &pb.ListPoliciesRequest{}) + if err != nil { + return nil, err + } + rules := make([]PolicyRule, 0, len(resp.Rules)) + for _, r := range resp.Rules { + rules = append(rules, pbToRule(r)) + } + return rules, nil +} + +// GetPolicy retrieves a single policy rule by ID. +func (c *VaultClient) GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error) { + resp, err := c.policy.GetPolicy(withToken(ctx, token), &pb.GetPolicyRequest{Id: id}) + if err != nil { + return nil, err + } + rule := pbToRule(resp.Rule) + return &rule, nil +} + +// CreatePolicy creates a new policy rule. +func (c *VaultClient) CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error) { + resp, err := c.policy.CreatePolicy(withToken(ctx, token), &pb.CreatePolicyRequest{ + Rule: ruleToPB(rule), + }) + if err != nil { + return nil, err + } + created := pbToRule(resp.Rule) + return &created, nil +} + +// DeletePolicy removes a policy rule by ID. +func (c *VaultClient) DeletePolicy(ctx context.Context, token, id string) error { + _, err := c.policy.DeletePolicy(withToken(ctx, token), &pb.DeletePolicyRequest{Id: id}) + return err +} + +func pbToRule(r *pb.PolicyRule) PolicyRule { + if r == nil { + return PolicyRule{} + } + return PolicyRule{ + ID: r.Id, + Priority: int(r.Priority), + Effect: r.Effect, + Usernames: r.Usernames, + Roles: r.Roles, + Resources: r.Resources, + Actions: r.Actions, + } +} + +func ruleToPB(r PolicyRule) *pb.PolicyRule { + return &pb.PolicyRule{ + Id: r.ID, + Priority: int32(r.Priority), + Effect: r.Effect, + Usernames: r.Usernames, + Roles: r.Roles, + Resources: r.Resources, + Actions: r.Actions, + } +} + // CertSummary holds lightweight certificate metadata for list views. type CertSummary struct { Serial string diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index 7147fac..c9ae111 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" @@ -38,6 +39,12 @@ func (ws *WebServer) registerRoutes(r chi.Router) { r.Get("/dashboard", ws.requireAuth(ws.handleDashboard)) r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA)) + r.Route("/policy", func(r chi.Router) { + r.Get("/", ws.requireAuth(ws.handlePolicy)) + r.Post("/", ws.requireAuth(ws.handlePolicyCreate)) + r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete)) + }) + r.Route("/pki", func(r chi.Router) { r.Get("/", ws.requireAuth(ws.handlePKI)) r.Post("/import-root", ws.requireAuth(ws.handleImportRoot)) @@ -71,6 +78,7 @@ func (ws *WebServer) requireAuth(next http.HandlerFunc) http.HandlerFunc { http.Redirect(w, r, "/login", http.StatusFound) return } + info.Username = ws.resolveUser(info.Username) r = r.WithContext(withTokenInfo(r.Context(), info)) next(w, r) } @@ -469,6 +477,10 @@ func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request) } } + for i := range certs { + certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy) + } + data := map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, @@ -625,6 +637,8 @@ func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) { return } + cert.IssuedBy = ws.resolveUser(cert.IssuedBy) + cert.RevokedBy = ws.resolveUser(cert.RevokedBy) ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, @@ -822,6 +836,104 @@ func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) return "", fmt.Errorf("no CA engine mounted") } +func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) { + info := tokenInfoFromContext(r.Context()) + if !info.IsAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + token := extractCookie(r) + rules, err := ws.vault.ListPolicies(r.Context(), token) + if err != nil { + rules = []PolicyRule{} + } + ws.renderTemplate(w, "policy.html", map[string]interface{}{ + "Username": info.Username, + "IsAdmin": info.IsAdmin, + "Rules": rules, + }) +} + +func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) { + info := tokenInfoFromContext(r.Context()) + if !info.IsAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + token := extractCookie(r) + _ = r.ParseForm() + + priorityStr := r.FormValue("priority") + priority := 50 + if priorityStr != "" { + if p, err := strconv.Atoi(priorityStr); err == nil { + priority = p + } + } + + splitCSV := func(s string) []string { + var out []string + for _, v := range strings.Split(s, ",") { + v = strings.TrimSpace(v) + if v != "" { + out = append(out, v) + } + } + return out + } + + rule := PolicyRule{ + ID: r.FormValue("id"), + Priority: priority, + Effect: r.FormValue("effect"), + Usernames: splitCSV(r.FormValue("usernames")), + Roles: splitCSV(r.FormValue("roles")), + Resources: splitCSV(r.FormValue("resources")), + Actions: splitCSV(r.FormValue("actions")), + } + + if rule.ID == "" || rule.Effect == "" { + ws.renderPolicyWithError(w, r, info, token, "ID and effect are required") + return + } + + if _, err := ws.vault.CreatePolicy(r.Context(), token, rule); err != nil { + ws.renderPolicyWithError(w, r, info, token, grpcMessage(err)) + return + } + http.Redirect(w, r, "/policy", http.StatusFound) +} + +func (ws *WebServer) handlePolicyDelete(w http.ResponseWriter, r *http.Request) { + info := tokenInfoFromContext(r.Context()) + if !info.IsAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + token := extractCookie(r) + _ = r.ParseForm() + id := r.FormValue("id") + if id == "" { + http.Redirect(w, r, "/policy", http.StatusFound) + return + } + if err := ws.vault.DeletePolicy(r.Context(), token, id); err != nil { + ws.renderPolicyWithError(w, r, info, token, grpcMessage(err)) + return + } + http.Redirect(w, r, "/policy", http.StatusFound) +} + +func (ws *WebServer) renderPolicyWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, token, errMsg string) { + rules, _ := ws.vault.ListPolicies(r.Context(), token) + ws.renderTemplate(w, "policy.html", map[string]interface{}{ + "Username": info.Username, + "IsAdmin": info.IsAdmin, + "Rules": rules, + "Error": errMsg, + }) +} + // grpcMessage extracts a human-readable message from a gRPC error. func grpcMessage(err error) string { if st, ok := status.FromError(err); ok { diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 00836ac..78d885f 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -15,6 +15,7 @@ import ( "github.com/go-chi/chi/v5" + mcias "git.wntrmute.dev/kyle/mcias/clients/go" "git.wntrmute.dev/kyle/metacrypt/internal/config" webui "git.wntrmute.dev/kyle/metacrypt/web" ) @@ -40,23 +41,69 @@ type vaultBackend interface { ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error) RevokeCert(ctx context.Context, token, mount, serial string) error DeleteCert(ctx context.Context, token, mount, serial string) error + ListPolicies(ctx context.Context, token string) ([]PolicyRule, error) + GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error) + CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error) + DeletePolicy(ctx context.Context, token, id string) error Close() error } +const userCacheTTL = 5 * time.Minute + // tgzEntry holds a cached tgz archive pending download. type tgzEntry struct { filename string data []byte } +// cachedUsername holds a resolved UUID→username entry with an expiry. +type cachedUsername struct { + username string + expiresAt time.Time +} + // WebServer is the standalone web UI server. type WebServer struct { - cfg *config.Config - vault vaultBackend - logger *slog.Logger - httpSrv *http.Server - staticFS fs.FS - tgzCache sync.Map // key: UUID string → *tgzEntry + cfg *config.Config + vault vaultBackend + mcias *mcias.Client // optional; nil when no service_token is configured + logger *slog.Logger + httpSrv *http.Server + staticFS fs.FS + tgzCache sync.Map // key: UUID string → *tgzEntry + userCache sync.Map // key: UUID string → *cachedUsername +} + +// resolveUser returns the display name for a user ID. If the ID is already a +// human-readable username (i.e. not a UUID), it is returned unchanged. When the +// webserver has an MCIAS client configured it will look up unknown IDs and cache +// the result; otherwise the raw ID is returned as a fallback. +func (ws *WebServer) resolveUser(id string) string { + if id == "" { + return id + } + if v, ok := ws.userCache.Load(id); ok { + if entry := v.(*cachedUsername); time.Now().Before(entry.expiresAt) { + ws.logger.Info("webserver: resolved user ID from cache", "id", id, "username", entry.username) + return entry.username + } + } + if ws.mcias == nil { + ws.logger.Warn("webserver: no MCIAS client available, cannot resolve user ID", "id", id) + return id + } + ws.logger.Info("webserver: looking up user ID via MCIAS", "id", id) + acct, err := ws.mcias.GetAccount(id) + if err != nil { + ws.logger.Warn("webserver: failed to resolve user ID", "id", id, "error", err) + return id + } + ws.logger.Info("webserver: resolved user ID", "id", id, "username", acct.Username) + ws.userCache.Store(id, &cachedUsername{ + username: acct.Username, + expiresAt: time.Now().Add(userCacheTTL), + }) + return acct.Username } // New creates a new WebServer. It dials the vault gRPC endpoint. @@ -73,12 +120,35 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) { return nil, fmt.Errorf("webserver: static FS: %w", err) } - return &WebServer{ + ws := &WebServer{ cfg: cfg, vault: vault, logger: logger, staticFS: staticFS, - }, nil + } + + if tok := cfg.MCIAS.ServiceToken; tok != "" { + mc, err := mcias.New(cfg.MCIAS.ServerURL, mcias.Options{ + CACertPath: cfg.MCIAS.CACert, + Token: tok, + }) + if err != nil { + logger.Warn("webserver: failed to create MCIAS client for user resolution", "error", err) + } else { + claims, err := mc.ValidateToken(tok) + switch { + case err != nil: + logger.Warn("webserver: MCIAS service token validation failed", "error", err) + case !claims.Valid: + logger.Warn("webserver: MCIAS service token is invalid or expired") + default: + logger.Info("webserver: MCIAS service token valid", "sub", claims.Sub, "roles", claims.Roles) + ws.mcias = mc + } + } + } + + return ws, nil } // loggingMiddleware logs each incoming HTTP request. diff --git a/web/templates/layout.html b/web/templates/layout.html index 9d17b6a..e1e3396 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -12,6 +12,9 @@ Metacrypt
{{if .Username}} + Dashboard + PKI + {{if .IsAdmin}}Policy{{end}} {{.Username}} {{if .IsAdmin}}admin{{end}} Logout diff --git a/web/templates/policy.html b/web/templates/policy.html new file mode 100644 index 0000000..c0b1759 --- /dev/null +++ b/web/templates/policy.html @@ -0,0 +1,108 @@ +{{define "title"}} - Policy{{end}} +{{define "content"}} + + +{{if .Error}}
{{.Error}}
{{end}} + +
+
Active Rules
+ {{if .Rules}} +
+ + + + + + + + + + + + + + + {{range .Rules}} + + + + + + + + + + + {{end}} + +
IDPriorityEffectUsernamesRolesResourcesActions
{{.ID}}{{.Priority}}{{.Effect}}{{if .Usernames}}{{range $i, $u := .Usernames}}{{if $i}}, {{end}}{{$u}}{{end}}{{else}}any{{end}}{{if .Roles}}{{range $i, $r := .Roles}}{{if $i}}, {{end}}{{$r}}{{end}}{{else}}any{{end}}{{if .Resources}}{{range $i, $r := .Resources}}{{if $i}}, {{end}}{{$r}}{{end}}{{else}}any{{end}}{{if .Actions}}{{range $i, $a := .Actions}}{{if $i}}, {{end}}{{$a}}{{end}}{{else}}any{{end}} +
+ + +
+
+
+ {{else}} +

No policy rules defined. Admins always have full access. Non-admin users are denied by default unless a matching allow rule exists.

+ {{end}} +
+ +
+
Create Rule
+
+ Add a new policy rule +
+
+
+ + +
+
+ + + Lower number = higher priority. Default: 50. +
+
+ + +
+
+
+
+ + + Comma-separated. Leave blank to match any user. +
+
+ + + Comma-separated. Leave blank to match any role. +
+
+
+
+ + + Comma-separated glob patterns. Leave blank to match any resource. +
+
+ + + Comma-separated: read or write. Leave blank to match any action. +
+
+
+ +
+
+
+
+{{end}}