diff --git a/builtin/credential/cert/backend_test.go b/builtin/credential/cert/backend_test.go index 807c8dff3..442ad5467 100644 --- a/builtin/credential/cert/backend_test.go +++ b/builtin/credential/cert/backend_test.go @@ -1079,6 +1079,35 @@ func TestBackend_email_singleCert(t *testing.T) { }) } +// Test a self-signed client with OU (root CA) that is trusted +func TestBackend_organizationalUnit_singleCert(t *testing.T) { + connState, err := testConnState( + "test-fixtures/root/rootcawoucert.pem", + "test-fixtures/root/rootcawoukey.pem", + "test-fixtures/root/rootcawoucert.pem", + ) + if err != nil { + t.Fatalf("error testing connection state: %v", err) + } + ca, err := ioutil.ReadFile("test-fixtures/root/rootcawoucert.pem") + if err != nil { + t.Fatalf("err: %v", err) + } + logicaltest.Test(t, logicaltest.TestCase{ + Backend: testFactory(t), + Steps: []logicaltest.TestStep{ + testAccStepCert(t, "web", ca, "foo", allowed{organizational_units: "engineering"}, false), + testAccStepLogin(t, connState), + testAccStepCert(t, "web", ca, "foo", allowed{organizational_units: "eng*"}, false), + testAccStepLogin(t, connState), + testAccStepCert(t, "web", ca, "foo", allowed{organizational_units: "engineering,finance"}, false), + testAccStepLogin(t, connState), + testAccStepCert(t, "web", ca, "foo", allowed{organizational_units: "foo"}, false), + testAccStepLoginInvalid(t, connState), + }, + }) +} + // Test a self-signed client with URI alt names (root CA) that is trusted func TestBackend_uri_singleCert(t *testing.T) { connState, err := testConnState( @@ -1432,12 +1461,13 @@ func testAccStepListCerts( } type allowed struct { - names string // allowed names in the certificate, looks at common, name, dns, email [depricated] - common_names string // allowed common names in the certificate - dns string // allowed dns names in the SAN extension of the certificate - emails string // allowed email names in SAN extension of the certificate - uris string // allowed uris in SAN extension of the certificate - ext string // required extensions in the certificate + names string // allowed names in the certificate, looks at common, name, dns, email [depricated] + common_names string // allowed common names in the certificate + dns string // allowed dns names in the SAN extension of the certificate + emails string // allowed email names in SAN extension of the certificate + uris string // allowed uris in SAN extension of the certificate + organizational_units string // allowed OUs in the certificate + ext string // required extensions in the certificate } func testAccStepCert( @@ -1447,16 +1477,17 @@ func testAccStepCert( Path: "certs/" + name, ErrorOk: expectError, Data: map[string]interface{}{ - "certificate": string(cert), - "policies": policies, - "display_name": name, - "allowed_names": testData.names, - "allowed_common_names": testData.common_names, - "allowed_dns_sans": testData.dns, - "allowed_email_sans": testData.emails, - "allowed_uri_sans": testData.uris, - "required_extensions": testData.ext, - "lease": 1000, + "certificate": string(cert), + "policies": policies, + "display_name": name, + "allowed_names": testData.names, + "allowed_common_names": testData.common_names, + "allowed_dns_sans": testData.dns, + "allowed_email_sans": testData.emails, + "allowed_uri_sans": testData.uris, + "allowed_organizational_units": testData.organizational_units, + "required_extensions": testData.ext, + "lease": 1000, }, Check: func(resp *logical.Response) error { if resp == nil && expectError { diff --git a/builtin/credential/cert/path_certs.go b/builtin/credential/cert/path_certs.go index 384cd47fd..1bf3c1ca2 100644 --- a/builtin/credential/cert/path_certs.go +++ b/builtin/credential/cert/path_certs.go @@ -74,6 +74,12 @@ At least one must exist in the SANs. Supports globbing.`, At least one must exist in the SANs. Supports globbing.`, }, + "allowed_organizational_units": &framework.FieldSchema{ + Type: framework.TypeCommaStringSlice, + Description: `A comma-separated list of Organizational Units names. +At least one must exist in the OU field.`, + }, + "required_extensions": &framework.FieldSchema{ Type: framework.TypeCommaStringSlice, Description: `A comma-separated string or array of extensions @@ -179,18 +185,19 @@ func (b *backend) pathCertRead(ctx context.Context, req *logical.Request, d *fra return &logical.Response{ Data: map[string]interface{}{ - "certificate": cert.Certificate, - "display_name": cert.DisplayName, - "policies": cert.Policies, - "ttl": cert.TTL / time.Second, - "max_ttl": cert.MaxTTL / time.Second, - "period": cert.Period / time.Second, - "allowed_names": cert.AllowedNames, - "allowed_common_names": cert.AllowedCommonNames, - "allowed_dns_sans": cert.AllowedDNSSANs, - "allowed_email_sans": cert.AllowedEmailSANs, - "allowed_uri_sans": cert.AllowedURISANs, - "required_extensions": cert.RequiredExtensions, + "certificate": cert.Certificate, + "display_name": cert.DisplayName, + "policies": cert.Policies, + "ttl": cert.TTL / time.Second, + "max_ttl": cert.MaxTTL / time.Second, + "period": cert.Period / time.Second, + "allowed_names": cert.AllowedNames, + "allowed_common_names": cert.AllowedCommonNames, + "allowed_dns_sans": cert.AllowedDNSSANs, + "allowed_email_sans": cert.AllowedEmailSANs, + "allowed_uri_sans": cert.AllowedURISANs, + "allowed_organizational_units": cert.AllowedOrganizationalUnits, + "required_extensions": cert.RequiredExtensions, }, }, nil } @@ -205,6 +212,7 @@ func (b *backend) pathCertWrite(ctx context.Context, req *logical.Request, d *fr allowedDNSSANs := d.Get("allowed_dns_sans").([]string) allowedEmailSANs := d.Get("allowed_email_sans").([]string) allowedURISANs := d.Get("allowed_uri_sans").([]string) + allowedOrganizationalUnits := d.Get("allowed_organizational_units").([]string) requiredExtensions := d.Get("required_extensions").([]string) var resp logical.Response @@ -278,20 +286,21 @@ func (b *backend) pathCertWrite(ctx context.Context, req *logical.Request, d *fr } certEntry := &CertEntry{ - Name: name, - Certificate: certificate, - DisplayName: displayName, - Policies: policies, - AllowedNames: allowedNames, - AllowedCommonNames: allowedCommonNames, - AllowedDNSSANs: allowedDNSSANs, - AllowedEmailSANs: allowedEmailSANs, - AllowedURISANs: allowedURISANs, - RequiredExtensions: requiredExtensions, - TTL: ttl, - MaxTTL: maxTTL, - Period: period, - BoundCIDRs: parsedCIDRs, + Name: name, + Certificate: certificate, + DisplayName: displayName, + Policies: policies, + AllowedNames: allowedNames, + AllowedCommonNames: allowedCommonNames, + AllowedDNSSANs: allowedDNSSANs, + AllowedEmailSANs: allowedEmailSANs, + AllowedURISANs: allowedURISANs, + AllowedOrganizationalUnits: allowedOrganizationalUnits, + RequiredExtensions: requiredExtensions, + TTL: ttl, + MaxTTL: maxTTL, + Period: period, + BoundCIDRs: parsedCIDRs, } // Store it @@ -311,20 +320,21 @@ func (b *backend) pathCertWrite(ctx context.Context, req *logical.Request, d *fr } type CertEntry struct { - Name string - Certificate string - DisplayName string - Policies []string - TTL time.Duration - MaxTTL time.Duration - Period time.Duration - AllowedNames []string - AllowedCommonNames []string - AllowedDNSSANs []string - AllowedEmailSANs []string - AllowedURISANs []string - RequiredExtensions []string - BoundCIDRs []*sockaddr.SockAddrMarshaler + Name string + Certificate string + DisplayName string + Policies []string + TTL time.Duration + MaxTTL time.Duration + Period time.Duration + AllowedNames []string + AllowedCommonNames []string + AllowedDNSSANs []string + AllowedEmailSANs []string + AllowedURISANs []string + AllowedOrganizationalUnits []string + RequiredExtensions []string + BoundCIDRs []*sockaddr.SockAddrMarshaler } const pathCertHelpSyn = ` diff --git a/builtin/credential/cert/path_login.go b/builtin/credential/cert/path_login.go index 90dbacb44..c85502e50 100644 --- a/builtin/credential/cert/path_login.go +++ b/builtin/credential/cert/path_login.go @@ -252,6 +252,7 @@ func (b *backend) matchesConstraints(clientCert *x509.Certificate, trustedChain b.matchesDNSSANs(clientCert, config) && b.matchesEmailSANs(clientCert, config) && b.matchesURISANs(clientCert, config) && + b.matchesOrganizationalUnits(clientCert, config) && b.matchesCertificateExtensions(clientCert, config) } @@ -358,6 +359,25 @@ func (b *backend) matchesURISANs(clientCert *x509.Certificate, config *ParsedCer return false } +// matchesOrganizationalUnits verifies that the certificate matches at least one configurd allowed OU +func (b *backend) matchesOrganizationalUnits(clientCert *x509.Certificate, config *ParsedCert) bool { + // Default behavior (no OUs) is to allow all OUs + if len(config.Entry.AllowedOrganizationalUnits) == 0 { + return true + } + + // At least one pattern must match at least one name if any patterns are specified + for _, allowedOrganizationalUnits := range config.Entry.AllowedOrganizationalUnits { + for _, ou := range clientCert.Subject.OrganizationalUnit { + if glob.Glob(allowedOrganizationalUnits, ou) { + return true + } + } + } + + return false +} + // matchesCertificateExtensions verifies that the certificate matches configured // required extensions func (b *backend) matchesCertificateExtensions(clientCert *x509.Certificate, config *ParsedCert) bool { diff --git a/builtin/credential/cert/test-fixtures/root/rootcawou.cnf b/builtin/credential/cert/test-fixtures/root/rootcawou.cnf new file mode 100644 index 000000000..be11c33a1 --- /dev/null +++ b/builtin/credential/cert/test-fixtures/root/rootcawou.cnf @@ -0,0 +1,18 @@ +[ req ] +default_bits = 2048 +encrypt_key = no +prompt = no +default_md = sha256 +distinguished_name = dn +req_extensions = req_v3 + +[ req_v3 ] +subjectAltName = @alt_names + +[ dn ] +CN = example.com +OU = engineering + +[ alt_names ] +IP.1 = 127.0.0.1 +email = valid@example.com diff --git a/builtin/credential/cert/test-fixtures/root/rootcawou.csr b/builtin/credential/cert/test-fixtures/root/rootcawou.csr new file mode 100644 index 000000000..d72579b76 --- /dev/null +++ b/builtin/credential/cert/test-fixtures/root/rootcawou.csr @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICpjCCAY4CAQAwLDEUMBIGA1UEAwwLZXhhbXBsZS5jb20xFDASBgNVBAsMC2Vu +Z2luZWVyaW5nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsI4ZJWfQ +mI/3qfacas7O260Iii06oTP4GoQ5QpAYvcfWKKnkXagd0fBl+hfpnrK6ojYY71Jt +cMstVdff2Wc5D3bnQ8Hikb1TMhdAAtZDUW4QbeWAXJ4mkDq1ARRcbTvK121bmDQp +1efepohe0mDxNCruGSHpqfayC6LOkk7XZ73VAOcPPV5OOpY8el7quUdfvElxn0vH +KBVlFRBBW2fbY5EAHDMkmBjWr0ofpwb+vhSuQlOZgsbd20mjDwSYIbywG0tAEOoj +pLI0pOQV5msdfbqmKYE6ZmUeL/Q/pZjYh5uxFUZ4aMD/STDaeq7GdYQYcm17WL+N +ceal9+gKceJSiQIDAQABoDUwMwYJKoZIhvcNAQkOMSYwJDAiBgNVHREEGzAZhwR/ +AAABgRF2YWxpZEBleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAf1tnXgX1 +/1p2MAxHhcil5/lsOMgHWU5dRL6KjK2cepuBpfzlCbxFtvnsj9WHx46f9Q/xbqy+ +1A2TJIBUWxK+Eji//WJxbDsi7fmV5VQlpG7+sEa7yin3KobfMd84nDIYP8wLF1Fq +HhRf7ZjIDh3zTgBosvIIjGEyABrouGYm4Nl409I09MftGXK/5TLJkgm6sxcJCAHG +BMm8IFaI0VN5QFIHKvJ/1oQLpLV+gvtR6jAM/99LXc0SXmFn0Jcy/mE/hxJXJigW +dDOblgjliJo0rWwHK4gfsgpMbHjJiG70g0XHtTpBW+i/NyuPnc8RYzBIJv+4sks+ +hWSmn6/IL46qTg== +-----END CERTIFICATE REQUEST----- diff --git a/builtin/credential/cert/test-fixtures/root/rootcawoucert.pem b/builtin/credential/cert/test-fixtures/root/rootcawoucert.pem new file mode 100644 index 000000000..fe0f22754 --- /dev/null +++ b/builtin/credential/cert/test-fixtures/root/rootcawoucert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDATCCAemgAwIBAgIJAMAMmdiZi5G/MA0GCSqGSIb3DQEBCwUAMCwxFDASBgNV +BAMMC2V4YW1wbGUuY29tMRQwEgYDVQQLDAtlbmdpbmVlcmluZzAeFw0xODA5MDEx +NDM0NTVaFw0yODA4MjkxNDM0NTVaMCwxFDASBgNVBAMMC2V4YW1wbGUuY29tMRQw +EgYDVQQLDAtlbmdpbmVlcmluZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALCOGSVn0JiP96n2nGrOztutCIotOqEz+BqEOUKQGL3H1iip5F2oHdHwZfoX +6Z6yuqI2GO9SbXDLLVXX39lnOQ9250PB4pG9UzIXQALWQ1FuEG3lgFyeJpA6tQEU +XG07ytdtW5g0KdXn3qaIXtJg8TQq7hkh6an2sguizpJO12e91QDnDz1eTjqWPHpe +6rlHX7xJcZ9LxygVZRUQQVtn22ORABwzJJgY1q9KH6cG/r4UrkJTmYLG3dtJow8E +mCG8sBtLQBDqI6SyNKTkFeZrHX26pimBOmZlHi/0P6WY2IebsRVGeGjA/0kw2nqu +xnWEGHJte1i/jXHmpffoCnHiUokCAwEAAaMmMCQwIgYDVR0RBBswGYcEfwAAAYER +dmFsaWRAZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQADggEBAHATSjW20P7+6en0 +Oq/n/R/i+aCgzcxIWSgf3dhOyxGfBW6svSg8ZtBQFEZZHqIRSXZX89zz25+mvwqi +kGRJKKzD/KDd2v9C5+H3DSuu9CqClVtpjF2XLvRHnuclBIrwvyijRcqa2GCTA9YZ +sOfVVGQYobDbtRCgTwWkEpU9RrZWWoD8HAYMkxFc1Cs/vJconeAaQDPEIZx9wnAN +4r/F5143rn5dyhbYehz1/gykL3K0v7s4U5NhaSACE2AiQ+63vhAEd5xt9WPKAAGY +zEyK4b/qPO88mxLr3A/rdzzt1UYAwT38kXA7aV82AH1J8EaCr7tLnXzyLXiEsI4E +BOrHBgU= +-----END CERTIFICATE----- diff --git a/builtin/credential/cert/test-fixtures/root/rootcawoukey.pem b/builtin/credential/cert/test-fixtures/root/rootcawoukey.pem new file mode 100644 index 000000000..166317294 --- /dev/null +++ b/builtin/credential/cert/test-fixtures/root/rootcawoukey.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCwjhklZ9CYj/ep +9pxqzs7brQiKLTqhM/gahDlCkBi9x9YoqeRdqB3R8GX6F+mesrqiNhjvUm1wyy1V +19/ZZzkPdudDweKRvVMyF0AC1kNRbhBt5YBcniaQOrUBFFxtO8rXbVuYNCnV596m +iF7SYPE0Ku4ZIemp9rILos6STtdnvdUA5w89Xk46ljx6Xuq5R1+8SXGfS8coFWUV +EEFbZ9tjkQAcMySYGNavSh+nBv6+FK5CU5mCxt3bSaMPBJghvLAbS0AQ6iOksjSk +5BXmax19uqYpgTpmZR4v9D+lmNiHm7EVRnhowP9JMNp6rsZ1hBhybXtYv41x5qX3 +6Apx4lKJAgMBAAECggEAF1Jd7fv9qPlzfKcP2GgDGS+NLjt1QDAOOOp4aduA+Si5 +mFuAyAJaFg5MWjHocUcosh61Qn+/5yNflLRUZHJnLizFtcSZuiipIbfCg91rvQjt +8KZdQ168t1aZ7E+VOfSpAbX3YG6bjB754UOoSt/1XK/DDdzV8dadhD34TYlOmOxZ +MMnIRERqa+IBSn90TONWPyY3ELSpaiCkz1YZpp6g9RnTACZKLwzBMSunNO5qbEfH +TWlk5o14DZ3zRu5gLT5wy3SGfzm2M+qi8afQq1MT2I6opXj4KU3c64agjNUBYTq7 +S2YWmw6yrqPzxcg0hOz9H6djCx2oen/UxM2z4uoE1QKBgQDlHIFQcVTWEmxhy5yp +uV7Ya5ubx6rW4FnCgh5lJ+wWuSa5TkMuBr30peJn0G6y0I0J1El4o3iwLD/jxwHb +BIJTB1z5fBo3K7lhpZLuRFSWe9Mcd/Aj2pFcy5TqaIV9x8bgVAMVOoZAq9muiEog +zIWVWrVF6FDuFgRMRegNDej6pwKBgQDFRpNQMscPpH+x6xeS0E8boZKnHyuJUZQZ +kfEmnHQuTYmmHS4kXSnJhjODa53YddknTrPrHOvddDDYAaulyyYitPemubYQzBog +MyIgaeFSw/eHrcr/8g4QTohRFcI71xnKRmHvQZb8UflFJkqsqil6WZ6FJiC+STcn +Qdnhol9fTwKBgQCZtGDw1cdjgqKhjVcB6nG94ZtYjECJvaOaQW8g0AKsT/SxttaN +B0ri2XMl0IijgBROttO/knQCRP1r03PkOocwKq1uVprDzpqk7s6++KqC9nlwDOrX +Muf4iD/UbuC3vJIop1QWJtgwhNoaJCcPEAbCZ0Nbrfq1b6Hchb2jHGTj2wKBgHJo +DpDJEeaBeMi+1SoAgpA8sKcZDY+SbvgxShAhVcNwli5u586Q9OX5XTCPHbhmB+yi +2Pa2DBefBaCPv3LkEJa6KpFXTD4Lj+8ymE0B+nmcSpY19O9f+kX8tVOI8d7wTPWg +wbUWbbCg/ZXbshzWhj19cdA4H28bWM/8gZY4K2VDAoGBAMYsNhKdu9ON/7vaLijh +kai2tQLObYqDV6OAzdYm1gopmTTLcxQ6jP6aQlyw1ie51ms/hFozmNkGkaQGD8pp +751Lv3prQz/lDaZeQfKANNN1tpz/QqUOu2di9secMmodxXkwcLzcEKjWPDTuPhcO +VODU1hC5oj8yGFInoDLL2B0K +-----END PRIVATE KEY----- diff --git a/website/source/api/auth/cert/index.html.md b/website/source/api/auth/cert/index.html.md index 655a89521..adc7e0bd5 100644 --- a/website/source/api/auth/cert/index.html.md +++ b/website/source/api/auth/cert/index.html.md @@ -56,6 +56,11 @@ Sets a CA cert and associated parameters in a role name. (https://github.com/ryanuber/go-glob/blob/master/README.md#example). Value is a comma-separated list of URI patterns. Authentication requires at least one URI matching at least one pattern. If not set, defaults to allowing all URIs. +- `allowed_organizational_units` `(string: "" or array: [])` - Constrain the + Organizational Units (OU) in the client certificate with a [globbed pattern] + (https://github.com/ryanuber/go-glob/blob/master/README.md#example). Value is + a comma-separated list of OU patterns. Authentication requires at least one + OU matching at least one pattern. If not set, defaults to allowing all OUs. - `required_extensions` `(string: "" or array: [])` - Require specific Custom Extension OIDs to exist and match the pattern. Value is a comma separated string or array of `oid:value`. Expects the extension value to be some type