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