From ccc1098ea31e3ce37887294d02456e36b0915313 Mon Sep 17 00:00:00 2001 From: Pete Bohman Date: Wed, 15 Dec 2021 10:18:28 -0500 Subject: [PATCH] Add allowed_uri_sans_template (#10249) * Add allowed_uri_sans_template Enables identity templating for the allowed_uri_sans field in PKI cert roles. Implemented as suggested in #8509 * changelog++ * Update docs with URI SAN templating --- builtin/logical/pki/backend_test.go | 122 ++++++++++++++++++++++++ builtin/logical/pki/cert_util.go | 43 +++++---- builtin/logical/pki/path_roles.go | 12 ++- builtin/logical/pki/path_roles_test.go | 6 ++ changelog/10249.txt | 3 + website/content/api-docs/secret/pki.mdx | 3 + 6 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 changelog/10249.txt diff --git a/builtin/logical/pki/backend_test.go b/builtin/logical/pki/backend_test.go index 995bea598..392837ae6 100644 --- a/builtin/logical/pki/backend_test.go +++ b/builtin/logical/pki/backend_test.go @@ -2989,6 +2989,128 @@ func TestBackend_URI_SANs(t *testing.T) { } } +func TestBackend_AllowedURISANsTemplate(t *testing.T) { + coreConfig := &vault.CoreConfig{ + CredentialBackends: map[string]logical.Factory{ + "userpass": userpass.Factory, + }, + LogicalBackends: map[string]logical.Factory{ + "pki": Factory, + }, + } + cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + // Write test policy for userpass auth method. + err := client.Sys().PutPolicy("test", ` + path "pki/*" { + capabilities = ["update"] + }`) + if err != nil { + t.Fatal(err) + } + + // Enable userpass auth method. + if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { + t.Fatal(err) + } + + // Configure test role for userpass. + if _, err := client.Logical().Write("auth/userpass/users/userpassname", map[string]interface{}{ + "password": "test", + "policies": "test", + }); err != nil { + t.Fatal(err) + } + + // Login userpass for test role and keep client token. + secret, err := client.Logical().Write("auth/userpass/login/userpassname", map[string]interface{}{ + "password": "test", + }) + if err != nil || secret == nil { + t.Fatal(err) + } + userpassToken := secret.Auth.ClientToken + + // Get auth accessor for identity template. + auths, err := client.Sys().ListAuth() + if err != nil { + t.Fatal(err) + } + userpassAccessor := auths["userpass/"].Accessor + + // Mount PKI. + err = client.Sys().Mount("pki", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{ + DefaultLeaseTTL: "16h", + MaxLeaseTTL: "60h", + }, + }) + if err != nil { + t.Fatal(err) + } + + // Generate internal CA. + _, err = client.Logical().Write("pki/root/generate/internal", map[string]interface{}{ + "ttl": "40h", + "common_name": "myvault.com", + }) + if err != nil { + t.Fatal(err) + } + + // Write role PKI. + _, err = client.Logical().Write("pki/roles/test", map[string]interface{}{ + "allowed_uri_sans": []string{"spiffe://domain/{{identity.entity.aliases." + userpassAccessor + ".name}}", + "spiffe://domain/{{identity.entity.aliases." + userpassAccessor + ".name}}/*", "spiffe://domain/foo"}, + "allowed_uri_sans_template": true, + "require_cn": false, + }) + if err != nil { + t.Fatal(err) + } + + // Issue certificate with identity templating + client.SetToken(userpassToken) + _, err = client.Logical().Write("pki/issue/test", map[string]interface{}{"uri_sans": "spiffe://domain/userpassname, spiffe://domain/foo"}) + if err != nil { + t.Fatal(err) + } + + // Issue certificate with identity templating and glob + client.SetToken(userpassToken) + _, err = client.Logical().Write("pki/issue/test", map[string]interface{}{"uri_sans": "spiffe://domain/userpassname/bar"}) + if err != nil { + t.Fatal(err) + } + + // Issue certificate with non-matching identity template parameter + client.SetToken(userpassToken) + _, err = client.Logical().Write("pki/issue/test", map[string]interface{}{"uri_sans": "spiffe://domain/unknownuser"}) + if err == nil { + t.Fatal(err) + } + + // Set allowed_uri_sans_template to false. + _, err = client.Logical().Write("pki/roles/test", map[string]interface{}{ + "allowed_uri_sans_template": false, + }) + if err != nil { + t.Fatal(err) + } + + // Issue certificate with userpassToken. + _, err = client.Logical().Write("pki/issue/test", map[string]interface{}{"uri_sans": "spiffe://domain/users/userpassname"}) + if err == nil { + t.Fatal("expected error") + } +} + func TestBackend_AllowedDomainsTemplate(t *testing.T) { coreConfig := &vault.CoreConfig{ CredentialBackends: map[string]logical.Factory{ diff --git a/builtin/logical/pki/cert_util.go b/builtin/logical/pki/cert_util.go index 97241acd1..7c105dc3e 100644 --- a/builtin/logical/pki/cert_util.go +++ b/builtin/logical/pki/cert_util.go @@ -176,6 +176,29 @@ func fetchCertBySerial(ctx context.Context, req *logical.Request, prefix, serial return certEntry, nil } +// Given a URI SAN, verify that it is allowed. +func validateURISAN(b *backend, data *inputBundle, uri string) bool { + valid := false + for _, allowed := range data.role.AllowedURISANs { + if data.role.AllowedURISANsTemplate { + isTemplate, _ := framework.ValidateIdentityTemplate(allowed) + if isTemplate && data.req.EntityID != "" { + tmpAllowed, err := framework.PopulateIdentityTemplate(allowed, data.req.EntityID, b.System()) + if err != nil { + continue + } + allowed = tmpAllowed + } + } + validURI := glob.Glob(allowed, uri) + if validURI { + valid = true + break + } + } + return valid +} + // Given a set of requested names for a certificate, verifies that all of them // match the various toggles set in the role for controlling issuance. // If one does not pass, it is returned in the string argument. @@ -956,15 +979,7 @@ func generateCreationBundle(b *backend, data *inputBundle, caSign *certutil.CAIn // validate uri sans for _, uri := range csr.URIs { - valid := false - for _, allowed := range data.role.AllowedURISANs { - validURI := glob.Glob(allowed, uri.String()) - if validURI { - valid = true - break - } - } - + valid := validateURISAN(b, data, uri.String()) if !valid { return nil, errutil.UserError{ Err: fmt.Sprintf( @@ -986,15 +1001,7 @@ func generateCreationBundle(b *backend, data *inputBundle, caSign *certutil.CAIn } for _, uri := range uriAlt { - valid := false - for _, allowed := range data.role.AllowedURISANs { - validURI := glob.Glob(allowed, uri) - if validURI { - valid = true - break - } - } - + valid := validateURISAN(b, data, uri) if !valid { return nil, errutil.UserError{ Err: fmt.Sprintf( diff --git a/builtin/logical/pki/path_roles.go b/builtin/logical/pki/path_roles.go index 444e8071f..f7db8724d 100644 --- a/builtin/logical/pki/path_roles.go +++ b/builtin/logical/pki/path_roles.go @@ -144,7 +144,14 @@ Any valid URI is accepted, these values support globbing.`, }, }, - "allowed_other_sans": { + "allowed_uri_sans_template": &framework.FieldSchema{ + Type: framework.TypeBool, + Description: `If set, Allowed URI SANs can be specified using identity template policies. + Non-templated URI SANs are also permitted.`, + Default: false, + }, + + "allowed_other_sans": &framework.FieldSchema{ Type: framework.TypeCommaStringSlice, Description: `If set, an array of allowed other names to put in SANs. These values support globbing and must be in the format ;:. Currently only "utf8" is a valid type. All values, including globbing values, must use this syntax, with the exception being a single "*" which allows any OID and any value (but type must still be utf8).`, DisplayAttrs: &framework.DisplayAttributes{ @@ -565,6 +572,7 @@ func (b *backend) pathRoleCreate(ctx context.Context, req *logical.Request, data AllowSubdomains: data.Get("allow_subdomains").(bool), AllowGlobDomains: data.Get("allow_glob_domains").(bool), AllowAnyName: data.Get("allow_any_name").(bool), + AllowedURISANsTemplate: data.Get("allowed_uri_sans_template").(bool), EnforceHostnames: data.Get("enforce_hostnames").(bool), AllowIPSANs: data.Get("allow_ip_sans").(bool), AllowedURISANs: data.Get("allowed_uri_sans").([]string), @@ -783,6 +791,7 @@ type roleEntry struct { AllowedOtherSANs []string `json:"allowed_other_sans" mapstructure:"allowed_other_sans"` AllowedSerialNumbers []string `json:"allowed_serial_numbers" mapstructure:"allowed_serial_numbers"` AllowedURISANs []string `json:"allowed_uri_sans" mapstructure:"allowed_uri_sans"` + AllowedURISANsTemplate bool `json:"allowed_uri_sans_template"` PolicyIdentifiers []string `json:"policy_identifiers" mapstructure:"policy_identifiers"` ExtKeyUsageOIDs []string `json:"ext_key_usage_oids" mapstructure:"ext_key_usage_oids"` BasicConstraintsValidForNonCA bool `json:"basic_constraints_valid_for_non_ca" mapstructure:"basic_constraints_valid_for_non_ca"` @@ -804,6 +813,7 @@ func (r *roleEntry) ToResponseData() map[string]interface{} { "allow_subdomains": r.AllowSubdomains, "allow_glob_domains": r.AllowGlobDomains, "allow_any_name": r.AllowAnyName, + "allowed_uri_sans_template": r.AllowedURISANsTemplate, "enforce_hostnames": r.EnforceHostnames, "allow_ip_sans": r.AllowIPSANs, "server_flag": r.ServerFlag, diff --git a/builtin/logical/pki/path_roles_test.go b/builtin/logical/pki/path_roles_test.go index 64b8057b7..faacd23a0 100644 --- a/builtin/logical/pki/path_roles_test.go +++ b/builtin/logical/pki/path_roles_test.go @@ -592,6 +592,12 @@ func TestPki_RoleNoStore(t *testing.T) { t.Fatalf("allowed_domains_template should not be set by default") } + // By default, allowed_uri_sans_template should be `false` + allowedURISANsTemplate := resp.Data["allowed_uri_sans_template"].(bool) + if allowedURISANsTemplate { + t.Fatalf("allowed_uri_sans_template should not be set by default") + } + // Make sure that setting no_store to `true` works properly roleReq.Operation = logical.UpdateOperation roleReq.Path = "roles/testrole_nostore" diff --git a/changelog/10249.txt b/changelog/10249.txt new file mode 100644 index 000000000..7be43dbe5 --- /dev/null +++ b/changelog/10249.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/pki: Allow URI SAN templates in allowed_uri_sans when allowed_uri_sans_template is set to true. +``` diff --git a/website/content/api-docs/secret/pki.mdx b/website/content/api-docs/secret/pki.mdx index 30247938c..68f4117ee 100644 --- a/website/content/api-docs/secret/pki.mdx +++ b/website/content/api-docs/secret/pki.mdx @@ -793,6 +793,9 @@ request is denied. a JSON string slice. Values can contain glob patterns (e.g. `spiffe://hostname/*`). +- `allowed_uri_sans_template` `()bool: false)` – When set, `allowed_uri_sans` + may contain templates, as with [ACL Path Templating](/docs/concepts/policies). + - `allowed_other_sans` `(string: "")` – Defines allowed custom OID/UTF8-string SANs. This can be a comma-delimited list or a JSON string slice, where each element has the same format as OpenSSL: `;:`, but