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:
2026-03-16 15:22:04 -07:00
parent fbd6d1af04
commit ac4577f778
11 changed files with 810 additions and 68 deletions

View File

@@ -6,12 +6,14 @@ import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"strings"
"sync"
@@ -24,13 +26,14 @@ import (
)
var (
ErrSealed = errors.New("ca: engine is sealed")
ErrIssuerNotFound = errors.New("ca: issuer not found")
ErrIssuerExists = errors.New("ca: issuer already exists")
ErrCertNotFound = errors.New("ca: certificate not found")
ErrUnknownProfile = errors.New("ca: unknown profile")
ErrForbidden = errors.New("ca: forbidden")
ErrUnauthorized = errors.New("ca: authentication required")
ErrSealed = errors.New("ca: engine is sealed")
ErrIssuerNotFound = errors.New("ca: issuer not found")
ErrIssuerExists = errors.New("ca: issuer already exists")
ErrCertNotFound = errors.New("ca: certificate not found")
ErrUnknownProfile = errors.New("ca: unknown profile")
ErrForbidden = errors.New("ca: forbidden")
ErrUnauthorized = errors.New("ca: authentication required")
ErrIdentifierInUse = errors.New("ca: identifier already issued to another user")
)
// issuerState holds in-memory state for a loaded issuer.
@@ -360,6 +363,155 @@ func (e *CAEngine) GetChainPEM(issuerName string) ([]byte, error) {
return chain, nil
}
// GetCRLDER generates and returns a DER-encoded CRL for the named issuer,
// covering all revoked leaf certificates signed by that issuer.
func (e *CAEngine) GetCRLDER(ctx context.Context, issuerName string) ([]byte, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.rootCert == nil {
return nil, ErrSealed
}
is, ok := e.issuers[issuerName]
if !ok {
return nil, ErrIssuerNotFound
}
// Scan all cert records for revoked certs issued by this issuer.
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
if err != nil {
return nil, fmt.Errorf("ca: list certs for CRL: %w", err)
}
var entries []x509.RevocationListEntry
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p)
if err != nil {
continue
}
var record CertRecord
if err := json.Unmarshal(data, &record); err != nil {
continue
}
if !record.Revoked || record.Issuer != issuerName {
continue
}
serial := new(big.Int)
serial.SetString(record.Serial, 16)
entries = append(entries, x509.RevocationListEntry{
SerialNumber: serial,
RevocationTime: record.RevokedAt,
})
}
template := &x509.RevocationList{
Number: big.NewInt(time.Now().Unix()),
ThisUpdate: time.Now(),
NextUpdate: time.Now().Add(24 * time.Hour),
RevokedCertificateEntries: entries,
}
signer, ok2 := is.key.(crypto.Signer)
if !ok2 {
return nil, fmt.Errorf("ca: issuer key does not implement crypto.Signer")
}
return x509.CreateRevocationList(rand.Reader, template, is.cert, signer)
}
// mountName extracts the user-facing mount name from the mount path.
// Mount paths are "engine/{type}/{name}/".
func (e *CAEngine) mountName() string {
parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/")
if len(parts) >= 3 {
return parts[2]
}
return ""
}
// authorizeIssuance checks whether the caller may issue a cert with the given
// identifiers (CN + SANs). For each identifier:
// 1. If a policy rule explicitly allows or denies it, that decision wins.
// 2. If no policy covers it, the default ownership rule applies: the identifier
// must not be held by an active cert issued by another user.
//
// Caller must hold e.mu (at least RLock).
func (e *CAEngine) authorizeIssuance(ctx context.Context, req *engine.Request, cn string, sans []string) error {
mountName := e.mountName()
var needOwnershipCheck []string
for _, id := range append([]string{cn}, sans...) {
if id == "" {
continue
}
if req.CheckPolicy != nil {
resource := "ca/" + mountName + "/id/" + id
effect, matched := req.CheckPolicy(resource, "write")
if matched {
if effect == "deny" {
return ErrForbidden
}
continue // policy explicitly allows
}
}
needOwnershipCheck = append(needOwnershipCheck, id)
}
if len(needOwnershipCheck) == 0 {
return nil
}
return e.checkIdentifierOwnership(ctx, needOwnershipCheck, req.CallerInfo.Username)
}
// checkIdentifierOwnership scans all active (non-revoked, non-expired) cert
// records and returns ErrIdentifierInUse if any of the given identifiers are
// held by a cert issued by a different user.
//
// Caller must hold e.mu (at least RLock).
func (e *CAEngine) checkIdentifierOwnership(ctx context.Context, identifiers []string, username string) error {
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
if err != nil {
return fmt.Errorf("ca: list certs for ownership check: %w", err)
}
now := time.Now()
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p)
if err != nil {
continue
}
var record CertRecord
if err := json.Unmarshal(data, &record); err != nil {
continue
}
if record.Revoked || now.After(record.ExpiresAt) {
continue
}
if strings.EqualFold(record.IssuedBy, username) {
continue
}
// Check for overlap with the requested identifiers.
held := make(map[string]bool)
held[strings.ToLower(record.CN)] = true
for _, san := range record.SANs {
held[strings.ToLower(san)] = true
}
for _, id := range identifiers {
if held[strings.ToLower(id)] {
return fmt.Errorf("%w: %s", ErrIdentifierInUse, id)
}
}
}
return nil
}
// --- Operation handlers ---
func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (*engine.Response, error) {
@@ -664,7 +816,7 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
@@ -686,6 +838,16 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
return nil, fmt.Errorf("ca: common_name is required")
}
// Parse SANs.
var dnsNames []string
var ipAddrs []string
if v, ok := req.Data["dns_names"].([]interface{}); ok {
dnsNames = toStringSlice(v)
}
if v, ok := req.Data["ip_addresses"].([]interface{}); ok {
ipAddrs = toStringSlice(v)
}
e.mu.Lock()
defer e.mu.Unlock()
@@ -698,6 +860,14 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
return nil, ErrIssuerNotFound
}
// Authorization: admins bypass all issuance checks.
if !req.CallerInfo.IsAdmin {
sans := append(dnsNames, ipAddrs...)
if err := e.authorizeIssuance(ctx, req, cn, sans); err != nil {
return nil, err
}
}
profile, ok := GetProfile(profileName)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
@@ -724,16 +894,6 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
keySize = int(v)
}
// Parse SANs.
var dnsNames []string
var ipAddrs []string
if v, ok := req.Data["dns_names"].([]interface{}); ok {
dnsNames = toStringSlice(v)
}
if v, ok := req.Data["ip_addresses"].([]interface{}); ok {
ipAddrs = toStringSlice(v)
}
// Generate leaf key pair and CSR.
ks := certgen.KeySpec{Algorithm: keyAlg, Size: keySize}
_, leafKey, err := ks.Generate()
@@ -914,7 +1074,7 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
@@ -947,6 +1107,24 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
return nil, fmt.Errorf("ca: parse cert record: %w", err)
}
// Authorization: admins bypass; otherwise check policy then ownership.
if !req.CallerInfo.IsAdmin {
allowed := false
if req.CheckPolicy != nil {
resource := "ca/" + e.mountName() + "/id/" + serial
effect, matched := req.CheckPolicy(resource, "write")
if matched {
if effect == "deny" {
return nil, ErrForbidden
}
allowed = true
}
}
if !allowed && !strings.EqualFold(record.IssuedBy, req.CallerInfo.Username) {
return nil, ErrForbidden
}
}
// Look up issuer.
is, ok := e.issuers[record.Issuer]
if !ok {
@@ -1043,7 +1221,7 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
@@ -1097,6 +1275,14 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
return nil, ErrIssuerNotFound
}
// Authorization: admins bypass; otherwise check identifiers from the CSR.
if !req.CallerInfo.IsAdmin {
sans := append(csr.DNSNames, ipStrings(csr.IPAddresses)...)
if err := e.authorizeIssuance(ctx, req, csr.Subject.CommonName, sans); err != nil {
return nil, err
}
}
leafCert, err := profile.SignRequest(is.cert, csr, is.key)
if err != nil {
return nil, fmt.Errorf("ca: sign CSR: %w", err)

View File

@@ -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)
}
}

View File

@@ -50,12 +50,18 @@ func (c *CallerInfo) IsUser() bool {
return false
}
// PolicyChecker evaluates whether the caller has access to a specific resource.
// Returns the policy effect ("allow" or "deny") and whether a matching rule was found.
// When matched is false, the caller should fall back to default access rules.
type PolicyChecker func(resource, action string) (effect string, matched bool)
// Request is a request to an engine.
type Request struct {
Data map[string]interface{}
CallerInfo *CallerInfo
Operation string
Path string
Data map[string]interface{}
CallerInfo *CallerInfo
CheckPolicy PolicyChecker
Operation string
Path string
}
// Response is a response from an engine.

View File

@@ -13,6 +13,7 @@ import (
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
)
type caServer struct {
@@ -35,6 +36,8 @@ func (cs *caServer) caHandleRequest(ctx context.Context, mount, operation string
st = codes.NotFound
case errors.Is(err, ca.ErrIssuerExists):
st = codes.AlreadyExists
case errors.Is(err, ca.ErrIdentifierInUse):
st = codes.AlreadyExists
case errors.Is(err, ca.ErrUnauthorized):
st = codes.Unauthenticated
case errors.Is(err, ca.ErrForbidden):
@@ -68,6 +71,26 @@ func (cs *caServer) callerInfo(ctx context.Context) *engine.CallerInfo {
}
}
func (cs *caServer) policyChecker(ctx context.Context) engine.PolicyChecker {
caller := cs.callerInfo(ctx)
if caller == nil {
return nil
}
return func(resource, action string) (string, bool) {
pReq := &policy.Request{
Username: caller.Username,
Roles: caller.Roles,
Resource: resource,
Action: action,
}
effect, matched, err := cs.s.policy.Match(ctx, pReq)
if err != nil {
return string(policy.EffectDeny), false
}
return string(effect), matched
}
}
func (cs *caServer) ImportRoot(ctx context.Context, req *pb.ImportRootRequest) (*pb.ImportRootResponse, error) {
if req.Mount == "" {
return nil, status.Error(codes.InvalidArgument, "mount is required")
@@ -260,9 +283,10 @@ func (cs *caServer) IssueCert(ctx context.Context, req *pb.IssueCertRequest) (*p
}
resp, err := cs.caHandleRequest(ctx, req.Mount, "issue", &engine.Request{
Operation: "issue",
CallerInfo: cs.callerInfo(ctx),
Data: data,
Operation: "issue",
CallerInfo: cs.callerInfo(ctx),
CheckPolicy: cs.policyChecker(ctx),
Data: data,
})
if err != nil {
return nil, err
@@ -338,9 +362,10 @@ func (cs *caServer) RenewCert(ctx context.Context, req *pb.RenewCertRequest) (*p
return nil, status.Error(codes.InvalidArgument, "mount and serial are required")
}
resp, err := cs.caHandleRequest(ctx, req.Mount, "renew", &engine.Request{
Operation: "renew",
CallerInfo: cs.callerInfo(ctx),
Data: map[string]interface{}{"serial": req.Serial},
Operation: "renew",
CallerInfo: cs.callerInfo(ctx),
CheckPolicy: cs.policyChecker(ctx),
Data: map[string]interface{}{"serial": req.Serial},
})
if err != nil {
return nil, err
@@ -389,9 +414,10 @@ func (cs *caServer) SignCSR(ctx context.Context, req *pb.SignCSRRequest) (*pb.Si
data["ttl"] = req.Ttl
}
resp, err := cs.caHandleRequest(ctx, req.Mount, "sign-csr", &engine.Request{
Operation: "sign-csr",
CallerInfo: cs.callerInfo(ctx),
Data: data,
Operation: "sign-csr",
CallerInfo: cs.callerInfo(ctx),
CheckPolicy: cs.policyChecker(ctx),
Data: data,
})
if err != nil {
return nil, err

View File

@@ -65,6 +65,27 @@ func (ps *pkiServer) GetIssuerCert(_ context.Context, req *pb.GetIssuerCertReque
return &pb.GetIssuerCertResponse{CertPem: certPEM}, nil
}
func (ps *pkiServer) GetCRL(ctx context.Context, req *pb.GetCRLRequest) (*pb.GetCRLResponse, error) {
if req.Issuer == "" {
return nil, status.Error(codes.InvalidArgument, "issuer is required")
}
caEng, err := ps.getCAEngine(req.Mount)
if err != nil {
return nil, err
}
crlDER, err := caEng.GetCRLDER(ctx, req.Issuer)
if err != nil {
if errors.Is(err, ca.ErrIssuerNotFound) {
return nil, status.Error(codes.NotFound, "issuer not found")
}
if errors.Is(err, ca.ErrSealed) {
return nil, status.Error(codes.Unavailable, "sealed")
}
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.GetCRLResponse{CrlDer: crlDER}, nil
}
func (ps *pkiServer) getCAEngine(mountName string) (*ca.CAEngine, error) {
mount, err := ps.s.engines.GetMount(mountName)
if err != nil {

View File

@@ -55,16 +55,23 @@ func NewEngine(b barrier.Barrier) *Engine {
// Otherwise: collect matching rules, sort by priority (lower = higher priority),
// first match wins, default deny.
func (e *Engine) Evaluate(ctx context.Context, req *Request) (Effect, error) {
effect, _, err := e.Match(ctx, req)
return effect, err
}
// Match checks whether a policy rule matches the request.
// Returns the effect, whether a rule actually matched (vs default deny), and any error.
func (e *Engine) Match(ctx context.Context, req *Request) (Effect, bool, error) {
// Admin bypass.
for _, r := range req.Roles {
if r == "admin" {
return EffectAllow, nil
return EffectAllow, true, nil
}
}
rules, err := e.listRules(ctx)
if err != nil {
return EffectDeny, err
return EffectDeny, false, err
}
// Sort by priority ascending (lower number = higher priority).
@@ -74,11 +81,11 @@ func (e *Engine) Evaluate(ctx context.Context, req *Request) (Effect, error) {
for _, rule := range rules {
if matchesRule(&rule, req) {
return rule.Effect, nil
return rule.Effect, true, nil
}
}
return EffectDeny, nil // default deny
return EffectDeny, false, nil // default deny, no matching rule
}
// CreateRule stores a new policy rule.

View File

@@ -43,6 +43,7 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
r.Get("/v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
r.Get("/v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer))
r.Get("/v1/pki/{mount}/issuer/{name}/crl", s.requireUnseal(s.handlePKICRL))
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
@@ -288,6 +289,20 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
return
}
policyChecker := func(resource, action string) (string, bool) {
pReq := &policy.Request{
Username: info.Username,
Roles: info.Roles,
Resource: resource,
Action: action,
}
eff, matched, pErr := s.policy.Match(r.Context(), pReq)
if pErr != nil {
return string(policy.EffectDeny), false
}
return string(eff), matched
}
engReq := &engine.Request{
Operation: req.Operation,
Path: req.Path,
@@ -297,6 +312,7 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: policyChecker,
}
resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq)
@@ -305,6 +321,8 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
switch {
case errors.Is(err, engine.ErrMountNotFound):
status = http.StatusNotFound
case errors.Is(err, ca.ErrIdentifierInUse):
status = http.StatusConflict
case strings.Contains(err.Error(), "forbidden"):
status = http.StatusForbidden
case strings.Contains(err.Error(), "authentication required"):
@@ -551,6 +569,30 @@ func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(certPEM) //nolint:gosec
}
func (s *Server) handlePKICRL(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
issuerName := chi.URLParam(r, "name")
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
return
}
crlDER, err := caEng.GetCRLDER(r.Context(), issuerName)
if err != nil {
if errors.Is(err, ca.ErrIssuerNotFound) {
http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/pkix-crl")
_, _ = w.Write(crlDER) //nolint:gosec
}
func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
mount, err := s.engines.GetMount(mountName)
if err != nil {

View File

@@ -50,6 +50,7 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer))
r.Post("/issue", ws.requireAuth(ws.handleIssueCert))
r.Post("/sign-csr", ws.requireAuth(ws.handleSignCSR))
r.Get("/download/{token}", ws.requireAuth(ws.handleTGZDownload))
r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail))
r.Get("/cert/{serial}", ws.requireAuth(ws.handleCertDetail))