Add CRL endpoint, sign-CSR web route, and policy-based issuance authorization
- Register handleSignCSR route in webserver (was dead code)
- Add GET /v1/pki/{mount}/issuer/{name}/crl REST endpoint and
PKIService.GetCRL gRPC RPC for DER-encoded CRL generation
- Replace admin-only gates on issue/renew/sign-csr with policy-based
access control: admins grant-all, authenticated users subject to
identifier ownership (CN/SANs not held by another user's active cert)
and optional policy overrides via ca/{mount}/id/{identifier} resources
- Add PolicyChecker to engine.Request and policy.Match() method to
distinguish matched rules from default deny
- Update and expand CA engine tests for ownership, revocation freeing,
and policy override scenarios
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -437,7 +437,38 @@ func TestIssueRejectsNilCallerInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueRejectsNonAdmin(t *testing.T) {
|
||||
func TestIssueAllowsUser(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)
|
||||
}
|
||||
|
||||
// Users can issue certs for identifiers not held by others.
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "user-cert.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected user to issue cert, got: %v", err)
|
||||
}
|
||||
if resp.Data["cn"] != "user-cert.example.com" {
|
||||
t.Errorf("cn: got %v", resp.Data["cn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueRejectsGuest(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -452,7 +483,7 @@ func TestIssueRejectsNonAdmin(t *testing.T) {
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: guestCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "test.example.com",
|
||||
@@ -464,7 +495,7 @@ func TestIssueRejectsNonAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewRejectsNonAdmin(t *testing.T) {
|
||||
func TestIssueRejectsIdentifierInUse(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -477,9 +508,139 @@ func TestRenewRejectsNonAdmin(t *testing.T) {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
// User A issues a cert.
|
||||
userA := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userA,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "shared.example.com",
|
||||
"profile": "server",
|
||||
"dns_names": []interface{}{"shared.example.com"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue by alice: %v", err)
|
||||
}
|
||||
|
||||
// User B tries to issue for the same CN — should fail.
|
||||
userB := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userB,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "shared.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrIdentifierInUse) {
|
||||
t.Errorf("expected ErrIdentifierInUse, got: %v", err)
|
||||
}
|
||||
|
||||
// User A can issue again for the same CN (re-issuance by same user).
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userA,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "shared.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("re-issue by alice should succeed: %v", err)
|
||||
}
|
||||
|
||||
// Admin can always issue regardless of ownership.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "shared.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin issue should bypass ownership: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueRevokedCertFreesIdentifier(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)
|
||||
}
|
||||
|
||||
// Alice issues a cert.
|
||||
alice := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: alice,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "reclaim.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
serial := resp.Data["serial"].(string) //nolint:errcheck
|
||||
|
||||
// Admin revokes it.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "revoke-cert",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"serial": serial},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
|
||||
// Bob can now issue for the same CN.
|
||||
bob := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: bob,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "reclaim.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bob should be able to issue after revocation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewOwnership(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)
|
||||
}
|
||||
|
||||
// Alice issues a cert.
|
||||
alice := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}
|
||||
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: alice,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "test.example.com",
|
||||
@@ -492,19 +653,49 @@ func TestRenewRejectsNonAdmin(t *testing.T) {
|
||||
|
||||
serial := issueResp.Data["serial"].(string) //nolint:errcheck
|
||||
|
||||
// Alice can renew her own cert.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"serial": serial,
|
||||
},
|
||||
CallerInfo: alice,
|
||||
Data: map[string]interface{}{"serial": serial},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("alice should renew her own cert: %v", err)
|
||||
}
|
||||
|
||||
// Bob cannot renew Alice's cert.
|
||||
bob := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: bob,
|
||||
Data: map[string]interface{}{"serial": serial},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden, got: %v", err)
|
||||
t.Errorf("expected ErrForbidden for bob renewing alice's cert, got: %v", err)
|
||||
}
|
||||
|
||||
// Guest cannot renew.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: guestCaller(),
|
||||
Data: map[string]interface{}{"serial": serial},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden for guest, got: %v", err)
|
||||
}
|
||||
|
||||
// Admin can always renew.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"serial": serial},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin should renew any cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignCSRRejectsNonAdmin(t *testing.T) {
|
||||
func TestSignCSRRejectsGuest(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -517,18 +708,6 @@ func TestSignCSRRejectsNonAdmin(t *testing.T) {
|
||||
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(),
|
||||
@@ -970,3 +1149,124 @@ func TestPublicMethods(t *testing.T) {
|
||||
t.Errorf("expected ErrIssuerNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuePolicyOverridesOwnership(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)
|
||||
}
|
||||
|
||||
// Alice issues a cert.
|
||||
alice := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: alice,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "shared.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue by alice: %v", err)
|
||||
}
|
||||
|
||||
// Bob normally blocked, but policy allows him.
|
||||
bob := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false}
|
||||
allowPolicy := func(resource, action string) (string, bool) {
|
||||
return "allow", true
|
||||
}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: bob,
|
||||
CheckPolicy: allowPolicy,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "shared.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bob with allow policy should succeed: %v", err)
|
||||
}
|
||||
|
||||
// Policy deny overrides even for free identifiers.
|
||||
denyPolicy := func(resource, action string) (string, bool) {
|
||||
return "deny", true
|
||||
}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: bob,
|
||||
CheckPolicy: denyPolicy,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "unique-for-bob.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("deny policy should reject: got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPolicyOverridesOwnership(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)
|
||||
}
|
||||
|
||||
// Alice issues a cert.
|
||||
alice := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: alice,
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "policy-renew.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
serial := resp.Data["serial"].(string) //nolint:errcheck
|
||||
|
||||
// Bob cannot renew without policy.
|
||||
bob := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: bob,
|
||||
Data: map[string]interface{}{"serial": serial},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden, got: %v", err)
|
||||
}
|
||||
|
||||
// Bob with allow policy can renew.
|
||||
allowPolicy := func(resource, action string) (string, bool) {
|
||||
return "allow", true
|
||||
}
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: bob,
|
||||
CheckPolicy: allowPolicy,
|
||||
Data: map[string]interface{}{"serial": serial},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bob with policy should renew: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user