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

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