Add policy CRUD, cert management, and web UI updates
- Add PUT /v1/policy/rule endpoint for updating policy rules; expose full policy CRUD through the web UI with a dedicated policy page - Add certificate revoke, delete, and get-cert to CA engine and wire REST + gRPC routes; fix missing interceptor registrations - Update ARCHITECTURE.md to reflect v2 gRPC as the active implementation, document ACME endpoints, correct CA permission levels, and add policy/cert management route tables - Add POLICY.md documenting the priority-based ACL engine design - Add web/templates/policy.html for policy management UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"lang":"en","usageCount":40}]
|
||||
[{"lang":"en","usageCount":49}]
|
||||
141
ARCHITECTURE.md
141
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<string, string>`
|
||||
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
|
||||
|
||||
206
POLICY.md
Normal file
206
POLICY.md
Normal file
@@ -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/<mount>/<operation>`. 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 <token>`).
|
||||
|
||||
### 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/<id>`. They are encrypted at rest with the master encryption key
|
||||
and are only accessible when the service is unsealed.
|
||||
2
go.mod
2
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<a class="topnav-brand" href="/">Metacrypt</a>
|
||||
<div class="topnav-right">
|
||||
{{if .Username}}
|
||||
<a href="/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
|
||||
<a href="/pki" class="btn btn-ghost btn-sm">PKI</a>
|
||||
{{if .IsAdmin}}<a href="/policy" class="btn btn-ghost btn-sm">Policy</a>{{end}}
|
||||
<span class="topnav-user">{{.Username}}</span>
|
||||
{{if .IsAdmin}}<span class="badge">admin</span>{{end}}
|
||||
<a href="/login" class="btn btn-ghost btn-sm" onclick="fetch('/v1/auth/logout',{method:'POST'})">Logout</a>
|
||||
|
||||
108
web/templates/policy.html
Normal file
108
web/templates/policy.html
Normal file
@@ -0,0 +1,108 @@
|
||||
{{define "title"}} - Policy{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h2>Policy Rules</h2>
|
||||
<div class="page-meta">
|
||||
<a href="/dashboard" class="btn btn-ghost btn-sm">← Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Active Rules</div>
|
||||
{{if .Rules}}
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Priority</th>
|
||||
<th>Effect</th>
|
||||
<th>Usernames</th>
|
||||
<th>Roles</th>
|
||||
<th>Resources</th>
|
||||
<th>Actions</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rules}}
|
||||
<tr>
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td>{{.Priority}}</td>
|
||||
<td><span class="badge {{if eq .Effect "allow"}}badge-ok{{else}}badge-warn{{end}}">{{.Effect}}</span></td>
|
||||
<td>{{if .Usernames}}{{range $i, $u := .Usernames}}{{if $i}}, {{end}}{{$u}}{{end}}{{else}}<em>any</em>{{end}}</td>
|
||||
<td>{{if .Roles}}{{range $i, $r := .Roles}}{{if $i}}, {{end}}{{$r}}{{end}}{{else}}<em>any</em>{{end}}</td>
|
||||
<td>{{if .Resources}}{{range $i, $r := .Resources}}{{if $i}}, {{end}}<code>{{$r}}</code>{{end}}{{else}}<em>any</em>{{end}}</td>
|
||||
<td>{{if .Actions}}{{range $i, $a := .Actions}}{{if $i}}, {{end}}{{$a}}{{end}}{{else}}<em>any</em>{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/policy/delete" style="display:inline">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete rule {{.ID}}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p>No policy rules defined. Admins always have full access. Non-admin users are denied by default unless a matching allow rule exists.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Create Rule</div>
|
||||
<details>
|
||||
<summary>Add a new policy rule</summary>
|
||||
<form method="post" action="/policy">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="rule_id">Rule ID <span class="required">*</span></label>
|
||||
<input type="text" id="rule_id" name="id" placeholder="allow-users-read" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rule_priority">Priority</label>
|
||||
<input type="number" id="rule_priority" name="priority" value="50" min="0" max="9999">
|
||||
<small>Lower number = higher priority. Default: 50.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rule_effect">Effect <span class="required">*</span></label>
|
||||
<select id="rule_effect" name="effect" required>
|
||||
<option value="allow">allow</option>
|
||||
<option value="deny">deny</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="rule_usernames">Usernames</label>
|
||||
<input type="text" id="rule_usernames" name="usernames" placeholder="alice, bob">
|
||||
<small>Comma-separated. Leave blank to match any user.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rule_roles">Roles</label>
|
||||
<input type="text" id="rule_roles" name="roles" placeholder="user, guest">
|
||||
<small>Comma-separated. Leave blank to match any role.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="rule_resources">Resources</label>
|
||||
<input type="text" id="rule_resources" name="resources" placeholder="engine/pki/*, engine/pki/list-certs">
|
||||
<small>Comma-separated glob patterns. Leave blank to match any resource.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rule_actions">Actions</label>
|
||||
<input type="text" id="rule_actions" name="actions" placeholder="read, write">
|
||||
<small>Comma-separated: <code>read</code> or <code>write</code>. Leave blank to match any action.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Create Rule</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user