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:
@@ -269,6 +269,25 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
|
||||
// Evaluate policy before dispatching to the engine.
|
||||
policyReq := &policy.Request{
|
||||
Username: info.Username,
|
||||
Roles: info.Roles,
|
||||
Resource: "engine/" + req.Mount + "/" + req.Operation,
|
||||
Action: operationAction(req.Operation),
|
||||
}
|
||||
effect, err := s.policy.Evaluate(r.Context(), policyReq)
|
||||
if err != nil {
|
||||
s.logger.Error("policy evaluation failed", "error", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if effect != policy.EffectAllow {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
engReq := &engine.Request{
|
||||
Operation: req.Operation,
|
||||
Path: req.Path,
|
||||
@@ -547,6 +566,16 @@ func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
|
||||
return caEng, nil
|
||||
}
|
||||
|
||||
// operationAction maps an engine operation name to a policy action ("read" or "write").
|
||||
func operationAction(op string) string {
|
||||
switch op {
|
||||
case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer":
|
||||
return "read"
|
||||
default:
|
||||
return "write"
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -163,6 +163,100 @@ func TestRootNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func unsealServer(t *testing.T, sealMgr *seal.Manager, _ interface{}) {
|
||||
t.Helper()
|
||||
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
|
||||
if err := sealMgr.Initialize(context.Background(), []byte("password"), params); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func makeEngineRequest(mount, operation string) string {
|
||||
return `{"mount":"` + mount + `","operation":"` + operation + `","data":{}}`
|
||||
}
|
||||
|
||||
func withTokenInfo(r *http.Request, info *auth.TokenInfo) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), tokenInfoKey, info))
|
||||
}
|
||||
|
||||
// TestEngineRequestPolicyDeniesNonAdmin verifies that a non-admin user without
|
||||
// an explicit allow rule is denied by the policy engine.
|
||||
func TestEngineRequestPolicyDeniesNonAdmin(t *testing.T) {
|
||||
srv, sealMgr, _ := setupTestServer(t)
|
||||
unsealServer(t, sealMgr, nil)
|
||||
|
||||
body := makeEngineRequest("pki", "list-issuers")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
|
||||
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleEngineRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 Forbidden for non-admin without policy rule, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineRequestPolicyAllowsAdmin verifies that admin users bypass policy.
|
||||
func TestEngineRequestPolicyAllowsAdmin(t *testing.T) {
|
||||
srv, sealMgr, _ := setupTestServer(t)
|
||||
unsealServer(t, sealMgr, nil)
|
||||
|
||||
body := makeEngineRequest("pki", "list-issuers")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
|
||||
req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleEngineRequest(w, req)
|
||||
|
||||
// Admin bypasses policy; will fail with mount-not-found (404), not forbidden (403).
|
||||
if w.Code == http.StatusForbidden {
|
||||
t.Errorf("admin should not be forbidden by policy, got 403: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineRequestPolicyAllowsWithRule verifies that a non-admin user with an
|
||||
// explicit allow rule is permitted to proceed.
|
||||
func TestEngineRequestPolicyAllowsWithRule(t *testing.T) {
|
||||
srv, sealMgr, _ := setupTestServer(t)
|
||||
unsealServer(t, sealMgr, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
_ = srv.policy.CreateRule(ctx, &policy.Rule{
|
||||
ID: "allow-user-read",
|
||||
Priority: 100,
|
||||
Effect: policy.EffectAllow,
|
||||
Roles: []string{"user"},
|
||||
Resources: []string{"engine/*/*"},
|
||||
Actions: []string{"read"},
|
||||
})
|
||||
|
||||
body := makeEngineRequest("pki", "list-issuers")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
|
||||
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleEngineRequest(w, req)
|
||||
|
||||
// Policy allows; will fail with mount-not-found (404), not forbidden (403).
|
||||
if w.Code == http.StatusForbidden {
|
||||
t.Errorf("user with allow rule should not be forbidden, got 403: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperationAction verifies the read/write classification of operations.
|
||||
func TestOperationAction(t *testing.T) {
|
||||
readOps := []string{"list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer"}
|
||||
for _, op := range readOps {
|
||||
if got := operationAction(op); got != "read" {
|
||||
t.Errorf("operationAction(%q) = %q, want %q", op, got, "read")
|
||||
}
|
||||
}
|
||||
writeOps := []string{"issue", "renew", "create-issuer", "delete-issuer", "sign-csr", "revoke"}
|
||||
for _, op := range writeOps {
|
||||
if got := operationAction(op); got != "write" {
|
||||
t.Errorf("operationAction(%q) = %q, want %q", op, got, "write")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenInfoFromContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if info := TokenInfoFromContext(ctx); info != nil {
|
||||
|
||||
Reference in New Issue
Block a user