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:
@@ -301,6 +301,102 @@ func (x *GetIssuerCertResponse) GetCertPem() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetCRLRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"`
|
||||
Issuer string `protobuf:"bytes,2,opt,name=issuer,proto3" json:"issuer,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetCRLRequest) Reset() {
|
||||
*x = GetCRLRequest{}
|
||||
mi := &file_proto_metacrypt_v2_pki_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetCRLRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetCRLRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetCRLRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_metacrypt_v2_pki_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetCRLRequest.ProtoReflect.Descriptor instead.
|
||||
func (*GetCRLRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_metacrypt_v2_pki_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *GetCRLRequest) GetMount() string {
|
||||
if x != nil {
|
||||
return x.Mount
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GetCRLRequest) GetIssuer() string {
|
||||
if x != nil {
|
||||
return x.Issuer
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetCRLResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CrlDer []byte `protobuf:"bytes,1,opt,name=crl_der,json=crlDer,proto3" json:"crl_der,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetCRLResponse) Reset() {
|
||||
*x = GetCRLResponse{}
|
||||
mi := &file_proto_metacrypt_v2_pki_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetCRLResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetCRLResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetCRLResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_metacrypt_v2_pki_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetCRLResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetCRLResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_metacrypt_v2_pki_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *GetCRLResponse) GetCrlDer() []byte {
|
||||
if x != nil {
|
||||
return x.CrlDer
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_proto_metacrypt_v2_pki_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_metacrypt_v2_pki_proto_rawDesc = "" +
|
||||
@@ -319,12 +415,18 @@ const file_proto_metacrypt_v2_pki_proto_rawDesc = "" +
|
||||
"\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" +
|
||||
"\x06issuer\x18\x02 \x01(\tR\x06issuer\"2\n" +
|
||||
"\x15GetIssuerCertResponse\x12\x19\n" +
|
||||
"\bcert_pem\x18\x01 \x01(\fR\acertPem2\x85\x02\n" +
|
||||
"\bcert_pem\x18\x01 \x01(\fR\acertPem\"=\n" +
|
||||
"\rGetCRLRequest\x12\x14\n" +
|
||||
"\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" +
|
||||
"\x06issuer\x18\x02 \x01(\tR\x06issuer\")\n" +
|
||||
"\x0eGetCRLResponse\x12\x17\n" +
|
||||
"\acrl_der\x18\x01 \x01(\fR\x06crlDer2\xca\x02\n" +
|
||||
"\n" +
|
||||
"PKIService\x12R\n" +
|
||||
"\vGetRootCert\x12 .metacrypt.v2.GetRootCertRequest\x1a!.metacrypt.v2.GetRootCertResponse\x12I\n" +
|
||||
"\bGetChain\x12\x1d.metacrypt.v2.GetChainRequest\x1a\x1e.metacrypt.v2.GetChainResponse\x12X\n" +
|
||||
"\rGetIssuerCert\x12\".metacrypt.v2.GetIssuerCertRequest\x1a#.metacrypt.v2.GetIssuerCertResponseB>Z<git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\rGetIssuerCert\x12\".metacrypt.v2.GetIssuerCertRequest\x1a#.metacrypt.v2.GetIssuerCertResponse\x12C\n" +
|
||||
"\x06GetCRL\x12\x1b.metacrypt.v2.GetCRLRequest\x1a\x1c.metacrypt.v2.GetCRLResponseB>Z<git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_pki_proto_rawDescOnce sync.Once
|
||||
@@ -338,7 +440,7 @@ func file_proto_metacrypt_v2_pki_proto_rawDescGZIP() []byte {
|
||||
return file_proto_metacrypt_v2_pki_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_metacrypt_v2_pki_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_proto_metacrypt_v2_pki_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
|
||||
var file_proto_metacrypt_v2_pki_proto_goTypes = []any{
|
||||
(*GetRootCertRequest)(nil), // 0: metacrypt.v2.GetRootCertRequest
|
||||
(*GetRootCertResponse)(nil), // 1: metacrypt.v2.GetRootCertResponse
|
||||
@@ -346,16 +448,20 @@ var file_proto_metacrypt_v2_pki_proto_goTypes = []any{
|
||||
(*GetChainResponse)(nil), // 3: metacrypt.v2.GetChainResponse
|
||||
(*GetIssuerCertRequest)(nil), // 4: metacrypt.v2.GetIssuerCertRequest
|
||||
(*GetIssuerCertResponse)(nil), // 5: metacrypt.v2.GetIssuerCertResponse
|
||||
(*GetCRLRequest)(nil), // 6: metacrypt.v2.GetCRLRequest
|
||||
(*GetCRLResponse)(nil), // 7: metacrypt.v2.GetCRLResponse
|
||||
}
|
||||
var file_proto_metacrypt_v2_pki_proto_depIdxs = []int32{
|
||||
0, // 0: metacrypt.v2.PKIService.GetRootCert:input_type -> metacrypt.v2.GetRootCertRequest
|
||||
2, // 1: metacrypt.v2.PKIService.GetChain:input_type -> metacrypt.v2.GetChainRequest
|
||||
4, // 2: metacrypt.v2.PKIService.GetIssuerCert:input_type -> metacrypt.v2.GetIssuerCertRequest
|
||||
1, // 3: metacrypt.v2.PKIService.GetRootCert:output_type -> metacrypt.v2.GetRootCertResponse
|
||||
3, // 4: metacrypt.v2.PKIService.GetChain:output_type -> metacrypt.v2.GetChainResponse
|
||||
5, // 5: metacrypt.v2.PKIService.GetIssuerCert:output_type -> metacrypt.v2.GetIssuerCertResponse
|
||||
3, // [3:6] is the sub-list for method output_type
|
||||
0, // [0:3] is the sub-list for method input_type
|
||||
6, // 3: metacrypt.v2.PKIService.GetCRL:input_type -> metacrypt.v2.GetCRLRequest
|
||||
1, // 4: metacrypt.v2.PKIService.GetRootCert:output_type -> metacrypt.v2.GetRootCertResponse
|
||||
3, // 5: metacrypt.v2.PKIService.GetChain:output_type -> metacrypt.v2.GetChainResponse
|
||||
5, // 6: metacrypt.v2.PKIService.GetIssuerCert:output_type -> metacrypt.v2.GetIssuerCertResponse
|
||||
7, // 7: metacrypt.v2.PKIService.GetCRL:output_type -> metacrypt.v2.GetCRLResponse
|
||||
4, // [4:8] is the sub-list for method output_type
|
||||
0, // [0:4] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
@@ -372,7 +478,7 @@ func file_proto_metacrypt_v2_pki_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_metacrypt_v2_pki_proto_rawDesc), len(file_proto_metacrypt_v2_pki_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 6,
|
||||
NumMessages: 8,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
PKIService_GetRootCert_FullMethodName = "/metacrypt.v2.PKIService/GetRootCert"
|
||||
PKIService_GetChain_FullMethodName = "/metacrypt.v2.PKIService/GetChain"
|
||||
PKIService_GetIssuerCert_FullMethodName = "/metacrypt.v2.PKIService/GetIssuerCert"
|
||||
PKIService_GetCRL_FullMethodName = "/metacrypt.v2.PKIService/GetCRL"
|
||||
)
|
||||
|
||||
// PKIServiceClient is the client API for PKIService service.
|
||||
@@ -34,6 +35,7 @@ type PKIServiceClient interface {
|
||||
GetRootCert(ctx context.Context, in *GetRootCertRequest, opts ...grpc.CallOption) (*GetRootCertResponse, error)
|
||||
GetChain(ctx context.Context, in *GetChainRequest, opts ...grpc.CallOption) (*GetChainResponse, error)
|
||||
GetIssuerCert(ctx context.Context, in *GetIssuerCertRequest, opts ...grpc.CallOption) (*GetIssuerCertResponse, error)
|
||||
GetCRL(ctx context.Context, in *GetCRLRequest, opts ...grpc.CallOption) (*GetCRLResponse, error)
|
||||
}
|
||||
|
||||
type pKIServiceClient struct {
|
||||
@@ -74,6 +76,16 @@ func (c *pKIServiceClient) GetIssuerCert(ctx context.Context, in *GetIssuerCertR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *pKIServiceClient) GetCRL(ctx context.Context, in *GetCRLRequest, opts ...grpc.CallOption) (*GetCRLResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetCRLResponse)
|
||||
err := c.cc.Invoke(ctx, PKIService_GetCRL_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PKIServiceServer is the server API for PKIService service.
|
||||
// All implementations must embed UnimplementedPKIServiceServer
|
||||
// for forward compatibility.
|
||||
@@ -84,6 +96,7 @@ type PKIServiceServer interface {
|
||||
GetRootCert(context.Context, *GetRootCertRequest) (*GetRootCertResponse, error)
|
||||
GetChain(context.Context, *GetChainRequest) (*GetChainResponse, error)
|
||||
GetIssuerCert(context.Context, *GetIssuerCertRequest) (*GetIssuerCertResponse, error)
|
||||
GetCRL(context.Context, *GetCRLRequest) (*GetCRLResponse, error)
|
||||
mustEmbedUnimplementedPKIServiceServer()
|
||||
}
|
||||
|
||||
@@ -103,6 +116,9 @@ func (UnimplementedPKIServiceServer) GetChain(context.Context, *GetChainRequest)
|
||||
func (UnimplementedPKIServiceServer) GetIssuerCert(context.Context, *GetIssuerCertRequest) (*GetIssuerCertResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetIssuerCert not implemented")
|
||||
}
|
||||
func (UnimplementedPKIServiceServer) GetCRL(context.Context, *GetCRLRequest) (*GetCRLResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetCRL not implemented")
|
||||
}
|
||||
func (UnimplementedPKIServiceServer) mustEmbedUnimplementedPKIServiceServer() {}
|
||||
func (UnimplementedPKIServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -178,6 +194,24 @@ func _PKIService_GetIssuerCert_Handler(srv interface{}, ctx context.Context, dec
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _PKIService_GetCRL_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetCRLRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(PKIServiceServer).GetCRL(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: PKIService_GetCRL_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(PKIServiceServer).GetCRL(ctx, req.(*GetCRLRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// PKIService_ServiceDesc is the grpc.ServiceDesc for PKIService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -197,6 +231,10 @@ var PKIService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetIssuerCert",
|
||||
Handler: _PKIService_GetIssuerCert_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetCRL",
|
||||
Handler: _PKIService_GetCRL_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/metacrypt/v2/pki.proto",
|
||||
|
||||
@@ -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"
|
||||
@@ -31,6 +33,7 @@ var (
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,16 @@ 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
|
||||
CheckPolicy PolicyChecker
|
||||
Operation string
|
||||
Path string
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -262,6 +285,7 @@ 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),
|
||||
CheckPolicy: cs.policyChecker(ctx),
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -340,6 +364,7 @@ func (cs *caServer) RenewCert(ctx context.Context, req *pb.RenewCertRequest) (*p
|
||||
resp, err := cs.caHandleRequest(ctx, req.Mount, "renew", &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: cs.callerInfo(ctx),
|
||||
CheckPolicy: cs.policyChecker(ctx),
|
||||
Data: map[string]interface{}{"serial": req.Serial},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -391,6 +416,7 @@ func (cs *caServer) SignCSR(ctx context.Context, req *pb.SignCSRRequest) (*pb.Si
|
||||
resp, err := cs.caHandleRequest(ctx, req.Mount, "sign-csr", &engine.Request{
|
||||
Operation: "sign-csr",
|
||||
CallerInfo: cs.callerInfo(ctx),
|
||||
CheckPolicy: cs.policyChecker(ctx),
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -10,6 +10,7 @@ service PKIService {
|
||||
rpc GetRootCert(GetRootCertRequest) returns (GetRootCertResponse);
|
||||
rpc GetChain(GetChainRequest) returns (GetChainResponse);
|
||||
rpc GetIssuerCert(GetIssuerCertRequest) returns (GetIssuerCertResponse);
|
||||
rpc GetCRL(GetCRLRequest) returns (GetCRLResponse);
|
||||
}
|
||||
|
||||
message GetRootCertRequest {
|
||||
@@ -34,3 +35,11 @@ message GetIssuerCertRequest {
|
||||
message GetIssuerCertResponse {
|
||||
bytes cert_pem = 1;
|
||||
}
|
||||
|
||||
message GetCRLRequest {
|
||||
string mount = 1;
|
||||
string issuer = 2;
|
||||
}
|
||||
message GetCRLResponse {
|
||||
bytes crl_der = 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user