diff --git a/.changelog/18708.txt b/.changelog/18708.txt new file mode 100644 index 0000000000..66a34da789 --- /dev/null +++ b/.changelog/18708.txt @@ -0,0 +1,7 @@ +```release-note:feature +acl: Added ACL Templated policies to simplify getting the right ACL token. +``` + +```release-note:improvement +cli: Added `-templated-policy`, `-templated-policy-file`, `-replace-templated-policy`, `-append-templated-policy`, `-replace-templated-policy-file`, `-append-templated-policy-file` and `-var` flags for creating or updating tokens/roles. +``` diff --git a/agent/consul/acl.go b/agent/consul/acl.go index 84646912a5..a73372a040 100644 --- a/agent/consul/acl.go +++ b/agent/consul/acl.go @@ -102,6 +102,10 @@ func (id *missingIdentity) NodeIdentityList() []*structs.ACLNodeIdentity { return nil } +func (id *missingIdentity) TemplatedPolicyList() []*structs.ACLTemplatedPolicy { + return nil +} + func (id *missingIdentity) IsExpired(asOf time.Time) bool { return false } @@ -596,9 +600,11 @@ func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) ( roleIDs = identity.RoleIDs() serviceIdentities = structs.ACLServiceIdentities(identity.ServiceIdentityList()) nodeIdentities = structs.ACLNodeIdentities(identity.NodeIdentityList()) + templatedPolicies = structs.ACLTemplatedPolicies(identity.TemplatedPolicyList()) ) - if len(policyIDs) == 0 && len(serviceIdentities) == 0 && len(roleIDs) == 0 && len(nodeIdentities) == 0 { + if len(policyIDs) == 0 && len(serviceIdentities) == 0 && + len(roleIDs) == 0 && len(nodeIdentities) == 0 && len(templatedPolicies) == 0 { // In this case the default policy will be all that is in effect. return nil, nil } @@ -616,16 +622,19 @@ func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) ( } serviceIdentities = append(serviceIdentities, role.ServiceIdentities...) nodeIdentities = append(nodeIdentities, role.NodeIdentityList()...) + templatedPolicies = append(templatedPolicies, role.TemplatedPolicyList()...) } // Now deduplicate any policies or service identities that occur more than once. policyIDs = dedupeStringSlice(policyIDs) serviceIdentities = serviceIdentities.Deduplicate() nodeIdentities = nodeIdentities.Deduplicate() + templatedPolicies = templatedPolicies.Deduplicate() // Generate synthetic policies for all service identities in effect. syntheticPolicies := r.synthesizePoliciesForServiceIdentities(serviceIdentities, identity.EnterpriseMetadata()) syntheticPolicies = append(syntheticPolicies, r.synthesizePoliciesForNodeIdentities(nodeIdentities, identity.EnterpriseMetadata())...) + syntheticPolicies = append(syntheticPolicies, r.synthesizePoliciesForTemplatedPolicies(templatedPolicies, identity.EnterpriseMetadata())...) // For the new ACLs policy replication is mandatory for correct operation on servers. Therefore // we only attempt to resolve policies locally @@ -669,6 +678,24 @@ func (r *ACLResolver) synthesizePoliciesForNodeIdentities(nodeIdentities []*stru return syntheticPolicies } +func (r *ACLResolver) synthesizePoliciesForTemplatedPolicies(templatedPolicies []*structs.ACLTemplatedPolicy, entMeta *acl.EnterpriseMeta) []*structs.ACLPolicy { + if len(templatedPolicies) == 0 { + return nil + } + + syntheticPolicies := make([]*structs.ACLPolicy, 0, len(templatedPolicies)) + for _, tp := range templatedPolicies { + policy, err := tp.SyntheticPolicy(entMeta) + if err != nil { + r.logger.Warn(fmt.Sprintf("could not generate synthetic policy for templated policy: %q", tp.TemplateName), "error", err) + continue + } + syntheticPolicies = append(syntheticPolicies, policy) + } + + return syntheticPolicies +} + func mergeStringSlice(a, b []string) []string { out := make([]string, 0, len(a)+len(b)) out = append(out, a...) diff --git a/agent/consul/acl_endpoint.go b/agent/consul/acl_endpoint.go index ba9a081ea7..7552c6c14c 100644 --- a/agent/consul/acl_endpoint.go +++ b/agent/consul/acl_endpoint.go @@ -350,9 +350,10 @@ func (a *ACL) lookupExpandedTokenInfo(ws memdb.WatchSet, state *state.Store, tok policyIDs := make(map[string]struct{}) roleIDs := make(map[string]struct{}) identityPolicies := make(map[string]*structs.ACLPolicy) + templatedPolicies := make(map[string]*structs.ACLPolicy) tokenInfo := structs.ExpandedTokenInfo{} - // Add the token's policies and node/service identity policies + // Add the token's policies, templated policies and node/service identity policies for _, policy := range token.Policies { policyIDs[policy.ID] = struct{}{} } @@ -368,6 +369,14 @@ func (a *ACL) lookupExpandedTokenInfo(ws memdb.WatchSet, state *state.Store, tok policy := identity.SyntheticPolicy(&token.EnterpriseMeta) identityPolicies[policy.ID] = policy } + for _, templatedPolicy := range token.TemplatedPolicies { + policy, err := templatedPolicy.SyntheticPolicy(&token.EnterpriseMeta) + if err != nil { + a.logger.Warn(fmt.Sprintf("could not generate synthetic policy for templated policy: %q", templatedPolicy.TemplateName), "error", err) + continue + } + templatedPolicies[policy.ID] = policy + } // Get any namespace default roles/policies to look up nsPolicies, nsRoles, err := getTokenNamespaceDefaults(ws, state, &token.EnterpriseMeta) @@ -405,6 +414,14 @@ func (a *ACL) lookupExpandedTokenInfo(ws memdb.WatchSet, state *state.Store, tok policy := identity.SyntheticPolicy(&role.EnterpriseMeta) identityPolicies[policy.ID] = policy } + for _, templatedPolicy := range role.TemplatedPolicies { + policy, err := templatedPolicy.SyntheticPolicy(&role.EnterpriseMeta) + if err != nil { + a.logger.Warn(fmt.Sprintf("could not generate synthetic policy for templated policy: %q", templatedPolicy.TemplateName), "error", err) + continue + } + templatedPolicies[policy.ID] = policy + } tokenInfo.ExpandedRoles = append(tokenInfo.ExpandedRoles, role) } @@ -423,6 +440,9 @@ func (a *ACL) lookupExpandedTokenInfo(ws memdb.WatchSet, state *state.Store, tok for _, policy := range identityPolicies { policies = append(policies, policy) } + for _, policy := range templatedPolicies { + policies = append(policies, policy) + } tokenInfo.ExpandedPolicies = policies tokenInfo.AgentACLDefaultPolicy = a.srv.config.ACLResolverSettings.ACLDefaultPolicy @@ -486,6 +506,7 @@ func (a *ACL) TokenClone(args *structs.ACLTokenSetRequest, reply *structs.ACLTok Roles: token.Roles, ServiceIdentities: token.ServiceIdentities, NodeIdentities: token.NodeIdentities, + TemplatedPolicies: token.TemplatedPolicies, Local: token.Local, Description: token.Description, ExpirationTime: token.ExpirationTime, @@ -1364,6 +1385,27 @@ func (a *ACL) RoleSet(args *structs.ACLRoleSetRequest, reply *structs.ACLRole) e } role.NodeIdentities = role.NodeIdentities.Deduplicate() + for _, templatedPolicy := range role.TemplatedPolicies { + if templatedPolicy.TemplateName == "" { + return fmt.Errorf("templated policy is missing the template name field on this role") + } + + baseTemplate, ok := structs.GetACLTemplatedPolicyBase(templatedPolicy.TemplateName) + if !ok { + return fmt.Errorf("templated policy with an invalid templated name: %s for this role", templatedPolicy.TemplateName) + } + + if templatedPolicy.TemplateID == "" { + templatedPolicy.TemplateID = baseTemplate.TemplateID + } + + err := templatedPolicy.ValidateTemplatedPolicy(baseTemplate.Schema) + if err != nil { + return fmt.Errorf("encountered role with invalid templated policy: %w", err) + } + } + role.TemplatedPolicies = role.TemplatedPolicies.Deduplicate() + // calculate the hash for this role role.SetHash(true) diff --git a/agent/consul/acl_endpoint_test.go b/agent/consul/acl_endpoint_test.go index 73f7f6230d..d844efb778 100644 --- a/agent/consul/acl_endpoint_test.go +++ b/agent/consul/acl_endpoint_test.go @@ -23,6 +23,7 @@ import ( "github.com/hashicorp/consul/agent/consul/authmethod/testauth" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/structs/aclfilter" + "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/sdk/testutil/retry" @@ -376,7 +377,7 @@ func TestACLEndpoint_TokenRead(t *testing.T) { require.ElementsMatch(t, []*structs.ACLRole{r1, r2}, resp.ExpandedRoles) }) - t.Run("expanded output with node/service identities", func(t *testing.T) { + t.Run("expanded output with node/service identities and templated policies", func(t *testing.T) { setReq := structs.ACLTokenSetRequest{ Datacenter: "dc1", ACLToken: structs.ACLToken{ @@ -401,6 +402,22 @@ func TestACLEndpoint_TokenRead(t *testing.T) { Datacenter: "dc1", }, }, + TemplatedPolicies: []*structs.ACLTemplatedPolicy{ + { + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &structs.ACLTemplatedPolicyVariables{ + Name: "web", + }, + Datacenters: []string{"dc1"}, + }, + { + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &structs.ACLTemplatedPolicyVariables{ + Name: "foo", + }, + Datacenters: []string{"dc1"}, + }, + }, Local: false, }, WriteRequest: structs.WriteRequest{Token: TestDefaultInitialManagementToken}, @@ -414,6 +431,11 @@ func TestACLEndpoint_TokenRead(t *testing.T) { for _, serviceIdentity := range setReq.ACLToken.NodeIdentities { expectedPolicies = append(expectedPolicies, serviceIdentity.SyntheticPolicy(entMeta)) } + for _, templatedPolicy := range setReq.ACLToken.TemplatedPolicies { + pol, tmplError := templatedPolicy.SyntheticPolicy(entMeta) + require.NoError(t, tmplError) + expectedPolicies = append(expectedPolicies, pol) + } setResp := structs.ACLToken{} err := msgpackrpc.CallWithCodec(codec, "ACL.TokenSet", &setReq, &setResp) @@ -468,6 +490,10 @@ func TestACLEndpoint_TokenClone(t *testing.T) { t.NodeIdentities = []*structs.ACLNodeIdentity{ {NodeName: "foo", Datacenter: "bar"}, } + t.TemplatedPolicies = []*structs.ACLTemplatedPolicy{ + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "foo"}, Datacenters: []string{"bar"}}, + {TemplateName: api.ACLTemplatedPolicyNodeName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "node"}}, + } }) require.NoError(t, err) @@ -490,6 +516,7 @@ func TestACLEndpoint_TokenClone(t *testing.T) { require.Equal(t, t1.Roles, t2.Roles) require.Equal(t, t1.ServiceIdentities, t2.ServiceIdentities) require.Equal(t, t1.NodeIdentities, t2.NodeIdentities) + require.Equal(t, t1.TemplatedPolicies, t2.TemplatedPolicies) require.Equal(t, t1.Local, t2.Local) require.NotEqual(t, t1.AccessorID, t2.AccessorID) require.NotEqual(t, t1.SecretID, t2.SecretID) @@ -548,6 +575,10 @@ func TestACLEndpoint_TokenSet(t *testing.T) { Datacenter: "dc1", }, }, + TemplatedPolicies: []*structs.ACLTemplatedPolicy{ + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "foo"}, Datacenters: []string{"bar"}}, + {TemplateName: api.ACLTemplatedPolicyNodeName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "node"}}, + }, }, WriteRequest: structs.WriteRequest{Token: TestDefaultInitialManagementToken}, } @@ -570,6 +601,19 @@ func TestACLEndpoint_TokenSet(t *testing.T) { require.Equal(t, "foo", token.NodeIdentities[0].NodeName) require.Equal(t, "dc1", token.NodeIdentities[0].Datacenter) + require.Len(t, token.TemplatedPolicies, 2) + require.Contains(t, token.TemplatedPolicies, &structs.ACLTemplatedPolicy{ + TemplateID: structs.ACLTemplatedPolicyServiceID, + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "foo"}, + Datacenters: []string{"bar"}, + }) + require.Contains(t, token.TemplatedPolicies, &structs.ACLTemplatedPolicy{ + TemplateID: structs.ACLTemplatedPolicyNodeID, + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "node"}, + }) + accessorID = token.AccessorID }) @@ -2183,6 +2227,39 @@ func TestACLEndpoint_PolicySet_CustomID(t *testing.T) { require.Error(t, err) } +func TestACLEndpoint_TemplatedPolicySet_UnknownTemplateName(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + _, srv, _ := testACLServerWithConfig(t, nil, false) + waitForLeaderEstablishment(t, srv) + + aclEp := ACL{srv: srv} + + t.Run("unknown template name", func(t *testing.T) { + req := structs.ACLTokenSetRequest{ + Datacenter: "dc1", + ACLToken: structs.ACLToken{ + Description: "foobar", + Policies: nil, + Local: false, + TemplatedPolicies: []*structs.ACLTemplatedPolicy{{TemplateName: "fake-builtin"}}, + }, + Create: true, + WriteRequest: structs.WriteRequest{Token: TestDefaultInitialManagementToken}, + } + + resp := structs.ACLToken{} + + err := aclEp.TokenSet(&req, &resp) + require.Error(t, err) + require.ErrorContains(t, err, "no such ACL templated policy with Name \"fake-builtin\"") + }) +} + func TestACLEndpoint_PolicySet_builtins(t *testing.T) { if testing.Short() { t.Skip("too slow for testing.Short") diff --git a/agent/consul/acl_test.go b/agent/consul/acl_test.go index 275316786b..b2179edb53 100644 --- a/agent/consul/acl_test.go +++ b/agent/consul/acl_test.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/consul/acl/resolver" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/token" + "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/sdk/testutil/retry" ) @@ -1978,6 +1979,48 @@ func testACLResolver_variousTokens(t *testing.T, delegate *ACLResolverTestDelega }, }, }, + &structs.ACLToken{ + AccessorID: "359b9927-25fd-46b9-84c2-3470f848ec65", + SecretID: "found-synthetic-policy-5", + TemplatedPolicies: []*structs.ACLTemplatedPolicy{ + { + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &structs.ACLTemplatedPolicyVariables{ + Name: "templated-test-node1", + }, + Datacenters: []string{"dc1"}, + }, + { + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &structs.ACLTemplatedPolicyVariables{ + Name: "templated-test-node2", + }, + // as the resolver is in dc1 this identity should be ignored + Datacenters: []string{"dc2"}, + }, + }, + }, + &structs.ACLToken{ + AccessorID: "359b9927-25fd-46b9-84c2-3470f848ec65", + SecretID: "found-synthetic-policy-6", + TemplatedPolicies: []*structs.ACLTemplatedPolicy{ + { + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &structs.ACLTemplatedPolicyVariables{ + Name: "templated-test-node3", + }, + Datacenters: []string{"dc1"}, + }, + { + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &structs.ACLTemplatedPolicyVariables{ + Name: "templated-test-node4", + }, + // as the resolver is in dc1 this identity should be ignored + Datacenters: []string{"dc2"}, + }, + }, + }, }) // We resolve these tokens in the same cache session @@ -2043,6 +2086,22 @@ func testACLResolver_variousTokens(t *testing.T, delegate *ACLResolverTestDelega // ensure node identity for other DC is ignored require.Equal(t, acl.Deny, authz.NodeWrite("test-node-dc2", nil)) }) + t.Run("synthetic-policy-6", func(t *testing.T) { // templated policy + authz, err := r.ResolveToken("found-synthetic-policy-6") + require.NoError(t, err) + require.NotNil(t, authz) + + // spot check some random perms + require.Equal(t, acl.Deny, authz.ACLRead(nil)) + require.Equal(t, acl.Deny, authz.NodeWrite("foo", nil)) + // ensure we didn't bleed over to the other synthetic policy + require.Equal(t, acl.Deny, authz.NodeWrite("templated-test-node1", nil)) + // check our own synthetic policy + require.Equal(t, acl.Allow, authz.ServiceRead("literally-anything", nil)) + require.Equal(t, acl.Allow, authz.NodeWrite("templated-test-node3", nil)) + // ensure template identity for other DC is ignored + require.Equal(t, acl.Deny, authz.NodeWrite("templated-test-node4", nil)) + }) }) runTwiceAndReset("Anonymous", func(t *testing.T) { diff --git a/agent/consul/auth/token_writer.go b/agent/consul/auth/token_writer.go index 8321b78610..0112d0387c 100644 --- a/agent/consul/auth/token_writer.go +++ b/agent/consul/auth/token_writer.go @@ -309,6 +309,12 @@ func (w *TokenWriter) write(token, existing *structs.ACLToken, fromLogin bool) ( } token.NodeIdentities = nodeIdentities + templatedPolicies, err := w.normalizeTemplatedPolicies(token.TemplatedPolicies) + if err != nil { + return nil, err + } + token.TemplatedPolicies = templatedPolicies + if err := w.enterpriseValidation(token, existing); err != nil { return nil, err } @@ -442,3 +448,32 @@ func (w *TokenWriter) normalizeNodeIdentities(nodeIDs structs.ACLNodeIdentities) } return nodeIDs.Deduplicate(), nil } + +func (w *TokenWriter) normalizeTemplatedPolicies(templatedPolicies structs.ACLTemplatedPolicies) (structs.ACLTemplatedPolicies, error) { + if len(templatedPolicies) == 0 { + return templatedPolicies, nil + } + + finalPolicies := make(structs.ACLTemplatedPolicies, 0, len(templatedPolicies)) + for _, templatedPolicy := range templatedPolicies { + if templatedPolicy.TemplateName == "" { + return nil, errors.New("templated policy is missing the template name field on this token") + } + + tmp, ok := structs.GetACLTemplatedPolicyBase(templatedPolicy.TemplateName) + if !ok { + return nil, fmt.Errorf("no such ACL templated policy with Name %q", templatedPolicy.TemplateName) + } + + out := templatedPolicy.Clone() + out.TemplateID = tmp.TemplateID + + err := templatedPolicy.ValidateTemplatedPolicy(tmp.Schema) + if err != nil { + return nil, fmt.Errorf("validation error for templated policy %q: %w", templatedPolicy.TemplateName, err) + } + finalPolicies = append(finalPolicies, out) + } + + return finalPolicies.Deduplicate(), nil +} diff --git a/agent/consul/auth/token_writer_test.go b/agent/consul/auth/token_writer_test.go index 45cd4c99ce..3206476a50 100644 --- a/agent/consul/auth/token_writer_test.go +++ b/agent/consul/auth/token_writer_test.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/consul/state" "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/api" ) func TestTokenWriter_Create_Validation(t *testing.T) { @@ -357,6 +358,59 @@ func TestTokenWriter_NodeIdentities(t *testing.T) { } } +func TestTokenWriter_TemplatedPolicies(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + writer := buildTokenWriter(store, aclCache) + + testCases := map[string]struct { + input []*structs.ACLTemplatedPolicy + output []*structs.ACLTemplatedPolicy + errorContains string + }{ + "missing templated policy name": { + input: []*structs.ACLTemplatedPolicy{{TemplateName: ""}}, + errorContains: "templated policy is missing the template name field on this token", + }, + "invalid templated policy name": { + input: []*structs.ACLTemplatedPolicy{{TemplateName: "faketemplate"}}, + errorContains: "no such ACL templated policy with Name \"faketemplate\"", + }, + "missing required template variable: name": { + input: []*structs.ACLTemplatedPolicy{{TemplateName: api.ACLTemplatedPolicyServiceName, Datacenters: []string{"dc1"}}}, + errorContains: "validation error for templated policy \"builtin/service\"", + }, + "duplicate templated policies are removed and ids are set": { + input: []*structs.ACLTemplatedPolicy{ + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "web"}}, + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "web"}}, + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "api"}}, + {TemplateName: api.ACLTemplatedPolicyNodeName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "nodename"}}, + }, + output: []*structs.ACLTemplatedPolicy{ + {TemplateID: structs.ACLTemplatedPolicyServiceID, TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "web"}}, + {TemplateID: structs.ACLTemplatedPolicyServiceID, TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "api"}}, + {TemplateID: structs.ACLTemplatedPolicyNodeID, TemplateName: api.ACLTemplatedPolicyNodeName, TemplateVariables: &structs.ACLTemplatedPolicyVariables{Name: "nodename"}}, + }, + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + updated, err := writer.Create(&structs.ACLToken{TemplatedPolicies: tc.input}, false) + if tc.errorContains == "" { + require.NoError(t, err) + require.ElementsMatch(t, tc.output, updated.TemplatedPolicies) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + } + }) + } +} + func TestTokenWriter_Create_Expiration(t *testing.T) { aclCache := &MockACLCache{} aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) diff --git a/agent/consul/state/acl.go b/agent/consul/state/acl.go index 5114d09e46..27fdcb799e 100644 --- a/agent/consul/state/acl.go +++ b/agent/consul/state/acl.go @@ -1177,6 +1177,26 @@ func aclRoleSetTxn(tx WriteTxn, idx uint64, role *structs.ACLRole, allowMissing } } + for _, templatedPolicy := range role.TemplatedPolicies { + if templatedPolicy.TemplateName == "" { + return fmt.Errorf("encountered a Role %s (%s) with an empty templated policy name in the state store", role.Name, role.ID) + } + + baseTemplate, ok := structs.GetACLTemplatedPolicyBase(templatedPolicy.TemplateName) + if !ok { + return fmt.Errorf("encountered a Role %s (%s) with an invalid templated policy name %q", role.Name, role.ID, templatedPolicy.TemplateName) + } + + if templatedPolicy.TemplateID == "" { + templatedPolicy.TemplateID = baseTemplate.TemplateID + } + + err := templatedPolicy.ValidateTemplatedPolicy(baseTemplate.Schema) + if err != nil { + return fmt.Errorf("encountered a Role %s (%s) with an invalid templated policy: %w", role.Name, role.ID, err) + } + } + if err := aclRoleUpsertValidateEnterprise(tx, role, existing); err != nil { return err } diff --git a/agent/structs/acl.go b/agent/structs/acl.go index 8b5e041378..fe1cdac82c 100644 --- a/agent/structs/acl.go +++ b/agent/structs/acl.go @@ -126,6 +126,7 @@ type ACLIdentity interface { RoleIDs() []string ServiceIdentityList() []*ACLServiceIdentity NodeIdentityList() []*ACLNodeIdentity + TemplatedPolicyList() []*ACLTemplatedPolicy IsExpired(asOf time.Time) bool IsLocal() bool EnterpriseMetadata() *acl.EnterpriseMeta @@ -314,6 +315,9 @@ type ACLToken struct { // The node identities that this token should be allowed to manage. NodeIdentities ACLNodeIdentities `json:",omitempty"` + // The templated policies to generate synthetic policies for. + TemplatedPolicies ACLTemplatedPolicies `json:",omitempty"` + // Whether this token is DC local. This means that it will not be synced // to the ACL datacenter and replicated to others. Local bool @@ -394,6 +398,7 @@ func (t *ACLToken) Clone() *ACLToken { t2.Roles = nil t2.ServiceIdentities = nil t2.NodeIdentities = nil + t2.TemplatedPolicies = nil if len(t.Policies) > 0 { t2.Policies = make([]ACLTokenPolicyLink, len(t.Policies)) @@ -415,6 +420,12 @@ func (t *ACLToken) Clone() *ACLToken { t2.NodeIdentities[i] = n.Clone() } } + if len(t.TemplatedPolicies) > 0 { + t2.TemplatedPolicies = make([]*ACLTemplatedPolicy, len(t.TemplatedPolicies)) + for idx, tp := range t.TemplatedPolicies { + t2.TemplatedPolicies[idx] = tp.Clone() + } + } return &t2 } @@ -523,6 +534,10 @@ func (t *ACLToken) SetHash(force bool) []byte { nodeID.AddToHash(hash) } + for _, templatedPolicy := range t.TemplatedPolicies { + templatedPolicy.AddToHash(hash) + } + t.EnterpriseMeta.AddToHash(hash, false) // Finalize the hash @@ -549,6 +564,9 @@ func (t *ACLToken) EstimateSize() int { for _, nodeID := range t.NodeIdentities { size += nodeID.EstimateSize() } + for _, templatedPolicy := range t.TemplatedPolicies { + size += templatedPolicy.EstimateSize() + } return size + t.EnterpriseMeta.EstimateSize() } @@ -563,6 +581,7 @@ type ACLTokenListStub struct { Roles []ACLTokenRoleLink `json:",omitempty"` ServiceIdentities ACLServiceIdentities `json:",omitempty"` NodeIdentities ACLNodeIdentities `json:",omitempty"` + TemplatedPolicies ACLTemplatedPolicies `json:",omitempty"` Local bool AuthMethod string `json:",omitempty"` ExpirationTime *time.Time `json:",omitempty"` @@ -585,6 +604,7 @@ func (token *ACLToken) Stub() *ACLTokenListStub { Roles: token.Roles, ServiceIdentities: token.ServiceIdentities, NodeIdentities: token.NodeIdentities, + TemplatedPolicies: token.TemplatedPolicies, Local: token.Local, AuthMethod: token.AuthMethod, ExpirationTime: token.ExpirationTime, @@ -870,6 +890,9 @@ type ACLRole struct { // List of nodes to generate synthetic policies for. NodeIdentities ACLNodeIdentities `json:",omitempty"` + // List of templated policies to generate synthethic policies for. + TemplatedPolicies ACLTemplatedPolicies `json:",omitempty"` + // Hash of the contents of the role // This does not take into account the ID (which is immutable) // nor the raft metadata. @@ -909,6 +932,7 @@ func (r *ACLRole) Clone() *ACLRole { r2.Policies = nil r2.ServiceIdentities = nil r2.NodeIdentities = nil + r2.TemplatedPolicies = nil if len(r.Policies) > 0 { r2.Policies = make([]ACLRolePolicyLink, len(r.Policies)) @@ -926,6 +950,12 @@ func (r *ACLRole) Clone() *ACLRole { r2.NodeIdentities[i] = n.Clone() } } + if len(r.TemplatedPolicies) > 0 { + r2.TemplatedPolicies = make([]*ACLTemplatedPolicy, len(r.TemplatedPolicies)) + for i, n := range r.TemplatedPolicies { + r2.TemplatedPolicies[i] = n.Clone() + } + } return &r2 } @@ -957,6 +987,9 @@ func (r *ACLRole) SetHash(force bool) []byte { for _, nodeID := range r.NodeIdentities { nodeID.AddToHash(hash) } + for _, templatedPolicy := range r.TemplatedPolicies { + templatedPolicy.AddToHash(hash) + } r.EnterpriseMeta.AddToHash(hash, false) @@ -984,6 +1017,9 @@ func (r *ACLRole) EstimateSize() int { for _, nodeID := range r.NodeIdentities { size += nodeID.EstimateSize() } + for _, templatedPolicy := range r.TemplatedPolicies { + size += templatedPolicy.EstimateSize() + } return size + r.EnterpriseMeta.EstimateSize() } @@ -1845,6 +1881,10 @@ func (id *AgentRecoveryTokenIdentity) NodeIdentityList() []*ACLNodeIdentity { return nil } +func (id *AgentRecoveryTokenIdentity) TemplatedPolicyList() []*ACLTemplatedPolicy { + return nil +} + func (id *AgentRecoveryTokenIdentity) IsExpired(asOf time.Time) bool { return false } @@ -1893,6 +1933,10 @@ func (i *ACLServerIdentity) NodeIdentityList() []*ACLNodeIdentity { return nil } +func (i *ACLServerIdentity) TemplatedPolicyList() []*ACLTemplatedPolicy { + return nil +} + func (i *ACLServerIdentity) IsExpired(asOf time.Time) bool { return false } diff --git a/agent/structs/acl_templated_policy.go b/agent/structs/acl_templated_policy.go new file mode 100644 index 0000000000..3cef447974 --- /dev/null +++ b/agent/structs/acl_templated_policy.go @@ -0,0 +1,269 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package structs + +import ( + "bytes" + "fmt" + "hash" + "hash/fnv" + "html/template" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/lib/stringslice" + "github.com/hashicorp/go-multierror" + "github.com/xeipuuv/gojsonschema" + "golang.org/x/exp/slices" +) + +type ACLTemplatedPolicies []*ACLTemplatedPolicy + +const ( + ACLTemplatedPolicyNodeID = "00000000-0000-0000-0000-000000000004" + ACLTemplatedPolicyServiceID = "00000000-0000-0000-0000-000000000003" + ACLTemplatedPolicyIdentitiesSchema = `{ + "type": "object", + "properties": { + "name": { "type": "string", "$ref": "#/definitions/min-length-one" } + }, + "required": ["name"], + "definitions": { + "min-length-one": { + "type": "string", + "minLength": 1 + } + } + }` + + ACLTemplatedPolicyDNSID = "00000000-0000-0000-0000-000000000005" + ACLTemplatedPolicyDNSSchema = "" // empty schema as it does not require variables +) + +// ACLTemplatedPolicyBase contains basic information about builtin templated policies +// template name, id, template code and schema +type ACLTemplatedPolicyBase struct { + TemplateName string + TemplateID string + Schema string + Template string +} + +var ( + // TODO(Ronald): add other templates + // This supports: node, service and dns templates + aclTemplatedPoliciesList = map[string]*ACLTemplatedPolicyBase{ + api.ACLTemplatedPolicyServiceName: { + TemplateID: ACLTemplatedPolicyServiceID, + TemplateName: api.ACLTemplatedPolicyServiceName, + Schema: ACLTemplatedPolicyIdentitiesSchema, + Template: ACLTemplatedPolicyService, + }, + api.ACLTemplatedPolicyNodeName: { + TemplateID: ACLTemplatedPolicyNodeID, + TemplateName: api.ACLTemplatedPolicyNodeName, + Schema: ACLTemplatedPolicyIdentitiesSchema, + Template: ACLTemplatedPolicyNode, + }, + api.ACLTemplatedPolicyDNSName: { + TemplateID: ACLTemplatedPolicyDNSID, + TemplateName: api.ACLTemplatedPolicyDNSName, + Schema: ACLTemplatedPolicyDNSSchema, + Template: ACLTemplatedPolicyDNS, + }, + } +) + +// ACLTemplatedPolicy represents a template used to generate a `synthetic` policy +// given some input variables. +type ACLTemplatedPolicy struct { + // TemplateID are hidden from all displays and should not be exposed to the users. + TemplateID string `json:",omitempty"` + + // TemplateName is used for display purposes mostly and should not be used for policy rendering. + TemplateName string `json:",omitempty"` + + // TemplateVariables are input variables required to render templated policies. + TemplateVariables *ACLTemplatedPolicyVariables `json:",omitempty"` + + // Datacenters that the synthetic policy will be valid within. + // - No wildcards allowed + // - If empty then the synthetic policy is valid within all datacenters + // + // This is kept for legacy reasons to enable us to replace Node/Service Identities by templated policies. + // + // Only valid for global tokens. It is an error to specify this for local tokens. + Datacenters []string `json:",omitempty"` +} + +// ACLTemplatedPolicyVariables are input variables required to render templated policies. +type ACLTemplatedPolicyVariables struct { + Name string `json:"name,omitempty"` +} + +func (tp *ACLTemplatedPolicy) Clone() *ACLTemplatedPolicy { + tp2 := *tp + + tp2.TemplateVariables = nil + if tp.TemplateVariables != nil { + tp2.TemplateVariables = tp.TemplateVariables.Clone() + } + tp2.Datacenters = stringslice.CloneStringSlice(tp.Datacenters) + + return &tp2 +} + +func (tp *ACLTemplatedPolicy) AddToHash(h hash.Hash) { + h.Write([]byte(tp.TemplateID)) + h.Write([]byte(tp.TemplateName)) + + if tp.TemplateVariables != nil { + tp.TemplateVariables.AddToHash(h) + } + for _, dc := range tp.Datacenters { + h.Write([]byte(dc)) + } +} + +func (tv *ACLTemplatedPolicyVariables) AddToHash(h hash.Hash) { + h.Write([]byte(tv.Name)) +} + +func (tv *ACLTemplatedPolicyVariables) Clone() *ACLTemplatedPolicyVariables { + tv2 := *tv + return &tv2 +} + +// validates templated policy variables against schema. +func (tp *ACLTemplatedPolicy) ValidateTemplatedPolicy(schema string) error { + if schema == "" { + return nil + } + + loader := gojsonschema.NewStringLoader(schema) + dataloader := gojsonschema.NewGoLoader(tp.TemplateVariables) + res, err := gojsonschema.Validate(loader, dataloader) + if err != nil { + return fmt.Errorf("failed to load json schema for validation %w", err) + } + + if res.Valid() { + return nil + } + + var merr *multierror.Error + + for _, resultError := range res.Errors() { + merr = multierror.Append(merr, fmt.Errorf(resultError.Description())) + } + return merr.ErrorOrNil() +} + +func (tp *ACLTemplatedPolicy) EstimateSize() int { + size := len(tp.TemplateName) + len(tp.TemplateID) + tp.TemplateVariables.EstimateSize() + for _, dc := range tp.Datacenters { + size += len(dc) + } + + return size +} + +func (tv *ACLTemplatedPolicyVariables) EstimateSize() int { + return len(tv.Name) +} + +// SyntheticPolicy generates a policy based on templated policies' ID and variables +// +// Given that we validate this string name before persisting, we do not +// have to escape it before doing the following interpolation. +func (tp *ACLTemplatedPolicy) SyntheticPolicy(entMeta *acl.EnterpriseMeta) (*ACLPolicy, error) { + rules, err := tp.aclTemplatedPolicyRules(entMeta) + if err != nil { + return nil, err + } + hasher := fnv.New128a() + hashID := fmt.Sprintf("%x", hasher.Sum([]byte(rules))) + + policy := &ACLPolicy{ + Rules: rules, + ID: hashID, + Name: fmt.Sprintf("synthetic-policy-%s", hashID), + Datacenters: tp.Datacenters, + Description: fmt.Sprintf("synthetic policy generated from templated policy: %s", tp.TemplateName), + } + policy.EnterpriseMeta.Merge(entMeta) + policy.SetHash(true) + + return policy, nil +} + +func (tp *ACLTemplatedPolicy) aclTemplatedPolicyRules(entMeta *acl.EnterpriseMeta) (string, error) { + if entMeta == nil { + entMeta = DefaultEnterpriseMetaInDefaultPartition() + } + entMeta.Normalize() + + tpl := template.New(tp.TemplateName) + tmplCode, ok := aclTemplatedPoliciesList[tp.TemplateName] + if !ok { + return "", fmt.Errorf("acl templated policy does not exist: %s", tp.TemplateName) + } + + parsedTpl, err := tpl.Parse(tmplCode.Template) + if err != nil { + return "", fmt.Errorf("an error occured when parsing template structs: %w", err) + } + var buf bytes.Buffer + err = parsedTpl.Execute(&buf, struct { + *ACLTemplatedPolicyVariables + Namespace string + Partition string + }{ + Namespace: entMeta.NamespaceOrDefault(), + Partition: entMeta.PartitionOrDefault(), + ACLTemplatedPolicyVariables: tp.TemplateVariables, + }) + if err != nil { + return "", fmt.Errorf("an error occured when executing on templated policy variables: %w", err) + } + + return buf.String(), nil +} + +// Deduplicate returns a new list of templated policies without duplicates. +// compares values of template variables to ensure no duplicates +func (tps ACLTemplatedPolicies) Deduplicate() ACLTemplatedPolicies { + list := make(map[string][]ACLTemplatedPolicyVariables) + var out ACLTemplatedPolicies + + for _, tp := range tps { + // checks if template name already in the unique list + _, found := list[tp.TemplateName] + if !found { + list[tp.TemplateName] = make([]ACLTemplatedPolicyVariables, 0) + } + templateSchema := aclTemplatedPoliciesList[tp.TemplateName].Schema + + // if schema is empty, template does not require variables + if templateSchema == "" { + if !found { + out = append(out, tp) + } + continue + } + + if !slices.Contains(list[tp.TemplateName], *tp.TemplateVariables) { + list[tp.TemplateName] = append(list[tp.TemplateName], *tp.TemplateVariables) + out = append(out, tp) + } + } + + return out +} + +func GetACLTemplatedPolicyBase(templateName string) (*ACLTemplatedPolicyBase, bool) { + baseTemplate, found := aclTemplatedPoliciesList[templateName] + + return baseTemplate, found +} diff --git a/agent/structs/acl_templated_policy_ce.go b/agent/structs/acl_templated_policy_ce.go new file mode 100644 index 0000000000..5645f00281 --- /dev/null +++ b/agent/structs/acl_templated_policy_ce.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !consulent + +package structs + +const ( + ACLTemplatedPolicyService = ` +service "{{.Name}}" { + policy = "write" +} +service "{{.Name}}-sidecar-proxy" { + policy = "write" +} +service_prefix "" { + policy = "read" +} +node_prefix "" { + policy = "read" +}` + + ACLTemplatedPolicyNode = ` +node "{{.Name}}" { + policy = "write" +} +service_prefix "" { + policy = "read" +}` + + ACLTemplatedPolicyDNS = ` +node_prefix "" { + policy = "read" +} +service_prefix "" { + policy = "read" +} +query_prefix "" { + policy = "read" +}` +) + +func (t *ACLToken) TemplatedPolicyList() []*ACLTemplatedPolicy { + if len(t.TemplatedPolicies) == 0 { + return nil + } + + out := make([]*ACLTemplatedPolicy, 0, len(t.TemplatedPolicies)) + for _, n := range t.TemplatedPolicies { + out = append(out, n.Clone()) + } + return out +} + +func (t *ACLRole) TemplatedPolicyList() []*ACLTemplatedPolicy { + if len(t.TemplatedPolicies) == 0 { + return nil + } + + out := make([]*ACLTemplatedPolicy, 0, len(t.TemplatedPolicies)) + for _, n := range t.TemplatedPolicies { + out = append(out, n.Clone()) + } + return out +} diff --git a/agent/structs/acl_templated_policy_ce_test.go b/agent/structs/acl_templated_policy_ce_test.go new file mode 100644 index 0000000000..60cb1f887d --- /dev/null +++ b/agent/structs/acl_templated_policy_ce_test.go @@ -0,0 +1,99 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !consulent + +package structs + +import ( + "testing" + + "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/require" +) + +func TestStructs_ACLTemplatedPolicy_SyntheticPolicy(t *testing.T) { + type testCase struct { + templatedPolicy *ACLTemplatedPolicy + expectedPolicy *ACLPolicy + } + + testCases := map[string]testCase{ + "service-identity-template": { + templatedPolicy: &ACLTemplatedPolicy{ + TemplateID: ACLTemplatedPolicyServiceID, + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + expectedPolicy: &ACLPolicy{ + Description: "synthetic policy generated from templated policy: builtin/service", + Rules: ` +service "api" { + policy = "write" +} +service "api-sidecar-proxy" { + policy = "write" +} +service_prefix "" { + policy = "read" +} +node_prefix "" { + policy = "read" +}`, + }, + }, + "node-identity-template": { + templatedPolicy: &ACLTemplatedPolicy{ + TemplateID: ACLTemplatedPolicyNodeID, + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "web", + }, + }, + expectedPolicy: &ACLPolicy{ + Description: "synthetic policy generated from templated policy: builtin/node", + Rules: ` +node "web" { + policy = "write" +} +service_prefix "" { + policy = "read" +}`, + }, + }, + "dns-template": { + templatedPolicy: &ACLTemplatedPolicy{ + TemplateID: ACLTemplatedPolicyDNSID, + TemplateName: api.ACLTemplatedPolicyDNSName, + }, + expectedPolicy: &ACLPolicy{ + Description: "synthetic policy generated from templated policy: builtin/dns", + Rules: ` +node_prefix "" { + policy = "read" +} +service_prefix "" { + policy = "read" +} +query_prefix "" { + policy = "read" +}`, + }, + }, + } + + for name, tcase := range testCases { + t.Run(name, func(t *testing.T) { + policy, err := tcase.templatedPolicy.SyntheticPolicy(nil) + + require.NoError(t, err) + require.Equal(t, tcase.expectedPolicy.Description, policy.Description) + require.Equal(t, tcase.expectedPolicy.Rules, policy.Rules) + require.Contains(t, policy.Name, "synthetic-policy-") + require.NotEmpty(t, policy.Hash) + require.NotEmpty(t, policy.ID) + }) + } +} diff --git a/agent/structs/acl_templated_policy_test.go b/agent/structs/acl_templated_policy_test.go new file mode 100644 index 0000000000..5d907ca010 --- /dev/null +++ b/agent/structs/acl_templated_policy_test.go @@ -0,0 +1,103 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package structs + +import ( + "testing" + + "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/require" +) + +func TestDeduplicate(t *testing.T) { + type testCase struct { + templatedPolicies ACLTemplatedPolicies + expectedCount int + } + tcases := map[string]testCase{ + "multiple-of-the-same-template": { + templatedPolicies: ACLTemplatedPolicies{ + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + }, + expectedCount: 1, + }, + "separate-templates-with-matching-variables": { + templatedPolicies: ACLTemplatedPolicies{ + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + }, + expectedCount: 2, + }, + "separate-templates-with-multiple-matching-variables": { + templatedPolicies: ACLTemplatedPolicies{ + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "web", + }, + }, + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyDNSName, + }, + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &ACLTemplatedPolicyVariables{ + Name: "web", + }, + }, + &ACLTemplatedPolicy{ + TemplateName: api.ACLTemplatedPolicyDNSName, + }, + }, + expectedCount: 5, + }, + } + + for name, tcase := range tcases { + t.Run(name, func(t *testing.T) { + policies := tcase.templatedPolicies.Deduplicate() + + require.Equal(t, tcase.expectedCount, len(policies)) + }) + } +} diff --git a/api/acl.go b/api/acl.go index 48d2e66ee9..ea9da1efea 100644 --- a/api/acl.go +++ b/api/acl.go @@ -19,6 +19,11 @@ const ( // ACLManagementType is the management type token ACLManagementType = "management" + + // ACLTemplatedPolicy names + ACLTemplatedPolicyServiceName = "builtin/service" + ACLTemplatedPolicyNodeName = "builtin/node" + ACLTemplatedPolicyDNSName = "builtin/dns" ) type ACLLink struct { @@ -40,6 +45,7 @@ type ACLToken struct { Roles []*ACLTokenRoleLink `json:",omitempty"` ServiceIdentities []*ACLServiceIdentity `json:",omitempty"` NodeIdentities []*ACLNodeIdentity `json:",omitempty"` + TemplatedPolicies []*ACLTemplatedPolicy `json:",omitempty"` Local bool AuthMethod string `json:",omitempty"` ExpirationTTL time.Duration `json:",omitempty"` @@ -88,6 +94,7 @@ type ACLTokenListEntry struct { Roles []*ACLTokenRoleLink `json:",omitempty"` ServiceIdentities []*ACLServiceIdentity `json:",omitempty"` NodeIdentities []*ACLNodeIdentity `json:",omitempty"` + TemplatedPolicies []*ACLTemplatedPolicy `json:",omitempty"` Local bool AuthMethod string `json:",omitempty"` ExpirationTime *time.Time `json:",omitempty"` @@ -148,6 +155,21 @@ type ACLNodeIdentity struct { Datacenter string } +// ACLTemplatedPolicy represents a template used to generate a `synthetic` policy +// given some input variables. +type ACLTemplatedPolicy struct { + TemplateName string + TemplateVariables *ACLTemplatedPolicyVariables `json:",omitempty"` + + // Datacenters are an artifact of Nodeidentity & ServiceIdentity. + // It is used to facilitate the future migration away from both + Datacenters []string `json:",omitempty"` +} + +type ACLTemplatedPolicyVariables struct { + Name string +} + // ACLPolicy represents an ACL Policy. type ACLPolicy struct { ID string @@ -196,6 +218,7 @@ type ACLRole struct { Policies []*ACLRolePolicyLink `json:",omitempty"` ServiceIdentities []*ACLServiceIdentity `json:",omitempty"` NodeIdentities []*ACLNodeIdentity `json:",omitempty"` + TemplatedPolicies []*ACLTemplatedPolicy `json:",omitempty"` Hash []byte CreateIndex uint64 ModifyIndex uint64 diff --git a/command/acl/acl_helpers.go b/command/acl/acl_helpers.go index 3459228c61..6766accb28 100644 --- a/command/acl/acl_helpers.go +++ b/command/acl/acl_helpers.go @@ -10,6 +10,9 @@ import ( "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/command/helpers" + "github.com/hashicorp/hcl" + "github.com/mitchellh/mapstructure" ) func GetTokenAccessorIDFromPartial(client *api.Client, partialAccessorID string) (string, error) { @@ -218,6 +221,70 @@ func ExtractNodeIdentities(nodeIdents []string) ([]*api.ACLNodeIdentity, error) return out, nil } +func ExtractTemplatedPolicies(templatedPolicy string, templatedPolicyFile string, templatedPolicyVariables []string) ([]*api.ACLTemplatedPolicy, error) { + var out []*api.ACLTemplatedPolicy + if templatedPolicy == "" && templatedPolicyFile == "" { + return out, nil + } + + if templatedPolicy != "" { + parsedVariables, err := getTemplatedPolicyVariables(templatedPolicyVariables) + if err != nil { + return nil, err + } + + out = append(out, &api.ACLTemplatedPolicy{ + TemplateName: templatedPolicy, + TemplateVariables: parsedVariables, + }) + } + + if templatedPolicyFile != "" { + fileData, err := helpers.LoadFromFile(templatedPolicyFile) + if err != nil { + return nil, err + } + + var config map[string]map[string][]api.ACLTemplatedPolicyVariables + err = hcl.Decode(&config, fileData) + if err != nil { + return nil, err + } + + for templateName, templateVariables := range config["TemplatedPolicy"] { + for _, tp := range templateVariables { + out = append(out, &api.ACLTemplatedPolicy{ + TemplateName: templateName, + TemplateVariables: &api.ACLTemplatedPolicyVariables{ + Name: tp.Name, + }, + }) + } + } + } + return out, nil +} + +func getTemplatedPolicyVariables(variables []string) (*api.ACLTemplatedPolicyVariables, error) { + if len(variables) == 0 { + return nil, nil + } + + out := &api.ACLTemplatedPolicyVariables{} + jsonVariables := make(map[string]string) + + for _, variable := range variables { + parts := strings.Split(variable, ":") + if len(parts) != 2 { + return nil, fmt.Errorf("malformed -var argument: %q, expecting VariableName:Value", variable) + } + jsonVariables[parts[0]] = parts[1] + } + + err := mapstructure.Decode(jsonVariables, out) + return out, err +} + // TestKubernetesJWT_A is a valid service account jwt extracted from a minikube setup. // // { diff --git a/command/acl/policy/create/policy_create.go b/command/acl/policy/create/policy_create.go index 03d836ee4a..a172b17fbb 100644 --- a/command/acl/policy/create/policy_create.go +++ b/command/acl/policy/create/policy_create.go @@ -71,7 +71,7 @@ func (c *cmd) Run(args []string) int { } if c.name == "" { - c.UI.Error(fmt.Sprintf("Missing require '-name' flag")) + c.UI.Error(fmt.Sprintf("Missing required '-name' flag")) c.UI.Error(c.Help()) return 1 } diff --git a/command/acl/role/create/role_create.go b/command/acl/role/create/role_create.go index b951367b2c..b93d25548d 100644 --- a/command/acl/role/create/role_create.go +++ b/command/acl/role/create/role_create.go @@ -28,12 +28,15 @@ type cmd struct { http *flags.HTTPFlags help string - name string - description string - policyIDs []string - policyNames []string - serviceIdents []string - nodeIdents []string + name string + description string + policyIDs []string + policyNames []string + serviceIdents []string + nodeIdents []string + templatedPolicy string + templatedPolicyFile string + templatedPolicyVariables []string showMeta bool format string @@ -55,6 +58,12 @@ func (c *cmd) init() { c.flags.Var((*flags.AppendSliceValue)(&c.nodeIdents), "node-identity", "Name of a "+ "node identity to use for this role. May be specified multiple times. Format is "+ "NODENAME:DATACENTER") + c.flags.Var((*flags.AppendSliceValue)(&c.templatedPolicyVariables), "var", "Templated policy variables."+ + " Must be used in combination with -templated-policy flag to specify required variables."+ + " May be specified multiple times with different variables."+ + " Format is VariableName:Value") + c.flags.StringVar(&c.templatedPolicy, "templated-policy", "", "The templated policy name. Use -var flag to specify variables when required.") + c.flags.StringVar(&c.templatedPolicyFile, "templated-policy-file", "", "Path to a file containing templated policy names and variables.") c.flags.StringVar( &c.format, "format", @@ -74,13 +83,14 @@ func (c *cmd) Run(args []string) int { } if c.name == "" { - c.UI.Error(fmt.Sprintf("Missing require '-name' flag")) + c.UI.Error("Missing required '-name' flag") c.UI.Error(c.Help()) return 1 } - if len(c.policyNames) == 0 && len(c.policyIDs) == 0 && len(c.serviceIdents) == 0 && len(c.nodeIdents) == 0 { - c.UI.Error(fmt.Sprintf("Cannot create a role without specifying -policy-name, -policy-id, -service-identity, or -node-identity at least once")) + if len(c.policyNames) == 0 && len(c.policyIDs) == 0 && len(c.serviceIdents) == 0 && len(c.nodeIdents) == 0 && + len(c.templatedPolicy) == 0 && len(c.templatedPolicyFile) == 0 { + c.UI.Error("Cannot create a role without specifying -policy-name, -policy-id, -service-identity, -node-identity, -templated-policy-file or -templated-policy at least once") return 1 } @@ -124,6 +134,13 @@ func (c *cmd) Run(args []string) int { } newRole.NodeIdentities = parsedNodeIdents + parsedTemplatedPolicies, err := acl.ExtractTemplatedPolicies(c.templatedPolicy, c.templatedPolicyFile, c.templatedPolicyVariables) + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + newRole.TemplatedPolicies = parsedTemplatedPolicies + r, _, err := client.ACL().RoleCreate(newRole, nil) if err != nil { c.UI.Error(fmt.Sprintf("Failed to create new role: %v", err)) @@ -167,5 +184,7 @@ Usage: consul acl role create -name NAME [options] -policy-id b52fc3de-5 \ -policy-name "acl-replication" \ -service-identity "web" \ - -service-identity "db:east,west" + -service-identity "db:east,west" \ + -templated-policy "builtin/service" \ + -var "name:api" ` diff --git a/command/acl/role/formatter.go b/command/acl/role/formatter.go index 0dba147fab..9ead147582 100644 --- a/command/acl/role/formatter.go +++ b/command/acl/role/formatter.go @@ -89,6 +89,20 @@ func (f *prettyFormatter) FormatRole(role *api.ACLRole) (string, error) { buffer.WriteString(fmt.Sprintf(" %s (Datacenter: %s)\n", nodeid.NodeName, nodeid.Datacenter)) } } + if len(role.TemplatedPolicies) > 0 { + buffer.WriteString(fmt.Sprintln("Templated Policies:")) + for _, templatedPolicy := range role.TemplatedPolicies { + buffer.WriteString(fmt.Sprintf(" %s\n", templatedPolicy.TemplateName)) + if templatedPolicy.TemplateVariables != nil && templatedPolicy.TemplateVariables.Name != "" { + buffer.WriteString(fmt.Sprintf(" Name: %s\n", templatedPolicy.TemplateVariables.Name)) + } + if len(templatedPolicy.Datacenters) > 0 { + buffer.WriteString(fmt.Sprintf(" Datacenters: %s\n", strings.Join(templatedPolicy.Datacenters, ", "))) + } else { + buffer.WriteString(" Datacenters: all\n") + } + } + } return buffer.String(), nil } @@ -144,6 +158,21 @@ func (f *prettyFormatter) formatRoleListEntry(role *api.ACLRole) string { } } + if len(role.TemplatedPolicies) > 0 { + buffer.WriteString(fmt.Sprintln(" Templated Policies:")) + for _, templatedPolicy := range role.TemplatedPolicies { + buffer.WriteString(fmt.Sprintf(" %s\n", templatedPolicy.TemplateName)) + if templatedPolicy.TemplateVariables != nil && templatedPolicy.TemplateVariables.Name != "" { + buffer.WriteString(fmt.Sprintf(" Name: %s\n", templatedPolicy.TemplateVariables.Name)) + } + if len(templatedPolicy.Datacenters) > 0 { + buffer.WriteString(fmt.Sprintf(" Datacenters: %s\n", strings.Join(templatedPolicy.Datacenters, ", "))) + } else { + buffer.WriteString(" Datacenters: all\n") + } + } + } + return buffer.String() } diff --git a/command/acl/role/formatter_test.go b/command/acl/role/formatter_test.go index ac6be59b42..3c1ecaeddc 100644 --- a/command/acl/role/formatter_test.go +++ b/command/acl/role/formatter_test.go @@ -83,6 +83,14 @@ func TestFormatRole(t *testing.T) { Datacenter: "middleearth-northwest", }, }, + TemplatedPolicies: []*api.ACLTemplatedPolicy{ + { + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "gardener"}, + Datacenters: []string{"middleearth-northwest", "somewhere-east"}, + }, + {TemplateName: api.ACLTemplatedPolicyNodeName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "bagend"}}, + }, }, }, } @@ -114,7 +122,7 @@ func TestFormatRole(t *testing.T) { } } -func TestFormatTokenList(t *testing.T) { +func TestFormatRoleList(t *testing.T) { type testCase struct { roles []*api.ACLRole overrideGoldenName string @@ -165,6 +173,10 @@ func TestFormatTokenList(t *testing.T) { Datacenter: "middleearth-northwest", }, }, + TemplatedPolicies: []*api.ACLTemplatedPolicy{ + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "gardener"}}, + {TemplateName: api.ACLTemplatedPolicyNodeName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "bagend"}}, + }, }, }, }, diff --git a/command/acl/role/testdata/FormatRole/complex.json.golden b/command/acl/role/testdata/FormatRole/complex.json.golden index b8d9b64950..8b22fcb3b7 100644 --- a/command/acl/role/testdata/FormatRole/complex.json.golden +++ b/command/acl/role/testdata/FormatRole/complex.json.golden @@ -26,6 +26,24 @@ "Datacenter": "middleearth-northwest" } ], + "TemplatedPolicies": [ + { + "TemplateName": "builtin/service", + "TemplateVariables": { + "Name": "gardener" + }, + "Datacenters": [ + "middleearth-northwest", + "somewhere-east" + ] + }, + { + "TemplateName": "builtin/node", + "TemplateVariables": { + "Name": "bagend" + } + } + ], "Hash": "YWJjZGVmZ2g=", "CreateIndex": 5, "ModifyIndex": 10, diff --git a/command/acl/role/testdata/FormatRole/complex.pretty-meta.golden b/command/acl/role/testdata/FormatRole/complex.pretty-meta.golden index 56e61f7780..174d3eb2b8 100644 --- a/command/acl/role/testdata/FormatRole/complex.pretty-meta.golden +++ b/command/acl/role/testdata/FormatRole/complex.pretty-meta.golden @@ -12,3 +12,10 @@ Service Identities: gardener (Datacenters: middleearth-northwest) Node Identities: bagend (Datacenter: middleearth-northwest) +Templated Policies: + builtin/service + Name: gardener + Datacenters: middleearth-northwest, somewhere-east + builtin/node + Name: bagend + Datacenters: all diff --git a/command/acl/role/testdata/FormatRole/complex.pretty.golden b/command/acl/role/testdata/FormatRole/complex.pretty.golden index b3d347ed00..54402c34e4 100644 --- a/command/acl/role/testdata/FormatRole/complex.pretty.golden +++ b/command/acl/role/testdata/FormatRole/complex.pretty.golden @@ -9,3 +9,10 @@ Service Identities: gardener (Datacenters: middleearth-northwest) Node Identities: bagend (Datacenter: middleearth-northwest) +Templated Policies: + builtin/service + Name: gardener + Datacenters: middleearth-northwest, somewhere-east + builtin/node + Name: bagend + Datacenters: all diff --git a/command/acl/role/testdata/FormatRoleList/complex.json.golden b/command/acl/role/testdata/FormatRoleList/complex.json.golden index 58c6d850b8..2d448667a5 100644 --- a/command/acl/role/testdata/FormatRoleList/complex.json.golden +++ b/command/acl/role/testdata/FormatRoleList/complex.json.golden @@ -27,6 +27,20 @@ "Datacenter": "middleearth-northwest" } ], + "TemplatedPolicies": [ + { + "TemplateName": "builtin/service", + "TemplateVariables": { + "Name": "gardener" + } + }, + { + "TemplateName": "builtin/node", + "TemplateVariables": { + "Name": "bagend" + } + } + ], "Hash": "YWJjZGVmZ2g=", "CreateIndex": 5, "ModifyIndex": 10, diff --git a/command/acl/role/testdata/FormatRoleList/complex.pretty-meta.golden b/command/acl/role/testdata/FormatRoleList/complex.pretty-meta.golden index 76355e91dd..842482b4e2 100644 --- a/command/acl/role/testdata/FormatRoleList/complex.pretty-meta.golden +++ b/command/acl/role/testdata/FormatRoleList/complex.pretty-meta.golden @@ -12,3 +12,10 @@ complex: gardener (Datacenters: middleearth-northwest) Node Identities: bagend (Datacenter: middleearth-northwest) + Templated Policies: + builtin/service + Name: gardener + Datacenters: all + builtin/node + Name: bagend + Datacenters: all diff --git a/command/acl/role/testdata/FormatRoleList/complex.pretty.golden b/command/acl/role/testdata/FormatRoleList/complex.pretty.golden index ab2b0c87f8..a3907e4d97 100644 --- a/command/acl/role/testdata/FormatRoleList/complex.pretty.golden +++ b/command/acl/role/testdata/FormatRoleList/complex.pretty.golden @@ -9,3 +9,10 @@ complex: gardener (Datacenters: middleearth-northwest) Node Identities: bagend (Datacenter: middleearth-northwest) + Templated Policies: + builtin/service + Name: gardener + Datacenters: all + builtin/node + Name: bagend + Datacenters: all diff --git a/command/acl/role/update/role_update.go b/command/acl/role/update/role_update.go index 731bfb1726..fa1ac4176f 100644 --- a/command/acl/role/update/role_update.go +++ b/command/acl/role/update/role_update.go @@ -28,13 +28,18 @@ type cmd struct { http *flags.HTTPFlags help string - roleID string - name string - description string - policyIDs []string - policyNames []string - serviceIdents []string - nodeIdents []string + roleID string + name string + description string + policyIDs []string + policyNames []string + serviceIdents []string + nodeIdents []string + appendTemplatedPolicy string + replaceTemplatedPolicy string + appendTemplatedPolicyFile string + replaceTemplatedPolicyFile string + templatedPolicyVariables []string noMerge bool showMeta bool @@ -69,6 +74,16 @@ func (c *cmd) init() { role.PrettyFormat, fmt.Sprintf("Output format {%s}", strings.Join(role.GetSupportedFormats(), "|")), ) + c.flags.Var((*flags.AppendSliceValue)(&c.templatedPolicyVariables), "var", "Templated policy variables."+ + " Must be used in combination with -append-templated-policy or -replace-templated-policy flags to specify required variables."+ + " May be specified multiple times with different variables."+ + " Format is VariableName:Value") + c.flags.StringVar(&c.appendTemplatedPolicy, "append-templated-policy", "", "The templated policy name to attach to the role's existing templated policies list. Use -var flag to specify variables when required."+ + " The role retains existing templated policies.") + c.flags.StringVar(&c.replaceTemplatedPolicy, "replace-templated-policy", "", "The templated policy name to replace the existing templated policies list with. Use -var flag to specify variables when required."+ + " Overwrites the role's existing templated policies.") + c.flags.StringVar(&c.appendTemplatedPolicyFile, "append-templated-policy-file", "", "Path to a file containing templated policies and variables. Works like `-append-templated-policy`. The role retains existing templated policies.") + c.flags.StringVar(&c.replaceTemplatedPolicyFile, "replace-templated-policy-file", "", "Path to a file containing templated policies and variables. Works like `-replace-templated-policy`. Overwrites the role's existing templated policies.") c.http = &flags.HTTPFlags{} flags.Merge(c.flags, c.http.ClientFlags()) @@ -111,6 +126,24 @@ func (c *cmd) Run(args []string) int { return 1 } + hasAppendTemplatedPolicies := len(c.appendTemplatedPolicy) > 0 || len(c.appendTemplatedPolicyFile) > 0 + hasReplaceTemplatedPolicies := len(c.replaceTemplatedPolicy) > 0 || len(c.replaceTemplatedPolicyFile) > 0 + + if hasReplaceTemplatedPolicies && hasAppendTemplatedPolicies { + c.UI.Error("Cannot combine the use of append-templated-policy flags with replace-templated-policy. " + + "To set or overwrite existing templated policies, use -replace-templated-policy or -replace-templated-policy-file. " + + "To append to existing templated policies, use -append-templated-policy or -append-templated-policy-file.") + return 1 + } + parsedTemplatedPolicies, err := acl.ExtractTemplatedPolicies(c.replaceTemplatedPolicy, c.replaceTemplatedPolicyFile, c.templatedPolicyVariables) + if hasAppendTemplatedPolicies { + parsedTemplatedPolicies, err = acl.ExtractTemplatedPolicies(c.appendTemplatedPolicy, c.appendTemplatedPolicyFile, c.templatedPolicyVariables) + } + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + // Read the current role in both cases so we can fail better if not found. currentRole, _, err := client.ACL().RoleRead(roleID, nil) if err != nil { @@ -129,6 +162,7 @@ func (c *cmd) Run(args []string) int { Description: c.description, ServiceIdentities: parsedServiceIdents, NodeIdentities: parsedNodeIdents, + TemplatedPolicies: parsedTemplatedPolicies, } for _, policyName := range c.policyNames { @@ -221,6 +255,12 @@ func (c *cmd) Run(args []string) int { r.NodeIdentities = append(r.NodeIdentities, nodeid) } } + + if hasReplaceTemplatedPolicies { + r.TemplatedPolicies = parsedTemplatedPolicies + } else { + r.TemplatedPolicies = append(r.TemplatedPolicies, parsedTemplatedPolicies...) + } } r, _, err = client.ACL().RoleUpdate(r, nil) @@ -273,6 +313,8 @@ Usage: consul acl role update [options] -name "better-name" \ -description "replication" \ -policy-name "token-replication" \ - -service-identity "web" + -service-identity "web" \ + -templated-policy "builtin/service" \ + -var "name:api" ` ) diff --git a/command/acl/role/update/role_update_test.go b/command/acl/role/update/role_update_test.go index 07c4cb2d2d..e9868f8c20 100644 --- a/command/acl/role/update/role_update_test.go +++ b/command/acl/role/update/role_update_test.go @@ -68,6 +68,9 @@ func TestRoleUpdateCommand(t *testing.T) { ServiceName: "fake", }, }, + TemplatedPolicies: []*api.ACLTemplatedPolicy{ + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "fake"}}, + }, }, &api.WriteOptions{Token: "root"}, ) @@ -122,6 +125,7 @@ func TestRoleUpdateCommand(t *testing.T) { require.Equal(t, "test role edited", role.Description) require.Len(t, role.Policies, 1) require.Len(t, role.ServiceIdentities, 1) + require.Len(t, role.TemplatedPolicies, 1) }) t.Run("update with policy by id", func(t *testing.T) { @@ -198,6 +202,47 @@ func TestRoleUpdateCommand(t *testing.T) { require.Len(t, role.ServiceIdentities, 3) require.Len(t, role.NodeIdentities, 1) }) + t.Run("update with append templated policies", func(t *testing.T) { + _ = run(t, []string{ + "-id=" + role.ID, + "-token=root", + "-append-templated-policy=builtin/service", + "-var=name:api", + }) + + role, _, err := client.ACL().RoleRead( + role.ID, + &api.QueryOptions{Token: "root"}, + ) + require.NoError(t, err) + require.NotNil(t, role) + require.Equal(t, "test role edited", role.Description) + require.Len(t, role.Policies, 2) + require.Len(t, role.ServiceIdentities, 3) + require.Len(t, role.NodeIdentities, 1) + require.Len(t, role.TemplatedPolicies, 2) + }) + + t.Run("update with replace templated policies", func(t *testing.T) { + _ = run(t, []string{ + "-id=" + role.ID, + "-token=root", + "-replace-templated-policy=builtin/service", + "-var=name:api", + }) + + role, _, err := client.ACL().RoleRead( + role.ID, + &api.QueryOptions{Token: "root"}, + ) + require.NoError(t, err) + require.NotNil(t, role) + require.Equal(t, "test role edited", role.Description) + require.Len(t, role.Policies, 2) + require.Len(t, role.ServiceIdentities, 3) + require.Len(t, role.NodeIdentities, 1) + require.Len(t, role.TemplatedPolicies, 1) + }) } func TestRoleUpdateCommand_JSON(t *testing.T) { @@ -335,6 +380,9 @@ func TestRoleUpdateCommand_noMerge(t *testing.T) { ServiceName: "fake", }, }, + TemplatedPolicies: []*api.ACLTemplatedPolicy{ + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "fake"}}, + }, Policies: []*api.ACLRolePolicyLink{ { ID: policy3.ID, @@ -482,4 +530,64 @@ func TestRoleUpdateCommand_noMerge(t *testing.T) { require.Len(t, role.Policies, 0) require.Len(t, role.ServiceIdentities, 1) }) + + t.Run("update with templated policy append", func(t *testing.T) { + role := createRole(t) + + ui := cli.NewMockUi() + cmd := New(ui) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-id=" + role.ID, + "-name=" + role.Name, + "-token=root", + "-no-merge", + "-append-templated-policy=builtin/service", + "-var=name:api", + } + + code := cmd.Run(args) + require.Equal(t, code, 0, "err: %s", ui.ErrorWriter.String()) + require.Empty(t, ui.ErrorWriter.String()) + + role, _, err := client.ACL().RoleRead( + role.ID, + &api.QueryOptions{Token: "root"}, + ) + require.NoError(t, err) + require.NotNil(t, role) + require.Equal(t, "", role.Description) + require.Len(t, role.Policies, 0) + require.Len(t, role.TemplatedPolicies, 1) + }) + + t.Run("update with replace templated policy", func(t *testing.T) { + role := createRole(t) + + ui := cli.NewMockUi() + cmd := New(ui) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-id=" + role.ID, + "-name=" + role.Name, + "-token=root", + "-no-merge", + "-replace-templated-policy=builtin/service", + "-var=name:api", + } + + code := cmd.Run(args) + require.Equal(t, code, 0, "err: %s", ui.ErrorWriter.String()) + require.Empty(t, ui.ErrorWriter.String()) + + role, _, err := client.ACL().RoleRead( + role.ID, + &api.QueryOptions{Token: "root"}, + ) + require.NoError(t, err) + require.NotNil(t, role) + require.Equal(t, "", role.Description) + require.Len(t, role.Policies, 0) + require.Len(t, role.TemplatedPolicies, 1) + }) } diff --git a/command/acl/token/clone/token_clone_test.go b/command/acl/token/clone/token_clone_test.go index 7181ac0cae..8fb729e40c 100644 --- a/command/acl/token/clone/token_clone_test.go +++ b/command/acl/token/clone/token_clone_test.go @@ -165,6 +165,7 @@ func TestTokenCloneCommand_Pretty(t *testing.T) { require.Equal(t, cloned.Description, apiToken.Description) require.Equal(t, cloned.Local, apiToken.Local) require.Equal(t, cloned.Policies, apiToken.Policies) + require.Equal(t, cloned.TemplatedPolicies, apiToken.TemplatedPolicies) }) } @@ -198,7 +199,13 @@ func TestTokenCloneCommand_JSON(t *testing.T) { // create a token token, _, err := client.ACL().TokenCreate( - &api.ACLToken{Description: "test", Policies: []*api.ACLTokenPolicyLink{{Name: "test-policy"}}}, + &api.ACLToken{ + Description: "test", + Policies: []*api.ACLTokenPolicyLink{{Name: "test-policy"}}, + TemplatedPolicies: []*api.ACLTemplatedPolicy{ + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "web"}}, + }, + }, &api.WriteOptions{Token: "root"}, ) require.NoError(t, err) diff --git a/command/acl/token/create/token_create.go b/command/acl/token/create/token_create.go index 25c00df605..bb6e524167 100644 --- a/command/acl/token/create/token_create.go +++ b/command/acl/token/create/token_create.go @@ -29,19 +29,22 @@ type cmd struct { http *flags.HTTPFlags help string - accessor string - secret string - policyIDs []string - policyNames []string - description string - roleIDs []string - roleNames []string - serviceIdents []string - nodeIdents []string - expirationTTL time.Duration - local bool - showMeta bool - format string + accessor string + secret string + policyIDs []string + policyNames []string + description string + roleIDs []string + roleNames []string + serviceIdents []string + nodeIdents []string + templatedPolicy string + templatedPolicyFile string + templatedPolicyVariables []string + expirationTTL time.Duration + local bool + showMeta bool + format string } func (c *cmd) init() { @@ -76,6 +79,12 @@ func (c *cmd) init() { token.PrettyFormat, fmt.Sprintf("Output format {%s}", strings.Join(token.GetSupportedFormats(), "|")), ) + c.flags.Var((*flags.AppendSliceValue)(&c.templatedPolicyVariables), "var", "Templated policy variables."+ + " Must be used in combination with -templated-policy flag to specify required variables."+ + " May be specified multiple times with different variables."+ + " Format is VariableName:Value") + c.flags.StringVar(&c.templatedPolicy, "templated-policy", "", "The templated policy name. Use -var flag to specify variables when required.") + c.flags.StringVar(&c.templatedPolicyFile, "templated-policy-file", "", "Path to a file containing templated policies and variables.") c.http = &flags.HTTPFlags{} flags.Merge(c.flags, c.http.ClientFlags()) flags.Merge(c.flags, c.http.ServerFlags()) @@ -90,8 +99,9 @@ func (c *cmd) Run(args []string) int { if len(c.policyNames) == 0 && len(c.policyIDs) == 0 && len(c.roleNames) == 0 && len(c.roleIDs) == 0 && - len(c.serviceIdents) == 0 && len(c.nodeIdents) == 0 { - c.UI.Error(fmt.Sprintf("Cannot create a token without specifying -policy-name, -policy-id, -role-name, -role-id, -service-identity, or -node-identity at least once")) + len(c.serviceIdents) == 0 && len(c.nodeIdents) == 0 && + len(c.templatedPolicy) == 0 && len(c.templatedPolicyFile) == 0 { + c.UI.Error("Cannot create a token without specifying -policy-name, -policy-id, -role-name, -role-id, -service-identity, -node-identity, -templated-policy, or -templated-policy-file at least once") return 1 } @@ -125,6 +135,13 @@ func (c *cmd) Run(args []string) int { } newToken.NodeIdentities = parsedNodeIdents + parsedTemplatedPolicies, err := acl.ExtractTemplatedPolicies(c.templatedPolicy, c.templatedPolicyFile, c.templatedPolicyVariables) + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + newToken.TemplatedPolicies = parsedTemplatedPolicies + for _, policyName := range c.policyNames { // We could resolve names to IDs here but there isn't any reason why its would be better // than allowing the agent to do it. @@ -203,6 +220,8 @@ Usage: consul acl token create [options] -role-id c630d4ef-6 \ -role-name "db-updater" \ -service-identity "web" \ - -service-identity "db:east,west" + -service-identity "db:east,west" \ + -templated-policy "builtin/service" \ + -var "name:web" ` ) diff --git a/command/acl/token/create/token_create_test.go b/command/acl/token/create/token_create_test.go index b92c64b0d1..90471b5a5b 100644 --- a/command/acl/token/create/token_create_test.go +++ b/command/acl/token/create/token_create_test.go @@ -107,6 +107,27 @@ func TestTokenCreateCommand_Pretty(t *testing.T) { require.Equal(t, a.Config.NodeName, nodes[0].Node) }) + // templated policy + t.Run("templated-policy", func(t *testing.T) { + token := run(t, []string{ + "-http-addr=" + a.HTTPAddr(), + "-token=root", + "-templated-policy=builtin/node", + "-var=name:" + a.Config.NodeName, + }) + + conf := api.DefaultConfig() + conf.Address = a.HTTPAddr() + conf.Token = token.SecretID + client, err := api.NewClient(conf) + require.NoError(t, err) + + nodes, _, err := client.Catalog().Nodes(nil) + require.NoError(t, err) + require.Len(t, nodes, 1) + require.Equal(t, a.Config.NodeName, nodes[0].Node) + }) + // create with accessor and secret t.Run("predefined-ids", func(t *testing.T) { token := run(t, []string{ diff --git a/command/acl/token/formatter.go b/command/acl/token/formatter.go index 6dec483aad..5325d285db 100644 --- a/command/acl/token/formatter.go +++ b/command/acl/token/formatter.go @@ -109,6 +109,20 @@ func (f *prettyFormatter) FormatToken(token *api.ACLToken) (string, error) { buffer.WriteString(fmt.Sprintf(" %s (Datacenter: %s)\n", nodeid.NodeName, nodeid.Datacenter)) } } + if len(token.TemplatedPolicies) > 0 { + buffer.WriteString(fmt.Sprintln("Templated Policies:")) + for _, templatedPolicy := range token.TemplatedPolicies { + buffer.WriteString(fmt.Sprintf(" %s\n", templatedPolicy.TemplateName)) + if templatedPolicy.TemplateVariables != nil && templatedPolicy.TemplateVariables.Name != "" { + buffer.WriteString(fmt.Sprintf(" Name: %s\n", templatedPolicy.TemplateVariables.Name)) + } + if len(templatedPolicy.Datacenters) > 0 { + buffer.WriteString(fmt.Sprintf(" Datacenters: %s\n", strings.Join(templatedPolicy.Datacenters, ", "))) + } else { + buffer.WriteString(" Datacenters: all\n") + } + } + } return buffer.String(), nil } @@ -174,10 +188,7 @@ func (f *prettyFormatter) FormatTokenExpanded(token *api.ACLTokenExpanded) (stri } identity := structs.ACLServiceIdentity{ServiceName: svcIdentity.ServiceName, Datacenters: svcIdentity.Datacenters} policy := identity.SyntheticPolicy(&entMeta) - buffer.WriteString(fmt.Sprintf(indent+WHITESPACE_2+"Description: %s\n", policy.Description)) - buffer.WriteString(indent + WHITESPACE_2 + "Rules:") - buffer.WriteString(strings.ReplaceAll(policy.Rules, "\n", "\n"+indent+WHITESPACE_4)) - buffer.WriteString("\n\n") + displaySyntheticPolicy(policy, &buffer, indent) } if len(token.ACLToken.ServiceIdentities) > 0 { buffer.WriteString("Service Identities:\n") @@ -190,10 +201,7 @@ func (f *prettyFormatter) FormatTokenExpanded(token *api.ACLTokenExpanded) (stri buffer.WriteString(fmt.Sprintf(indent+"Name: %s (Datacenter: %s)\n", nodeIdentity.NodeName, nodeIdentity.Datacenter)) identity := structs.ACLNodeIdentity{NodeName: nodeIdentity.NodeName, Datacenter: nodeIdentity.Datacenter} policy := identity.SyntheticPolicy(&entMeta) - buffer.WriteString(fmt.Sprintf(indent+WHITESPACE_2+"Description: %s\n", policy.Description)) - buffer.WriteString(indent + WHITESPACE_2 + "Rules:") - buffer.WriteString(strings.ReplaceAll(policy.Rules, "\n", "\n"+indent+WHITESPACE_4)) - buffer.WriteString("\n\n") + displaySyntheticPolicy(policy, &buffer, indent) } if len(token.ACLToken.NodeIdentities) > 0 { buffer.WriteString("Node Identities:\n") @@ -202,6 +210,34 @@ func (f *prettyFormatter) FormatTokenExpanded(token *api.ACLTokenExpanded) (stri } } + formatTemplatedPolicy := func(templatedPolicy *api.ACLTemplatedPolicy, indent string) { + buffer.WriteString(fmt.Sprintf(indent+"%s\n", templatedPolicy.TemplateName)) + tp := structs.ACLTemplatedPolicy{ + TemplateName: templatedPolicy.TemplateName, + Datacenters: templatedPolicy.Datacenters, + } + if templatedPolicy.TemplateVariables != nil && templatedPolicy.TemplateVariables.Name != "" { + tp.TemplateVariables = &structs.ACLTemplatedPolicyVariables{ + Name: templatedPolicy.TemplateVariables.Name, + } + buffer.WriteString(fmt.Sprintf(indent+WHITESPACE_2+"Name: %s\n", templatedPolicy.TemplateVariables.Name)) + } + if len(templatedPolicy.Datacenters) > 0 { + buffer.WriteString(fmt.Sprintf(indent+WHITESPACE_2+"Datacenters: %s\n", strings.Join(templatedPolicy.Datacenters, ", "))) + } else { + buffer.WriteString(fmt.Sprintf(indent + WHITESPACE_2 + "Datacenters: all\n")) + } + policy, _ := tp.SyntheticPolicy(&entMeta) + displaySyntheticPolicy(policy, &buffer, indent) + } + if len(token.ACLToken.TemplatedPolicies) > 0 { + buffer.WriteString("Templated Policies:\n") + + for _, templatedPolicy := range token.ACLToken.TemplatedPolicies { + formatTemplatedPolicy(templatedPolicy, WHITESPACE_2) + } + } + formatRole := func(role api.ACLRole, indent string) { buffer.WriteString(fmt.Sprintf(indent+"Role Name: %s\n", role.Name)) buffer.WriteString(fmt.Sprintf(indent+WHITESPACE_2+"ID: %s\n", role.ID)) @@ -266,6 +302,13 @@ func (f *prettyFormatter) FormatTokenExpanded(token *api.ACLTokenExpanded) (stri return buffer.String(), nil } +func displaySyntheticPolicy(policy *structs.ACLPolicy, buffer *bytes.Buffer, indent string) { + buffer.WriteString(fmt.Sprintf(indent+WHITESPACE_2+"Description: %s\n", policy.Description)) + buffer.WriteString(indent + WHITESPACE_2 + "Rules:") + buffer.WriteString(strings.ReplaceAll(policy.Rules, "\n", "\n"+indent+WHITESPACE_4)) + buffer.WriteString("\n\n") +} + func (f *prettyFormatter) FormatTokenList(tokens []*api.ACLTokenListEntry) (string, error) { var buffer bytes.Buffer @@ -335,6 +378,21 @@ func (f *prettyFormatter) formatTokenListEntry(token *api.ACLTokenListEntry) str buffer.WriteString(fmt.Sprintf(" %s (Datacenter: %s)\n", nodeid.NodeName, nodeid.Datacenter)) } } + + if len(token.TemplatedPolicies) > 0 { + buffer.WriteString(fmt.Sprintln("Templated Policies:")) + for _, templatedPolicy := range token.TemplatedPolicies { + buffer.WriteString(fmt.Sprintf(" %s\n", templatedPolicy.TemplateName)) + if templatedPolicy.TemplateVariables != nil && templatedPolicy.TemplateVariables.Name != "" { + buffer.WriteString(fmt.Sprintf(" Name: %s\n", templatedPolicy.TemplateVariables.Name)) + } + if len(templatedPolicy.Datacenters) > 0 { + buffer.WriteString(fmt.Sprintf(" Datacenters: %s\n", strings.Join(templatedPolicy.Datacenters, ", "))) + } else { + buffer.WriteString(" Datacenters: all\n") + } + } + } return buffer.String() } diff --git a/command/acl/token/formatter_test.go b/command/acl/token/formatter_test.go index ba4c04981d..65be4dfae9 100644 --- a/command/acl/token/formatter_test.go +++ b/command/acl/token/formatter_test.go @@ -106,6 +106,21 @@ func TestFormatToken(t *testing.T) { Datacenter: "middleearth-northwest", }, }, + TemplatedPolicies: []*api.ACLTemplatedPolicy{ + { + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &api.ACLTemplatedPolicyVariables{ + Name: "web", + }, + Datacenters: []string{"middleearth-northwest", "somewhere-east"}, + }, + { + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &api.ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + }, }, }, } @@ -209,6 +224,21 @@ func TestFormatTokenList(t *testing.T) { Datacenter: "middleearth-northwest", }, }, + TemplatedPolicies: []*api.ACLTemplatedPolicy{ + { + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &api.ACLTemplatedPolicyVariables{ + Name: "web", + }, + Datacenters: []string{"middleearth-northwest"}, + }, + { + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &api.ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + }, }, }, }, @@ -444,6 +474,21 @@ var expandedTokenTestCases = map[string]testCase{ Datacenter: "middleearth-northwest", }, }, + TemplatedPolicies: []*api.ACLTemplatedPolicy{ + { + TemplateName: api.ACLTemplatedPolicyServiceName, + TemplateVariables: &api.ACLTemplatedPolicyVariables{ + Name: "web", + }, + Datacenters: []string{"middleearth-northwest"}, + }, + { + TemplateName: api.ACLTemplatedPolicyNodeName, + TemplateVariables: &api.ACLTemplatedPolicyVariables{ + Name: "api", + }, + }, + }, }, }, }, diff --git a/command/acl/token/testdata/FormatToken/complex.json.golden b/command/acl/token/testdata/FormatToken/complex.json.golden index 26e2b0144c..f3e3baef55 100644 --- a/command/acl/token/testdata/FormatToken/complex.json.golden +++ b/command/acl/token/testdata/FormatToken/complex.json.golden @@ -38,6 +38,24 @@ "Datacenter": "middleearth-northwest" } ], + "TemplatedPolicies": [ + { + "TemplateName": "builtin/service", + "TemplateVariables": { + "Name": "web" + }, + "Datacenters": [ + "middleearth-northwest", + "somewhere-east" + ] + }, + { + "TemplateName": "builtin/node", + "TemplateVariables": { + "Name": "api" + } + } + ], "Local": false, "AuthMethod": "bar", "ExpirationTime": "2020-05-22T19:52:31Z", diff --git a/command/acl/token/testdata/FormatToken/complex.pretty-meta.golden b/command/acl/token/testdata/FormatToken/complex.pretty-meta.golden index b5b33dadc5..1644a72a66 100644 --- a/command/acl/token/testdata/FormatToken/complex.pretty-meta.golden +++ b/command/acl/token/testdata/FormatToken/complex.pretty-meta.golden @@ -19,3 +19,10 @@ Service Identities: gardener (Datacenters: middleearth-northwest) Node Identities: bagend (Datacenter: middleearth-northwest) +Templated Policies: + builtin/service + Name: web + Datacenters: middleearth-northwest, somewhere-east + builtin/node + Name: api + Datacenters: all diff --git a/command/acl/token/testdata/FormatToken/complex.pretty.golden b/command/acl/token/testdata/FormatToken/complex.pretty.golden index 5d649f35a8..6d25b40ea0 100644 --- a/command/acl/token/testdata/FormatToken/complex.pretty.golden +++ b/command/acl/token/testdata/FormatToken/complex.pretty.golden @@ -16,3 +16,10 @@ Service Identities: gardener (Datacenters: middleearth-northwest) Node Identities: bagend (Datacenter: middleearth-northwest) +Templated Policies: + builtin/service + Name: web + Datacenters: middleearth-northwest, somewhere-east + builtin/node + Name: api + Datacenters: all diff --git a/command/acl/token/testdata/FormatTokenExpanded/ce/complex.json.golden b/command/acl/token/testdata/FormatTokenExpanded/ce/complex.json.golden index b0ed45c0d3..3eddfdc5df 100644 --- a/command/acl/token/testdata/FormatTokenExpanded/ce/complex.json.golden +++ b/command/acl/token/testdata/FormatTokenExpanded/ce/complex.json.golden @@ -181,6 +181,23 @@ "Datacenter": "middleearth-northwest" } ], + "TemplatedPolicies": [ + { + "TemplateName": "builtin/service", + "TemplateVariables": { + "Name": "web" + }, + "Datacenters": [ + "middleearth-northwest" + ] + }, + { + "TemplateName": "builtin/node", + "TemplateVariables": { + "Name": "api" + } + } + ], "Local": false, "AuthMethod": "bar", "ExpirationTime": "2020-05-22T19:52:31Z", diff --git a/command/acl/token/testdata/FormatTokenExpanded/ce/complex.pretty-meta.golden b/command/acl/token/testdata/FormatTokenExpanded/ce/complex.pretty-meta.golden index bc8033edf9..64cfcf0f64 100644 --- a/command/acl/token/testdata/FormatTokenExpanded/ce/complex.pretty-meta.golden +++ b/command/acl/token/testdata/FormatTokenExpanded/ce/complex.pretty-meta.golden @@ -52,6 +52,37 @@ Node Identities: policy = "read" } +Templated Policies: + builtin/service + Name: web + Datacenters: middleearth-northwest + Description: synthetic policy generated from templated policy: builtin/service + Rules: + service "web" { + policy = "write" + } + service "web-sidecar-proxy" { + policy = "write" + } + service_prefix "" { + policy = "read" + } + node_prefix "" { + policy = "read" + } + + builtin/node + Name: api + Datacenters: all + Description: synthetic policy generated from templated policy: builtin/node + Rules: + node "api" { + policy = "write" + } + service_prefix "" { + policy = "read" + } + Roles: Role Name: shire ID: 3b0a78fe-b9c3-40de-b8ea-7d4d6674b366 diff --git a/command/acl/token/testdata/FormatTokenExpanded/ce/complex.pretty.golden b/command/acl/token/testdata/FormatTokenExpanded/ce/complex.pretty.golden index 215cf8b7a0..18e97e891a 100644 --- a/command/acl/token/testdata/FormatTokenExpanded/ce/complex.pretty.golden +++ b/command/acl/token/testdata/FormatTokenExpanded/ce/complex.pretty.golden @@ -49,6 +49,37 @@ Node Identities: policy = "read" } +Templated Policies: + builtin/service + Name: web + Datacenters: middleearth-northwest + Description: synthetic policy generated from templated policy: builtin/service + Rules: + service "web" { + policy = "write" + } + service "web-sidecar-proxy" { + policy = "write" + } + service_prefix "" { + policy = "read" + } + node_prefix "" { + policy = "read" + } + + builtin/node + Name: api + Datacenters: all + Description: synthetic policy generated from templated policy: builtin/node + Rules: + node "api" { + policy = "write" + } + service_prefix "" { + policy = "read" + } + Roles: Role Name: shire ID: 3b0a78fe-b9c3-40de-b8ea-7d4d6674b366 diff --git a/command/acl/token/testdata/FormatTokenList/complex.json.golden b/command/acl/token/testdata/FormatTokenList/complex.json.golden index 8bddb0f133..9e06b6c8ff 100644 --- a/command/acl/token/testdata/FormatTokenList/complex.json.golden +++ b/command/acl/token/testdata/FormatTokenList/complex.json.golden @@ -39,6 +39,23 @@ "Datacenter": "middleearth-northwest" } ], + "TemplatedPolicies": [ + { + "TemplateName": "builtin/service", + "TemplateVariables": { + "Name": "web" + }, + "Datacenters": [ + "middleearth-northwest" + ] + }, + { + "TemplateName": "builtin/node", + "TemplateVariables": { + "Name": "api" + } + } + ], "Local": false, "AuthMethod": "bar", "ExpirationTime": "2020-05-22T19:52:31Z", diff --git a/command/acl/token/testdata/FormatTokenList/complex.pretty-meta.golden b/command/acl/token/testdata/FormatTokenList/complex.pretty-meta.golden index c14bd70580..db4f2a8ce7 100644 --- a/command/acl/token/testdata/FormatTokenList/complex.pretty-meta.golden +++ b/command/acl/token/testdata/FormatTokenList/complex.pretty-meta.golden @@ -19,3 +19,10 @@ Service Identities: gardener (Datacenters: middleearth-northwest) Node Identities: bagend (Datacenter: middleearth-northwest) +Templated Policies: + builtin/service + Name: web + Datacenters: middleearth-northwest + builtin/node + Name: api + Datacenters: all diff --git a/command/acl/token/testdata/FormatTokenList/complex.pretty.golden b/command/acl/token/testdata/FormatTokenList/complex.pretty.golden index 4a3c011743..dd6fcf3ac2 100644 --- a/command/acl/token/testdata/FormatTokenList/complex.pretty.golden +++ b/command/acl/token/testdata/FormatTokenList/complex.pretty.golden @@ -16,3 +16,10 @@ Service Identities: gardener (Datacenters: middleearth-northwest) Node Identities: bagend (Datacenter: middleearth-northwest) +Templated Policies: + builtin/service + Name: web + Datacenters: middleearth-northwest + builtin/node + Name: api + Datacenters: all diff --git a/command/acl/token/update/token_update.go b/command/acl/token/update/token_update.go index 9d636ba215..dec85c59b9 100644 --- a/command/acl/token/update/token_update.go +++ b/command/acl/token/update/token_update.go @@ -28,22 +28,27 @@ type cmd struct { http *flags.HTTPFlags help string - tokenAccessorID string - policyIDs []string - appendPolicyIDs []string - policyNames []string - appendPolicyNames []string - roleIDs []string - appendRoleIDs []string - roleNames []string - appendRoleNames []string - serviceIdents []string - nodeIdents []string - appendNodeIdents []string - appendServiceIdents []string - description string - showMeta bool - format string + tokenAccessorID string + policyIDs []string + appendPolicyIDs []string + policyNames []string + appendPolicyNames []string + roleIDs []string + appendRoleIDs []string + roleNames []string + appendRoleNames []string + serviceIdents []string + nodeIdents []string + appendNodeIdents []string + appendServiceIdents []string + appendTemplatedPolicy string + replaceTemplatedPolicy string + appendTemplatedPolicyFile string + replaceTemplatedPolicyFile string + templatedPolicyVariables []string + description string + showMeta bool + format string // DEPRECATED mergeServiceIdents bool @@ -89,6 +94,19 @@ func (c *cmd) init() { c.flags.Var((*flags.AppendSliceValue)(&c.appendNodeIdents), "append-node-identity", "Name of a "+ "node identity to use for this token. This token retains existing node identities. May be "+ "specified multiple times. Format is NODENAME:DATACENTER") + c.flags.Var((*flags.AppendSliceValue)(&c.templatedPolicyVariables), "var", "Templated policy variables."+ + " Must be used in combination with -replace-templated-policy or -append-templated-policy flags to specify required variables."+ + " May be specified multiple times with different variables."+ + " Format is VariableName:Value") + c.flags.StringVar(&c.appendTemplatedPolicy, "append-templated-policy", "", "The templated policy name to attach to the token's existing templated policies."+ + "Use -var flag to specify variables when required. Token retains existing templated policies.") + c.flags.StringVar(&c.replaceTemplatedPolicy, "replace-templated-policy", "", "The templated policy name to use replace token's existing templated policies."+ + " Use -var flag to specify variables when required. Overwrites token's existing templated policies.") + c.flags.StringVar(&c.appendTemplatedPolicyFile, "append-templated-policy-file", "", "Path to a file containing templated policy names and variables."+ + " Works similarly to `-append-templated-policy`. The token retains existing templated policies.") + c.flags.StringVar(&c.replaceTemplatedPolicyFile, "replace-templated-policy-file", "", "Path to a file containing templated policy names and variables."+ + " Works similarly to `-replace-templated-policy`. Overwrites the token's existing templated policies.") + c.flags.StringVar( &c.format, "format", @@ -195,6 +213,24 @@ func (c *cmd) Run(args []string) int { return 1 } + hasAppendTemplatedPolicies := len(c.appendTemplatedPolicy) > 0 || len(c.appendTemplatedPolicyFile) > 0 + hasReplaceTemplatedPolicies := len(c.replaceTemplatedPolicy) > 0 || len(c.replaceTemplatedPolicyFile) > 0 + + if hasReplaceTemplatedPolicies && hasAppendTemplatedPolicies { + c.UI.Error("Cannot combine the use of -append-templated-policy flags with replace-templated-policy. " + + "To set or overwrite existing templated policies, use -replace-templated-policy or -replace-templated-policy-file. " + + "To append to existing templated policies, use -append-templated-policy or -append-templated-policy-file.") + return 1 + } + parsedTemplatedPolicies, err := acl.ExtractTemplatedPolicies(c.replaceTemplatedPolicy, c.replaceTemplatedPolicyFile, c.templatedPolicyVariables) + if hasAppendTemplatedPolicies { + parsedTemplatedPolicies, err = acl.ExtractTemplatedPolicies(c.appendTemplatedPolicy, c.appendTemplatedPolicyFile, c.templatedPolicyVariables) + } + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + if c.mergePolicies { c.UI.Warn("merge-policies is deprecated and will be removed in a future Consul version. " + "Use `append-policy-name` or `append-policy-id` instead.") @@ -384,6 +420,12 @@ func (c *cmd) Run(args []string) int { t.NodeIdentities = parsedNodeIdents } + if hasReplaceTemplatedPolicies { + t.TemplatedPolicies = parsedTemplatedPolicies + } else { + t.TemplatedPolicies = append(t.TemplatedPolicies, parsedTemplatedPolicies...) + } + t, _, err = client.ACL().TokenUpdate(t, nil) if err != nil { c.UI.Error(fmt.Sprintf("Failed to update token %s: %v", tok, err)) @@ -432,6 +474,8 @@ Usage: consul acl token update [options] $ consul acl token update -accessor-id abcd \ -description "replication" \ -policy-name "token-replication" \ - -role-name "db-updater" + -role-name "db-updater" \ + -templated-policy "builtin/service" \ + -var "name:web" ` ) diff --git a/command/acl/token/update/token_update_test.go b/command/acl/token/update/token_update_test.go index 019b8554a5..7867b0d87c 100644 --- a/command/acl/token/update/token_update_test.go +++ b/command/acl/token/update/token_update_test.go @@ -120,6 +120,51 @@ func TestTokenUpdateCommand(t *testing.T) { require.ElementsMatch(t, expected, responseToken.NodeIdentities) }) + t.Run("replace-templated-policy", func(t *testing.T) { + token := create_token( + t, + client, &api.ACLToken{Description: "test", TemplatedPolicies: []*api.ACLTemplatedPolicy{ + {TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "api"}}, + }}, + &api.WriteOptions{Token: "root"}, + ) + + responseToken := run(t, []string{ + "-http-addr=" + a.HTTPAddr(), + "-accessor-id=" + token.AccessorID, + "-token=root", + "-replace-templated-policy=builtin/node", + "-description=test token", + "-var=name:web", + }) + + require.Len(t, responseToken.TemplatedPolicies, 1) + require.Equal(t, api.ACLTemplatedPolicyNodeName, responseToken.TemplatedPolicies[0].TemplateName) + require.Equal(t, "web", responseToken.TemplatedPolicies[0].TemplateVariables.Name) + }) + t.Run("append-templated-policy", func(t *testing.T) { + templatedPolicy := &api.ACLTemplatedPolicy{TemplateName: api.ACLTemplatedPolicyServiceName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "api"}} + token := create_token( + t, + client, &api.ACLToken{Description: "test", TemplatedPolicies: []*api.ACLTemplatedPolicy{ + templatedPolicy, + }}, + &api.WriteOptions{Token: "root"}, + ) + + responseToken := run(t, []string{ + "-http-addr=" + a.HTTPAddr(), + "-accessor-id=" + token.AccessorID, + "-token=root", + "-append-templated-policy=builtin/node", + "-description=test token", + "-var=name:web", + }) + + require.Len(t, responseToken.TemplatedPolicies, 2) + require.ElementsMatch(t, responseToken.TemplatedPolicies, + []*api.ACLTemplatedPolicy{templatedPolicy, {TemplateName: api.ACLTemplatedPolicyNodeName, TemplateVariables: &api.ACLTemplatedPolicyVariables{Name: "web"}}}) + }) // update with policy by name t.Run("policy-name", func(t *testing.T) { token := create_token(t, client, &api.ACLToken{Description: "test"}, &api.WriteOptions{Token: "root"}) diff --git a/command/helpers/helpers.go b/command/helpers/helpers.go index bc990f76fa..e4b95fff37 100644 --- a/command/helpers/helpers.go +++ b/command/helpers/helpers.go @@ -18,7 +18,7 @@ import ( "github.com/hashicorp/go-multierror" ) -func loadFromFile(path string) (string, error) { +func LoadFromFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("Failed to read file: %v", err) @@ -47,7 +47,7 @@ func LoadDataSource(data string, testStdin io.Reader) (string, error) { switch data[0] { case '@': - return loadFromFile(data[1:]) + return LoadFromFile(data[1:]) case '-': if len(data) > 1 { return data, nil @@ -67,7 +67,7 @@ func LoadDataSourceNoRaw(data string, testStdin io.Reader) (string, error) { return loadFromStdin(testStdin) } - return loadFromFile(data) + return LoadFromFile(data) } func ParseConfigEntry(data string) (api.ConfigEntry, error) { diff --git a/go.mod b/go.mod index d8fd8a45af..215b4d55e6 100644 --- a/go.mod +++ b/go.mod @@ -97,6 +97,7 @@ require ( github.com/ryanuber/columnize v2.1.2+incompatible github.com/shirou/gopsutil/v3 v3.22.8 github.com/stretchr/testify v1.8.3 + github.com/xeipuuv/gojsonschema v1.2.0 github.com/zclconf/go-cty v1.2.0 go.etcd.io/bbolt v1.3.7 go.opentelemetry.io/otel v1.16.0 @@ -245,6 +246,7 @@ require ( github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926 // indirect github.com/vmware/govmomi v0.18.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yusufpapurcu/wmi v1.2.2 // indirect go.mongodb.org/mongo-driver v1.11.0 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/go.sum b/go.sum index 833718ff33..9291cb5530 100644 --- a/go.sum +++ b/go.sum @@ -937,8 +937,13 @@ github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+ github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= diff --git a/test-integ/go.mod b/test-integ/go.mod index 76c91af83c..2049ba1316 100644 --- a/test-integ/go.mod +++ b/test-integ/go.mod @@ -190,6 +190,8 @@ require ( github.com/testcontainers/testcontainers-go v0.22.0 // indirect github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/zclconf/go-cty v1.12.1 // indirect go.etcd.io/bbolt v1.3.7 // indirect go.mongodb.org/mongo-driver v1.11.0 // indirect diff --git a/test-integ/go.sum b/test-integ/go.sum index e662768b13..1a7c67d76a 100644 --- a/test-integ/go.sum +++ b/test-integ/go.sum @@ -774,8 +774,13 @@ github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+ github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/test/integration/consul-container/go.mod b/test/integration/consul-container/go.mod index 7dfdb1771e..db5fcca4ae 100644 --- a/test/integration/consul-container/go.mod +++ b/test/integration/consul-container/go.mod @@ -186,6 +186,8 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect go.etcd.io/bbolt v1.3.7 // indirect go.mongodb.org/mongo-driver v1.11.0 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/test/integration/consul-container/go.sum b/test/integration/consul-container/go.sum index b05dbbc817..2d86e3ef9c 100644 --- a/test/integration/consul-container/go.sum +++ b/test/integration/consul-container/go.sum @@ -763,8 +763,13 @@ github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+ github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=