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