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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user