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

@@ -637,6 +637,9 @@ func (e *CAEngine) handleListIssuers(_ context.Context, req *engine.Request) (*e
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
e.mu.RLock()
defer e.mu.RUnlock()
@@ -661,6 +664,9 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
issuerName, _ := req.Data["issuer"].(string)
if issuerName == "" {
@@ -810,6 +816,9 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
serial, _ := req.Data["serial"].(string)
if serial == "" {
@@ -858,6 +867,9 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
e.mu.RLock()
defer e.mu.RUnlock()
@@ -902,6 +914,9 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
serial, _ := req.Data["serial"].(string)
if serial == "" {
@@ -1028,6 +1043,9 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
issuerName, _ := req.Data["issuer"].(string)
if issuerName == "" {

View File

@@ -76,6 +76,10 @@ func userCaller() *engine.CallerInfo {
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) {
t.Helper()
b := newMemBarrier()
@@ -166,7 +170,7 @@ func TestInitializeWithImportedRoot(t *testing.T) {
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "imported.example.com",
@@ -313,7 +317,7 @@ func TestIssueCertificate(t *testing.T) {
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
Path: "infra",
CallerInfo: userCaller(),
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "web.example.com",
@@ -375,7 +379,7 @@ func TestIssueCertificateWithOverrides(t *testing.T) {
// Issue with custom TTL and key usages.
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"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) {
eng, b := setupEngine(t)
ctx := context.Background()
@@ -448,7 +615,7 @@ func TestPrivateKeyNotStoredInBarrier(t *testing.T) {
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "test.example.com",
@@ -487,7 +654,7 @@ func TestRenewCertificate(t *testing.T) {
// Issue original cert.
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "renew.example.com",
@@ -504,7 +671,7 @@ func TestRenewCertificate(t *testing.T) {
// Renew.
renewResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "renew",
CallerInfo: userCaller(),
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"serial": origSerial,
},
@@ -545,7 +712,7 @@ func TestGetAndListCerts(t *testing.T) {
for _, cn := range []string{"a.example.com", "b.example.com"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": cn,
@@ -622,7 +789,7 @@ func TestUnsealRestoresIssuers(t *testing.T) {
// Verify we can issue from the restored issuer.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "after-unseal.example.com",

View File

@@ -37,6 +37,19 @@ type CallerInfo struct {
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.
type Request struct {
Data map[string]interface{}