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:
2026-03-15 19:41:11 -07:00
parent 02ee538213
commit fbd6d1af04
17 changed files with 1055 additions and 58 deletions

View File

@@ -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 ----

View File

@@ -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

View File

@@ -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 {

View File

@@ -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.