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"
|
"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
|
policy/ Priority-based ACL engine
|
||||||
engine/ Pluggable engine registry & interface
|
engine/ Pluggable engine registry & interface
|
||||||
ca/ CA (PKI) engine — X.509 certificate issuance
|
ca/ CA (PKI) engine — X.509 certificate issuance
|
||||||
|
acme/ ACME protocol handler (RFC 8555); EAB, accounts, orders
|
||||||
server/ REST API HTTP server, routes, middleware
|
server/ REST API HTTP server, routes, middleware
|
||||||
grpcserver/ gRPC server, interceptors, per-service handlers
|
grpcserver/ gRPC server, interceptors, per-service handlers
|
||||||
webserver/ Web UI HTTP server, routes, HTMX handlers
|
webserver/ Web UI HTTP server, routes, HTMX handlers
|
||||||
@@ -78,6 +79,7 @@ proto/metacrypt/
|
|||||||
v1/ Original gRPC proto definitions (generic Execute RPC)
|
v1/ Original gRPC proto definitions (generic Execute RPC)
|
||||||
v2/ Typed gRPC proto definitions (per-operation RPCs, Timestamp fields)
|
v2/ Typed gRPC proto definitions (per-operation RPCs, Timestamp fields)
|
||||||
gen/metacrypt/v1/ Generated Go gRPC/protobuf code (v1)
|
gen/metacrypt/v1/ Generated Go gRPC/protobuf code (v1)
|
||||||
|
gen/metacrypt/v2/ Generated Go gRPC/protobuf code (v2)
|
||||||
web/
|
web/
|
||||||
templates/ Go HTML templates (layout, init, unseal, login, dashboard, PKI)
|
templates/ Go HTML templates (layout, init, unseal, login, dashboard, PKI)
|
||||||
static/ CSS, HTMX
|
static/ CSS, HTMX
|
||||||
@@ -384,10 +386,10 @@ Certificate generation uses the `certgen` package from
|
|||||||
| `create-issuer` | Admin | Generate intermediate CA signed by root |
|
| `create-issuer` | Admin | Generate intermediate CA signed by root |
|
||||||
| `delete-issuer` | Admin | Remove issuer and zeroize its key |
|
| `delete-issuer` | Admin | Remove issuer and zeroize its key |
|
||||||
| `list-issuers` | Any auth | List issuer names |
|
| `list-issuers` | Any auth | List issuer names |
|
||||||
| `issue` | User/Admin | Issue leaf cert from named issuer |
|
| `issue` | Admin | Issue leaf cert from named issuer |
|
||||||
| `get-cert` | Any auth | Get cert record by serial |
|
| `get-cert` | User/Admin | Get cert record by serial |
|
||||||
| `list-certs` | Any auth | List issued cert summaries |
|
| `list-certs` | User/Admin | List issued cert summaries |
|
||||||
| `renew` | User/Admin | Re-issue cert with same attributes |
|
| `renew` | Admin | Re-issue cert with same attributes |
|
||||||
|
|
||||||
#### Certificate Profiles
|
#### 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/unmount` | Remove engine mount | Admin |
|
||||||
| POST | `/v1/engine/request` | Route request to engine | User |
|
| 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
|
The mount endpoint accepts `{name, type, config}` where `config` is an
|
||||||
engine-type-specific configuration object. The request endpoint accepts
|
engine-type-specific configuration object. The request endpoint accepts
|
||||||
`{mount, operation, path, data}` and populates `CallerInfo` from the
|
`{mount, operation, path, data}` and populates `CallerInfo` from the
|
||||||
@@ -482,21 +494,56 @@ authenticated user's token.
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|-------------------------------------|-------------------------------|
|
|--------|-------------------------------------|-------------------------------|
|
||||||
| GET | `/v1/pki/{mount}/ca` | Root CA certificate (PEM) |
|
| 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) |
|
| GET | `/v1/pki/{mount}/issuer/{name}` | Issuer certificate (PEM) |
|
||||||
|
|
||||||
These routes serve certificates with `Content-Type: application/x-pem-file`,
|
These routes serve certificates with `Content-Type: application/x-pem-file`,
|
||||||
allowing systems to bootstrap TLS trust without authentication. The mount
|
allowing systems to bootstrap TLS trust without authentication. The mount
|
||||||
must be of type `ca`; returns 404 otherwise.
|
must be of type `ca`; returns 404 otherwise.
|
||||||
|
|
||||||
### Policy (Admin Only)
|
### CA Certificate Management (Authenticated)
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description | Auth |
|
||||||
|--------|-----------------------|---------------------|
|
|--------|---------------------------------------|------------------------------------|---------|
|
||||||
| GET | `/v1/policy/rules` | List all rules |
|
| GET | `/v1/ca/{mount}/cert/{serial}` | Get certificate record by serial | User |
|
||||||
| POST | `/v1/policy/rules` | Create a rule |
|
| POST | `/v1/ca/{mount}/cert/{serial}/revoke` | Revoke a certificate | Admin |
|
||||||
| GET | `/v1/policy/rule?id=` | Get rule by ID |
|
| DELETE | `/v1/ca/{mount}/cert/{serial}` | Delete a certificate record | Admin |
|
||||||
| DELETE | `/v1/policy/rule?id=` | Delete rule by ID |
|
|
||||||
|
### 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
|
### Error Responses
|
||||||
|
|
||||||
@@ -517,9 +564,11 @@ HTTP status codes:
|
|||||||
Metacrypt also exposes a gRPC API defined in `proto/metacrypt/`. Two API
|
Metacrypt also exposes a gRPC API defined in `proto/metacrypt/`. Two API
|
||||||
versions exist:
|
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
|
```protobuf
|
||||||
rpc Execute(ExecuteRequest) returns (ExecuteResponse);
|
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
|
The `EngineService` also provides `Mount`, `Unmount`, and `ListMounts` RPCs
|
||||||
for engine lifecycle management.
|
for engine lifecycle management.
|
||||||
|
|
||||||
#### v2 (defined, not yet implemented)
|
#### v2 (implemented)
|
||||||
|
|
||||||
The v2 API (`proto/metacrypt/v2/`) replaces the generic `Execute` RPC with
|
The v2 API (`proto/metacrypt/v2/`) replaces the generic `Execute` RPC with
|
||||||
strongly-typed, per-operation RPCs and uses `google.protobuf.Timestamp` for
|
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`,
|
`DeleteIssuer`, `ListIssuers`, `GetIssuer`, `GetChain`, `IssueCert`,
|
||||||
`GetCert`, `ListCerts`, `RenewCert`.
|
`GetCert`, `ListCerts`, `RenewCert`, `SignCSR`, `RevokeCert`, `DeleteCert`.
|
||||||
- **`EngineService`**: Retains `Mount`, `Unmount`, `ListMounts`; drops the
|
- **`EngineService`**: Retains `Mount`, `Unmount`, `ListMounts`; drops the
|
||||||
generic `Execute` RPC. `MountRequest.config` is `map<string, string>`
|
generic `Execute` RPC. `MountRequest.config` is `map<string, string>`
|
||||||
instead of `google.protobuf.Struct`.
|
instead of `google.protobuf.Struct`.
|
||||||
@@ -556,13 +606,13 @@ all time fields. Key changes:
|
|||||||
`google.protobuf.Timestamp` instead of RFC3339 strings.
|
`google.protobuf.Timestamp` instead of RFC3339 strings.
|
||||||
- **Message types**: `CertRecord` (full certificate data) and `CertSummary`
|
- **Message types**: `CertRecord` (full certificate data) and `CertSummary`
|
||||||
(lightweight, for list responses) replace the generic struct maps.
|
(lightweight, for list responses) replace the generic struct maps.
|
||||||
- **`ACMEService`** and **`AuthService`**: String timestamps replaced by
|
- **`ACMEService`**: `CreateEAB`, `SetConfig`, `ListAccounts`, `ListOrders`.
|
||||||
`google.protobuf.Timestamp`.
|
- **`AuthService`**: String timestamps replaced by `google.protobuf.Timestamp`.
|
||||||
|
|
||||||
The v2 proto definitions pass `buf lint` with no warnings. Server-side
|
The v2 proto definitions pass `buf lint` with no warnings. Generated Go code
|
||||||
implementation of v2 is planned as a future milestone.
|
lives in `gen/metacrypt/v2/`.
|
||||||
|
|
||||||
#### gRPC Interceptors (v1)
|
#### gRPC Interceptors (v2)
|
||||||
|
|
||||||
The gRPC server (`internal/grpcserver/`) uses three interceptor maps to gate
|
The gRPC server (`internal/grpcserver/`) uses three interceptor maps to gate
|
||||||
access:
|
access:
|
||||||
@@ -573,8 +623,9 @@ access:
|
|||||||
| `authRequiredMethods` | Validates MCIAS bearer token; populates caller info |
|
| `authRequiredMethods` | Validates MCIAS bearer token; populates caller info |
|
||||||
| `adminRequiredMethods`| Requires `IsAdmin == true` on the caller |
|
| `adminRequiredMethods`| Requires `IsAdmin == true` on the caller |
|
||||||
|
|
||||||
All three maps include the `Execute` RPC, ensuring engine operations are
|
All CA write operations, engine lifecycle RPCs, policy mutations, and ACME
|
||||||
always authenticated and gated on unseal state.
|
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:
|
Metacrypt includes an HTMX-powered web UI for basic operations:
|
||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
|--------------------------|-------------------------------------------------------|
|
|-------------------------------|-------------------------------------------------------|
|
||||||
| `/` | Redirects based on service state |
|
| `/` | Redirects based on service state |
|
||||||
| `/init` | Password setup form (first-time only) |
|
| `/init` | Password setup form (first-time only) |
|
||||||
| `/unseal` | Password entry to unseal |
|
| `/unseal` | Password entry to unseal |
|
||||||
| `/login` | MCIAS login form (username, password, TOTP) |
|
| `/login` | MCIAS login form (username, password, TOTP) |
|
||||||
| `/dashboard` | Engine mounts, service state, admin controls |
|
| `/dashboard` | Engine mounts, service state, admin controls |
|
||||||
| `/pki` | PKI overview: list issuers, download CA/issuer PEMs |
|
| `/dashboard/mount-ca` | Mount a new CA engine (POST, admin) |
|
||||||
| `/pki/issuer/{name}` | Issuer detail: certificates issued by that issuer |
|
| `/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
|
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
|
button. Templates use Go's `html/template` with a shared layout. HTMX provides
|
||||||
form submission without full page reloads.
|
form submission without full page reloads.
|
||||||
|
|
||||||
The PKI pages communicate with the backend via the internal gRPC client
|
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
|
The issuer detail page supports filtering certificates by common name
|
||||||
(case-insensitive substring match) and sorting by common name (default) or
|
(case-insensitive substring match) and sorting by common name (default) or
|
||||||
expiry date.
|
expiry date.
|
||||||
@@ -649,9 +711,15 @@ TOML configuration with environment variable overrides (`METACRYPT_*`).
|
|||||||
```toml
|
```toml
|
||||||
[server]
|
[server]
|
||||||
listen_addr = ":8443" # required
|
listen_addr = ":8443" # required
|
||||||
|
grpc_addr = ":8444" # optional; gRPC server disabled if unset
|
||||||
tls_cert = "/path/cert.pem" # required
|
tls_cert = "/path/cert.pem" # required
|
||||||
tls_key = "/path/key.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]
|
[database]
|
||||||
path = "/path/metacrypt.db" # required
|
path = "/path/metacrypt.db" # required
|
||||||
|
|
||||||
@@ -764,9 +832,6 @@ closing connections before exit.
|
|||||||
|
|
||||||
### Planned Capabilities
|
### 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
|
- **Post-quantum readiness** — Hybrid key exchange (ML-KEM + ECDH); the
|
||||||
versioned ciphertext format and engine interface are designed for algorithm
|
versioned ciphertext format and engine interface are designed for algorithm
|
||||||
agility
|
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
|
replace git.wntrmute.dev/kyle/goutils => /Users/kyle/src/goutils
|
||||||
|
|
||||||
require (
|
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
|
git.wntrmute.dev/kyle/mcias/clients/go v0.0.0-00010101000000-000000000000
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ type DatabaseConfig struct {
|
|||||||
|
|
||||||
// MCIASConfig holds MCIAS integration settings.
|
// MCIASConfig holds MCIAS integration settings.
|
||||||
type MCIASConfig struct {
|
type MCIASConfig struct {
|
||||||
ServerURL string `toml:"server_url"`
|
ServerURL string `toml:"server_url"`
|
||||||
CACert string `toml:"ca_cert"`
|
CACert string `toml:"ca_cert"`
|
||||||
|
ServiceToken string `toml:"service_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SealConfig holds Argon2id parameters for the seal process.
|
// 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 {
|
if req.CallerInfo == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
if !req.CallerInfo.IsUser() {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
@@ -661,6 +664,9 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
|
|||||||
if req.CallerInfo == nil {
|
if req.CallerInfo == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
if !req.CallerInfo.IsAdmin {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
issuerName, _ := req.Data["issuer"].(string)
|
issuerName, _ := req.Data["issuer"].(string)
|
||||||
if issuerName == "" {
|
if issuerName == "" {
|
||||||
@@ -810,6 +816,9 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng
|
|||||||
if req.CallerInfo == nil {
|
if req.CallerInfo == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
if !req.CallerInfo.IsUser() {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
serial, _ := req.Data["serial"].(string)
|
serial, _ := req.Data["serial"].(string)
|
||||||
if serial == "" {
|
if serial == "" {
|
||||||
@@ -858,6 +867,9 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e
|
|||||||
if req.CallerInfo == nil {
|
if req.CallerInfo == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
if !req.CallerInfo.IsUser() {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
@@ -902,6 +914,9 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
|
|||||||
if req.CallerInfo == nil {
|
if req.CallerInfo == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
if !req.CallerInfo.IsAdmin {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
serial, _ := req.Data["serial"].(string)
|
serial, _ := req.Data["serial"].(string)
|
||||||
if serial == "" {
|
if serial == "" {
|
||||||
@@ -1028,6 +1043,9 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
|
|||||||
if req.CallerInfo == nil {
|
if req.CallerInfo == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
if !req.CallerInfo.IsAdmin {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
issuerName, _ := req.Data["issuer"].(string)
|
issuerName, _ := req.Data["issuer"].(string)
|
||||||
if issuerName == "" {
|
if issuerName == "" {
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ func userCaller() *engine.CallerInfo {
|
|||||||
return &engine.CallerInfo{Username: "user", Roles: []string{"user"}, IsAdmin: false}
|
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) {
|
func setupEngine(t *testing.T) (*CAEngine, *memBarrier) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b := newMemBarrier()
|
b := newMemBarrier()
|
||||||
@@ -166,7 +170,7 @@ func TestInitializeWithImportedRoot(t *testing.T) {
|
|||||||
|
|
||||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||||
Operation: "issue",
|
Operation: "issue",
|
||||||
CallerInfo: userCaller(),
|
CallerInfo: adminCaller(),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"issuer": "infra",
|
"issuer": "infra",
|
||||||
"common_name": "imported.example.com",
|
"common_name": "imported.example.com",
|
||||||
@@ -313,7 +317,7 @@ func TestIssueCertificate(t *testing.T) {
|
|||||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||||
Operation: "issue",
|
Operation: "issue",
|
||||||
Path: "infra",
|
Path: "infra",
|
||||||
CallerInfo: userCaller(),
|
CallerInfo: adminCaller(),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"issuer": "infra",
|
"issuer": "infra",
|
||||||
"common_name": "web.example.com",
|
"common_name": "web.example.com",
|
||||||
@@ -375,7 +379,7 @@ func TestIssueCertificateWithOverrides(t *testing.T) {
|
|||||||
// Issue with custom TTL and key usages.
|
// Issue with custom TTL and key usages.
|
||||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||||
Operation: "issue",
|
Operation: "issue",
|
||||||
CallerInfo: userCaller(),
|
CallerInfo: adminCaller(),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"issuer": "infra",
|
"issuer": "infra",
|
||||||
"common_name": "peer.example.com",
|
"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) {
|
func TestPrivateKeyNotStoredInBarrier(t *testing.T) {
|
||||||
eng, b := setupEngine(t)
|
eng, b := setupEngine(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -448,7 +615,7 @@ func TestPrivateKeyNotStoredInBarrier(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||||
Operation: "issue",
|
Operation: "issue",
|
||||||
CallerInfo: userCaller(),
|
CallerInfo: adminCaller(),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"issuer": "infra",
|
"issuer": "infra",
|
||||||
"common_name": "test.example.com",
|
"common_name": "test.example.com",
|
||||||
@@ -487,7 +654,7 @@ func TestRenewCertificate(t *testing.T) {
|
|||||||
// Issue original cert.
|
// Issue original cert.
|
||||||
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
|
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||||
Operation: "issue",
|
Operation: "issue",
|
||||||
CallerInfo: userCaller(),
|
CallerInfo: adminCaller(),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"issuer": "infra",
|
"issuer": "infra",
|
||||||
"common_name": "renew.example.com",
|
"common_name": "renew.example.com",
|
||||||
@@ -504,7 +671,7 @@ func TestRenewCertificate(t *testing.T) {
|
|||||||
// Renew.
|
// Renew.
|
||||||
renewResp, err := eng.HandleRequest(ctx, &engine.Request{
|
renewResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||||
Operation: "renew",
|
Operation: "renew",
|
||||||
CallerInfo: userCaller(),
|
CallerInfo: adminCaller(),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"serial": origSerial,
|
"serial": origSerial,
|
||||||
},
|
},
|
||||||
@@ -545,7 +712,7 @@ func TestGetAndListCerts(t *testing.T) {
|
|||||||
for _, cn := range []string{"a.example.com", "b.example.com"} {
|
for _, cn := range []string{"a.example.com", "b.example.com"} {
|
||||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||||
Operation: "issue",
|
Operation: "issue",
|
||||||
CallerInfo: userCaller(),
|
CallerInfo: adminCaller(),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"issuer": "infra",
|
"issuer": "infra",
|
||||||
"common_name": cn,
|
"common_name": cn,
|
||||||
@@ -622,7 +789,7 @@ func TestUnsealRestoresIssuers(t *testing.T) {
|
|||||||
// Verify we can issue from the restored issuer.
|
// Verify we can issue from the restored issuer.
|
||||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||||
Operation: "issue",
|
Operation: "issue",
|
||||||
CallerInfo: userCaller(),
|
CallerInfo: adminCaller(),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"issuer": "infra",
|
"issuer": "infra",
|
||||||
"common_name": "after-unseal.example.com",
|
"common_name": "after-unseal.example.com",
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ type CallerInfo struct {
|
|||||||
IsAdmin bool
|
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.
|
// Request is a request to an engine.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Data map[string]interface{}
|
Data map[string]interface{}
|
||||||
|
|||||||
@@ -269,6 +269,25 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info := TokenInfoFromContext(r.Context())
|
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{
|
engReq := &engine.Request{
|
||||||
Operation: req.Operation,
|
Operation: req.Operation,
|
||||||
Path: req.Path,
|
Path: req.Path,
|
||||||
@@ -547,6 +566,16 @@ func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
|
|||||||
return caEng, nil
|
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{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
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) {
|
func TestTokenInfoFromContext(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if info := TokenInfoFromContext(ctx); info != nil {
|
if info := TokenInfoFromContext(ctx); info != nil {
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ func (m *mockVault) DeleteCert(ctx context.Context, token, mount, serial string)
|
|||||||
return nil
|
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 }
|
func (m *mockVault) Close() error { return nil }
|
||||||
|
|
||||||
// ---- handleTGZDownload tests ----
|
// ---- handleTGZDownload tests ----
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type VaultClient struct {
|
|||||||
engine pb.EngineServiceClient
|
engine pb.EngineServiceClient
|
||||||
pki pb.PKIServiceClient
|
pki pb.PKIServiceClient
|
||||||
ca pb.CAServiceClient
|
ca pb.CAServiceClient
|
||||||
|
policy pb.PolicyServiceClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVaultClient dials the vault gRPC server and returns a client.
|
// 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),
|
engine: pb.NewEngineServiceClient(conn),
|
||||||
pki: pb.NewPKIServiceClient(conn),
|
pki: pb.NewPKIServiceClient(conn),
|
||||||
ca: pb.NewCAServiceClient(conn),
|
ca: pb.NewCAServiceClient(conn),
|
||||||
|
policy: pb.NewPolicyServiceClient(conn),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +381,85 @@ func (c *VaultClient) DeleteCert(ctx context.Context, token, mount, serial strin
|
|||||||
return err
|
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.
|
// CertSummary holds lightweight certificate metadata for list views.
|
||||||
type CertSummary struct {
|
type CertSummary struct {
|
||||||
Serial string
|
Serial string
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,6 +39,12 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
|
|||||||
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
|
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
|
||||||
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
|
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.Route("/pki", func(r chi.Router) {
|
||||||
r.Get("/", ws.requireAuth(ws.handlePKI))
|
r.Get("/", ws.requireAuth(ws.handlePKI))
|
||||||
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
|
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)
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
info.Username = ws.resolveUser(info.Username)
|
||||||
r = r.WithContext(withTokenInfo(r.Context(), info))
|
r = r.WithContext(withTokenInfo(r.Context(), info))
|
||||||
next(w, r)
|
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{}{
|
data := map[string]interface{}{
|
||||||
"Username": info.Username,
|
"Username": info.Username,
|
||||||
"IsAdmin": info.IsAdmin,
|
"IsAdmin": info.IsAdmin,
|
||||||
@@ -625,6 +637,8 @@ func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cert.IssuedBy = ws.resolveUser(cert.IssuedBy)
|
||||||
|
cert.RevokedBy = ws.resolveUser(cert.RevokedBy)
|
||||||
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{
|
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{
|
||||||
"Username": info.Username,
|
"Username": info.Username,
|
||||||
"IsAdmin": info.IsAdmin,
|
"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")
|
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.
|
// grpcMessage extracts a human-readable message from a gRPC error.
|
||||||
func grpcMessage(err error) string {
|
func grpcMessage(err error) string {
|
||||||
if st, ok := status.FromError(err); ok {
|
if st, ok := status.FromError(err); ok {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||||
webui "git.wntrmute.dev/kyle/metacrypt/web"
|
webui "git.wntrmute.dev/kyle/metacrypt/web"
|
||||||
)
|
)
|
||||||
@@ -40,23 +41,69 @@ type vaultBackend interface {
|
|||||||
ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error)
|
ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error)
|
||||||
RevokeCert(ctx context.Context, token, mount, serial string) error
|
RevokeCert(ctx context.Context, token, mount, serial string) error
|
||||||
DeleteCert(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
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
// tgzEntry holds a cached tgz archive pending download.
|
// tgzEntry holds a cached tgz archive pending download.
|
||||||
type tgzEntry struct {
|
type tgzEntry struct {
|
||||||
filename string
|
filename string
|
||||||
data []byte
|
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.
|
// WebServer is the standalone web UI server.
|
||||||
type WebServer struct {
|
type WebServer struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
vault vaultBackend
|
vault vaultBackend
|
||||||
logger *slog.Logger
|
mcias *mcias.Client // optional; nil when no service_token is configured
|
||||||
httpSrv *http.Server
|
logger *slog.Logger
|
||||||
staticFS fs.FS
|
httpSrv *http.Server
|
||||||
tgzCache sync.Map // key: UUID string → *tgzEntry
|
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.
|
// 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 nil, fmt.Errorf("webserver: static FS: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &WebServer{
|
ws := &WebServer{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
vault: vault,
|
vault: vault,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
staticFS: staticFS,
|
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.
|
// loggingMiddleware logs each incoming HTTP request.
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<a class="topnav-brand" href="/">Metacrypt</a>
|
<a class="topnav-brand" href="/">Metacrypt</a>
|
||||||
<div class="topnav-right">
|
<div class="topnav-right">
|
||||||
{{if .Username}}
|
{{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>
|
<span class="topnav-user">{{.Username}}</span>
|
||||||
{{if .IsAdmin}}<span class="badge">admin</span>{{end}}
|
{{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>
|
<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