acl: ACL Tokens can now be assigned an optional set of service identities (#5390)
These act like a special cased version of a Policy Template for granting a token the privileges necessary to register a service and its connect proxy, and read upstreams from the catalog.
This commit is contained in:
parent
76321aa952
commit
b3956e511c
|
@ -4,10 +4,11 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/armon/go-metrics"
|
||||
metrics "github.com/armon/go-metrics"
|
||||
"github.com/hashicorp/consul/acl"
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
"github.com/hashicorp/consul/api"
|
||||
|
@ -133,7 +134,7 @@ type ACLResolverConfig struct {
|
|||
// - Resolving policies remotely via an ACL.PolicyResolve RPC
|
||||
//
|
||||
// Remote Resolution:
|
||||
// Remote resolution can be done syncrhonously or asynchronously depending
|
||||
// Remote resolution can be done synchronously or asynchronously depending
|
||||
// on the ACLDownPolicy in the Config passed to the resolver.
|
||||
//
|
||||
// When the down policy is set to async-cache and we have already cached values
|
||||
|
@ -503,7 +504,9 @@ func (r *ACLResolver) filterPoliciesByScope(policies structs.ACLPolicies) struct
|
|||
|
||||
func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) (structs.ACLPolicies, error) {
|
||||
policyIDs := identity.PolicyIDs()
|
||||
if len(policyIDs) == 0 {
|
||||
serviceIdentities := identity.ServiceIdentityList()
|
||||
|
||||
if len(policyIDs) == 0 && len(serviceIdentities) == 0 {
|
||||
policy := identity.EmbeddedPolicy()
|
||||
if policy != nil {
|
||||
return []*structs.ACLPolicy{policy}, nil
|
||||
|
@ -513,9 +516,96 @@ func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) (
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
syntheticPolicies := r.synthesizePoliciesForServiceIdentities(serviceIdentities)
|
||||
|
||||
// For the new ACLs policy replication is mandatory for correct operation on servers. Therefore
|
||||
// we only attempt to resolve policies locally
|
||||
policies := make([]*structs.ACLPolicy, 0, len(policyIDs))
|
||||
policies, err := r.collectPoliciesForIdentity(identity, policyIDs, len(syntheticPolicies))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policies = append(policies, syntheticPolicies...)
|
||||
filtered := r.filterPoliciesByScope(policies)
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (r *ACLResolver) synthesizePoliciesForServiceIdentities(serviceIdentities []*structs.ACLServiceIdentity) []*structs.ACLPolicy {
|
||||
if len(serviceIdentities) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect and dedupe service identities. Prefer increasing datacenter scope.
|
||||
serviceIdentities = dedupeServiceIdentities(serviceIdentities)
|
||||
|
||||
syntheticPolicies := make([]*structs.ACLPolicy, 0, len(serviceIdentities))
|
||||
for _, s := range serviceIdentities {
|
||||
syntheticPolicies = append(syntheticPolicies, s.SyntheticPolicy())
|
||||
}
|
||||
|
||||
return syntheticPolicies
|
||||
}
|
||||
|
||||
func dedupeServiceIdentities(in []*structs.ACLServiceIdentity) []*structs.ACLServiceIdentity {
|
||||
// From: https://github.com/golang/go/wiki/SliceTricks#in-place-deduplicate-comparable
|
||||
|
||||
if len(in) <= 1 {
|
||||
return in
|
||||
}
|
||||
|
||||
sort.Slice(in, func(i, j int) bool {
|
||||
return in[i].ServiceName < in[j].ServiceName
|
||||
})
|
||||
|
||||
j := 0
|
||||
for i := 1; i < len(in); i++ {
|
||||
if in[j].ServiceName == in[i].ServiceName {
|
||||
// Prefer increasing scope.
|
||||
if len(in[j].Datacenters) == 0 || len(in[i].Datacenters) == 0 {
|
||||
in[j].Datacenters = nil
|
||||
} else {
|
||||
in[j].Datacenters = mergeStringSlice(in[j].Datacenters, in[i].Datacenters)
|
||||
}
|
||||
continue
|
||||
}
|
||||
j++
|
||||
in[j] = in[i]
|
||||
}
|
||||
|
||||
// Discard the skipped items.
|
||||
for i := j + 1; i < len(in); i++ {
|
||||
in[i] = nil
|
||||
}
|
||||
|
||||
return in[:j+1]
|
||||
}
|
||||
|
||||
func mergeStringSlice(a, b []string) []string {
|
||||
out := make([]string, 0, len(a)+len(b))
|
||||
out = append(out, a...)
|
||||
out = append(out, b...)
|
||||
return dedupeStringSlice(out)
|
||||
}
|
||||
|
||||
func dedupeStringSlice(in []string) []string {
|
||||
// From: https://github.com/golang/go/wiki/SliceTricks#in-place-deduplicate-comparable
|
||||
|
||||
sort.Strings(in)
|
||||
|
||||
j := 0
|
||||
for i := 1; i < len(in); i++ {
|
||||
if in[j] == in[i] {
|
||||
continue
|
||||
}
|
||||
j++
|
||||
in[j] = in[i]
|
||||
}
|
||||
|
||||
return in[:j+1]
|
||||
}
|
||||
|
||||
func (r *ACLResolver) collectPoliciesForIdentity(identity structs.ACLIdentity, policyIDs []string, extraCap int) ([]*structs.ACLPolicy, error) {
|
||||
policies := make([]*structs.ACLPolicy, 0, len(policyIDs)+extraCap)
|
||||
|
||||
// Get all associated policies
|
||||
var missing []string
|
||||
|
@ -559,7 +649,7 @@ func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) (
|
|||
|
||||
// Hot-path if we have no missing or expired policies
|
||||
if len(missing)+len(expired) == 0 {
|
||||
return r.filterPoliciesByScope(policies), nil
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
hasMissing := len(missing) > 0
|
||||
|
@ -579,7 +669,7 @@ func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) (
|
|||
if !waitForResult {
|
||||
// waitForResult being false requires that all the policies were cached already
|
||||
policies = append(policies, expired...)
|
||||
return r.filterPoliciesByScope(policies), nil
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
res := <-waitChan
|
||||
|
@ -596,7 +686,7 @@ func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) (
|
|||
}
|
||||
}
|
||||
|
||||
return r.filterPoliciesByScope(policies), nil
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
func (r *ACLResolver) resolveTokenToPolicies(token string) (structs.ACLPolicies, error) {
|
||||
|
|
|
@ -8,13 +8,13 @@ import (
|
|||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/armon/go-metrics"
|
||||
metrics "github.com/armon/go-metrics"
|
||||
"github.com/hashicorp/consul/acl"
|
||||
"github.com/hashicorp/consul/agent/consul/state"
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
"github.com/hashicorp/consul/lib"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
"github.com/hashicorp/go-uuid"
|
||||
memdb "github.com/hashicorp/go-memdb"
|
||||
uuid "github.com/hashicorp/go-uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -24,7 +24,11 @@ const (
|
|||
)
|
||||
|
||||
// Regex for matching
|
||||
var validPolicyName = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,128}$`)
|
||||
var (
|
||||
validPolicyName = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,128}$`)
|
||||
validServiceIdentityName = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-_]*[a-z0-9])?$`)
|
||||
serviceIdentityNameMaxLength = 256
|
||||
)
|
||||
|
||||
// ACL endpoint is used to manipulate ACLs
|
||||
type ACL struct {
|
||||
|
@ -276,6 +280,7 @@ func (a *ACL) TokenClone(args *structs.ACLTokenSetRequest, reply *structs.ACLTok
|
|||
Datacenter: args.Datacenter,
|
||||
ACLToken: structs.ACLToken{
|
||||
Policies: token.Policies,
|
||||
ServiceIdentities: token.ServiceIdentities,
|
||||
Local: token.Local,
|
||||
Description: token.Description,
|
||||
ExpirationTime: token.ExpirationTime,
|
||||
|
@ -450,6 +455,18 @@ func (a *ACL) tokenSetInternal(args *structs.ACLTokenSetRequest, reply *structs.
|
|||
}
|
||||
token.Policies = policies
|
||||
|
||||
for _, svcid := range token.ServiceIdentities {
|
||||
if svcid.ServiceName == "" {
|
||||
return fmt.Errorf("Service identity is missing the service name field on this token")
|
||||
}
|
||||
if token.Local && len(svcid.Datacenters) > 0 {
|
||||
return fmt.Errorf("Service identity %q cannot specify a list of datacenters on a local token", svcid.ServiceName)
|
||||
}
|
||||
if !isValidServiceIdentityName(svcid.ServiceName) {
|
||||
return fmt.Errorf("Service identity %q has an invalid name. Only alphanumeric characters, '-' and '_' are allowed", svcid.ServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
if token.Rules != "" {
|
||||
return fmt.Errorf("Rules cannot be specified for this token")
|
||||
}
|
||||
|
@ -487,6 +504,17 @@ func (a *ACL) tokenSetInternal(args *structs.ACLTokenSetRequest, reply *structs.
|
|||
return nil
|
||||
}
|
||||
|
||||
// isValidServiceIdentityName returns true if the provided name can be used as
|
||||
// an ACLServiceIdentity ServiceName. This is more restrictive than standard
|
||||
// catalog registration, which basically takes the view that "everything is
|
||||
// valid".
|
||||
func isValidServiceIdentityName(name string) bool {
|
||||
if len(name) < 1 || len(name) > serviceIdentityNameMaxLength {
|
||||
return false
|
||||
}
|
||||
return validServiceIdentityName.MatchString(name)
|
||||
}
|
||||
|
||||
func (a *ACL) TokenDelete(args *structs.ACLTokenDeleteRequest, reply *string) error {
|
||||
if err := a.aclPreCheck(); err != nil {
|
||||
return err
|
||||
|
|
|
@ -919,6 +919,124 @@ func TestACLEndpoint_TokenSet(t *testing.T) {
|
|||
require.Len(t, token.Policies, 0)
|
||||
})
|
||||
|
||||
t.Run("Create it with invalid service identity (empty)", func(t *testing.T) {
|
||||
req := structs.ACLTokenSetRequest{
|
||||
Datacenter: "dc1",
|
||||
ACLToken: structs.ACLToken{
|
||||
Description: "foobar",
|
||||
Policies: nil,
|
||||
Local: false,
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{ServiceName: ""},
|
||||
},
|
||||
},
|
||||
WriteRequest: structs.WriteRequest{Token: "root"},
|
||||
}
|
||||
|
||||
resp := structs.ACLToken{}
|
||||
|
||||
err := acl.TokenSet(&req, &resp)
|
||||
requireErrorContains(t, err, "Service identity is missing the service name field")
|
||||
})
|
||||
|
||||
t.Run("Create it with invalid service identity (too large)", func(t *testing.T) {
|
||||
long := strings.Repeat("x", serviceIdentityNameMaxLength+1)
|
||||
req := structs.ACLTokenSetRequest{
|
||||
Datacenter: "dc1",
|
||||
ACLToken: structs.ACLToken{
|
||||
Description: "foobar",
|
||||
Policies: nil,
|
||||
Local: false,
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{ServiceName: long},
|
||||
},
|
||||
},
|
||||
WriteRequest: structs.WriteRequest{Token: "root"},
|
||||
}
|
||||
|
||||
resp := structs.ACLToken{}
|
||||
|
||||
err := acl.TokenSet(&req, &resp)
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"-abc", false},
|
||||
{"abc-", false},
|
||||
{"a-bc", true},
|
||||
{"_abc", false},
|
||||
{"abc_", false},
|
||||
{"a_bc", true},
|
||||
{":abc", false},
|
||||
{"abc:", false},
|
||||
{"a:bc", false},
|
||||
{"Abc", false},
|
||||
{"aBc", false},
|
||||
{"abC", false},
|
||||
{"0abc", true},
|
||||
{"abc0", true},
|
||||
{"a0bc", true},
|
||||
} {
|
||||
var testName string
|
||||
if test.ok {
|
||||
testName = "Create it with valid service identity (by regex): " + test.name
|
||||
} else {
|
||||
testName = "Create it with invalid service identity (by regex): " + test.name
|
||||
}
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
req := structs.ACLTokenSetRequest{
|
||||
Datacenter: "dc1",
|
||||
ACLToken: structs.ACLToken{
|
||||
Description: "foobar",
|
||||
Policies: nil,
|
||||
Local: false,
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{ServiceName: test.name},
|
||||
},
|
||||
},
|
||||
WriteRequest: structs.WriteRequest{Token: "root"},
|
||||
}
|
||||
|
||||
resp := structs.ACLToken{}
|
||||
|
||||
err := acl.TokenSet(&req, &resp)
|
||||
if test.ok {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the token directly to validate that it exists
|
||||
tokenResp, err := retrieveTestToken(codec, "root", "dc1", resp.AccessorID)
|
||||
require.NoError(t, err)
|
||||
token := tokenResp.Token
|
||||
require.ElementsMatch(t, req.ACLToken.ServiceIdentities, token.ServiceIdentities)
|
||||
} else {
|
||||
require.NotNil(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Create it with invalid service identity (datacenters set on local token)", func(t *testing.T) {
|
||||
req := structs.ACLTokenSetRequest{
|
||||
Datacenter: "dc1",
|
||||
ACLToken: structs.ACLToken{
|
||||
Description: "foobar",
|
||||
Policies: nil,
|
||||
Local: true,
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{ServiceName: "foo", Datacenters: []string{"dc2"}},
|
||||
},
|
||||
},
|
||||
WriteRequest: structs.WriteRequest{Token: "root"},
|
||||
}
|
||||
|
||||
resp := structs.ACLToken{}
|
||||
|
||||
err := acl.TokenSet(&req, &resp)
|
||||
requireErrorContains(t, err, "cannot specify a list of datacenters on a local token")
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
offset time.Duration
|
||||
|
|
|
@ -2861,3 +2861,92 @@ service "service" {
|
|||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeServiceIdentities(t *testing.T) {
|
||||
srvid := func(name string, datacenters ...string) *structs.ACLServiceIdentity {
|
||||
return &structs.ACLServiceIdentity{
|
||||
ServiceName: name,
|
||||
Datacenters: datacenters,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in []*structs.ACLServiceIdentity
|
||||
expect []*structs.ACLServiceIdentity
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
in: nil,
|
||||
expect: nil,
|
||||
},
|
||||
{
|
||||
name: "one",
|
||||
in: []*structs.ACLServiceIdentity{
|
||||
srvid("foo"),
|
||||
},
|
||||
expect: []*structs.ACLServiceIdentity{
|
||||
srvid("foo"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "just names",
|
||||
in: []*structs.ACLServiceIdentity{
|
||||
srvid("fooZ"),
|
||||
srvid("fooA"),
|
||||
srvid("fooY"),
|
||||
srvid("fooB"),
|
||||
},
|
||||
expect: []*structs.ACLServiceIdentity{
|
||||
srvid("fooA"),
|
||||
srvid("fooB"),
|
||||
srvid("fooY"),
|
||||
srvid("fooZ"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "just names with dupes",
|
||||
in: []*structs.ACLServiceIdentity{
|
||||
srvid("fooZ"),
|
||||
srvid("fooA"),
|
||||
srvid("fooY"),
|
||||
srvid("fooB"),
|
||||
srvid("fooA"),
|
||||
srvid("fooB"),
|
||||
srvid("fooY"),
|
||||
srvid("fooZ"),
|
||||
},
|
||||
expect: []*structs.ACLServiceIdentity{
|
||||
srvid("fooA"),
|
||||
srvid("fooB"),
|
||||
srvid("fooY"),
|
||||
srvid("fooZ"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "names with dupes and datacenters",
|
||||
in: []*structs.ACLServiceIdentity{
|
||||
srvid("fooZ", "dc2", "dc4"),
|
||||
srvid("fooA"),
|
||||
srvid("fooY", "dc1"),
|
||||
srvid("fooB"),
|
||||
srvid("fooA", "dc9", "dc8"),
|
||||
srvid("fooB"),
|
||||
srvid("fooY", "dc1"),
|
||||
srvid("fooZ", "dc3", "dc4"),
|
||||
},
|
||||
expect: []*structs.ACLServiceIdentity{
|
||||
srvid("fooA"),
|
||||
srvid("fooB"),
|
||||
srvid("fooY", "dc1"),
|
||||
srvid("fooZ", "dc2", "dc3", "dc4"),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := dedupeServiceIdentities(test.in)
|
||||
require.ElementsMatch(t, test.expect, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -658,7 +658,9 @@ func (s *Server) startACLUpgrade() {
|
|||
}
|
||||
|
||||
// Assign the global-management policy to legacy management tokens
|
||||
if len(newToken.Policies) == 0 && newToken.Type == structs.ACLTokenTypeManagement {
|
||||
if len(newToken.Policies) == 0 &&
|
||||
len(newToken.ServiceIdentities) == 0 &&
|
||||
newToken.Type == structs.ACLTokenTypeManagement {
|
||||
newToken.Policies = append(newToken.Policies, structs.ACLTokenPolicyLink{ID: structs.ACLPolicyGlobalManagementID})
|
||||
}
|
||||
|
||||
|
|
|
@ -478,6 +478,12 @@ func (s *Store) aclTokenSetTxn(tx *memdb.Txn, idx uint64, token *structs.ACLToke
|
|||
return err
|
||||
}
|
||||
|
||||
for _, svcid := range token.ServiceIdentities {
|
||||
if svcid.ServiceName == "" {
|
||||
return fmt.Errorf("Encountered a Token with an empty service identity name in the state store")
|
||||
}
|
||||
}
|
||||
|
||||
// Set the indexes
|
||||
if original != nil {
|
||||
if original.AccessorID != "" && token.AccessorID != original.AccessorID {
|
||||
|
|
|
@ -277,6 +277,38 @@ func TestStateStore_ACLToken_SetGet(t *testing.T) {
|
|||
require.Equal(t, ErrMissingACLTokenAccessor, err)
|
||||
})
|
||||
|
||||
t.Run("Missing Service Identity Fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testACLTokensStateStore(t)
|
||||
token := &structs.ACLToken{
|
||||
AccessorID: "daf37c07-d04d-4fd5-9678-a8206a57d61a",
|
||||
SecretID: "39171632-6f34-4411-827f-9416403687f4",
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.ACLTokenSet(2, token, false)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Missing Service Identity Name", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testACLTokensStateStore(t)
|
||||
token := &structs.ACLToken{
|
||||
AccessorID: "daf37c07-d04d-4fd5-9678-a8206a57d61a",
|
||||
SecretID: "39171632-6f34-4411-827f-9416403687f4",
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{
|
||||
Datacenters: []string{"dc1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.ACLTokenSet(2, token, false)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Missing Policy ID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testACLTokensStateStore(t)
|
||||
|
@ -322,6 +354,11 @@ func TestStateStore_ACLToken_SetGet(t *testing.T) {
|
|||
ID: "a0625e95-9b3e-42de-a8d6-ceef5b6f3286",
|
||||
},
|
||||
},
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{
|
||||
ServiceName: "web",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, s.ACLTokenSet(2, token.Clone(), false))
|
||||
|
@ -334,6 +371,8 @@ func TestStateStore_ACLToken_SetGet(t *testing.T) {
|
|||
require.Equal(t, uint64(2), rtoken.ModifyIndex)
|
||||
require.Len(t, rtoken.Policies, 1)
|
||||
require.Equal(t, "node-read", rtoken.Policies[0].Name)
|
||||
require.Len(t, rtoken.ServiceIdentities, 1)
|
||||
require.Equal(t, "web", rtoken.ServiceIdentities[0].ServiceName)
|
||||
})
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
|
@ -347,6 +386,11 @@ func TestStateStore_ACLToken_SetGet(t *testing.T) {
|
|||
ID: "a0625e95-9b3e-42de-a8d6-ceef5b6f3286",
|
||||
},
|
||||
},
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{
|
||||
ServiceName: "web",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, s.ACLTokenSet(2, token.Clone(), false))
|
||||
|
@ -359,6 +403,11 @@ func TestStateStore_ACLToken_SetGet(t *testing.T) {
|
|||
ID: structs.ACLPolicyGlobalManagementID,
|
||||
},
|
||||
},
|
||||
ServiceIdentities: []*structs.ACLServiceIdentity{
|
||||
&structs.ACLServiceIdentity{
|
||||
ServiceName: "db",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, s.ACLTokenSet(3, updated.Clone(), false))
|
||||
|
@ -372,6 +421,8 @@ func TestStateStore_ACLToken_SetGet(t *testing.T) {
|
|||
require.Len(t, rtoken.Policies, 1)
|
||||
require.Equal(t, structs.ACLPolicyGlobalManagementID, rtoken.Policies[0].ID)
|
||||
require.Equal(t, "global-management", rtoken.Policies[0].Name)
|
||||
require.Len(t, rtoken.ServiceIdentities, 1)
|
||||
require.Equal(t, "db", rtoken.ServiceIdentities[0].ServiceName)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strings"
|
||||
|
@ -84,6 +85,22 @@ session_prefix "" {
|
|||
// This is the policy ID for anonymous access. This is configurable by the
|
||||
// user.
|
||||
ACLTokenAnonymousID = "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
// aclPolicyTemplateServiceIdentity is the template used for synthesizing
|
||||
// policies for service identities.
|
||||
aclPolicyTemplateServiceIdentity = `
|
||||
service "%s" {
|
||||
policy = "write"
|
||||
}
|
||||
service "%s-sidecar-proxy" {
|
||||
policy = "write"
|
||||
}
|
||||
service_prefix "" {
|
||||
policy = "read"
|
||||
}
|
||||
node_prefix "" {
|
||||
policy = "read"
|
||||
}`
|
||||
)
|
||||
|
||||
func ACLIDReserved(id string) bool {
|
||||
|
@ -113,6 +130,7 @@ type ACLIdentity interface {
|
|||
SecretToken() string
|
||||
PolicyIDs() []string
|
||||
EmbeddedPolicy() *ACLPolicy
|
||||
ServiceIdentityList() []*ACLServiceIdentity
|
||||
IsExpired(asOf time.Time) bool
|
||||
}
|
||||
|
||||
|
@ -121,6 +139,49 @@ type ACLTokenPolicyLink struct {
|
|||
Name string `hash:"ignore"`
|
||||
}
|
||||
|
||||
// ACLServiceIdentity represents a high-level grant of all necessary privileges
|
||||
// to assume the identity of the named Service in the Catalog and within
|
||||
// Connect.
|
||||
type ACLServiceIdentity struct {
|
||||
ServiceName string
|
||||
|
||||
// Datacenters that the synthetic policy will be valid within.
|
||||
// - No wildcards allowed
|
||||
// - If empty then the synthetic policy is valid within all datacenters
|
||||
//
|
||||
// Only valid for global tokens. It is an error to specify this for local tokens.
|
||||
Datacenters []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
func (s *ACLServiceIdentity) Clone() *ACLServiceIdentity {
|
||||
s2 := *s
|
||||
s2.Datacenters = cloneStringSlice(s.Datacenters)
|
||||
return &s2
|
||||
}
|
||||
|
||||
func (s *ACLServiceIdentity) AddToHash(h hash.Hash) {
|
||||
h.Write([]byte(s.ServiceName))
|
||||
for _, dc := range s.Datacenters {
|
||||
h.Write([]byte(dc))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ACLServiceIdentity) SyntheticPolicy() *ACLPolicy {
|
||||
// Given that we validate this string name before persisting, we do not
|
||||
// have to escape it before doing the following interpolation.
|
||||
rules := fmt.Sprintf(aclPolicyTemplateServiceIdentity, s.ServiceName, s.ServiceName)
|
||||
|
||||
hasher := fnv.New128a()
|
||||
policy := &ACLPolicy{}
|
||||
policy.ID = fmt.Sprintf("%x", hasher.Sum([]byte(rules)))
|
||||
policy.Name = fmt.Sprintf("synthetic-policy-%s", policy.ID)
|
||||
policy.Rules = rules
|
||||
policy.Syntax = acl.SyntaxCurrent
|
||||
policy.Datacenters = s.Datacenters
|
||||
policy.SetHash(true)
|
||||
return policy
|
||||
}
|
||||
|
||||
type ACLToken struct {
|
||||
// This is the UUID used for tracking and management purposes
|
||||
AccessorID string
|
||||
|
@ -131,10 +192,13 @@ type ACLToken struct {
|
|||
// Human readable string to display for the token (Optional)
|
||||
Description string
|
||||
|
||||
// List of policy links - nil/empty for legacy tokens
|
||||
// List of policy links - nil/empty for legacy tokens or if service identities are in use.
|
||||
// Note this is the list of IDs and not the names. Prior to token creation
|
||||
// the list of policy names gets validated and the policy IDs get stored herein
|
||||
Policies []ACLTokenPolicyLink
|
||||
Policies []ACLTokenPolicyLink `json:",omitempty"`
|
||||
|
||||
// List of services to generate synthetic policies for.
|
||||
ServiceIdentities []*ACLServiceIdentity `json:",omitempty"`
|
||||
|
||||
// Type is the V1 Token Type
|
||||
// DEPRECATED (ACL-Legacy-Compat) - remove once we no longer support v1 ACL compat
|
||||
|
@ -181,11 +245,18 @@ type ACLToken struct {
|
|||
func (t *ACLToken) Clone() *ACLToken {
|
||||
t2 := *t
|
||||
t2.Policies = nil
|
||||
t2.ServiceIdentities = nil
|
||||
|
||||
if len(t.Policies) > 0 {
|
||||
t2.Policies = make([]ACLTokenPolicyLink, len(t.Policies))
|
||||
copy(t2.Policies, t.Policies)
|
||||
}
|
||||
if len(t.ServiceIdentities) > 0 {
|
||||
t2.ServiceIdentities = make([]*ACLServiceIdentity, len(t.ServiceIdentities))
|
||||
for i, s := range t.ServiceIdentities {
|
||||
t2.ServiceIdentities[i] = s.Clone()
|
||||
}
|
||||
}
|
||||
return &t2
|
||||
}
|
||||
|
||||
|
@ -198,13 +269,29 @@ func (t *ACLToken) SecretToken() string {
|
|||
}
|
||||
|
||||
func (t *ACLToken) PolicyIDs() []string {
|
||||
var ids []string
|
||||
if len(t.Policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(t.Policies))
|
||||
for _, link := range t.Policies {
|
||||
ids = append(ids, link.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (t *ACLToken) ServiceIdentityList() []*ACLServiceIdentity {
|
||||
if len(t.ServiceIdentities) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]*ACLServiceIdentity, 0, len(t.ServiceIdentities))
|
||||
for _, s := range t.ServiceIdentities {
|
||||
out = append(out, s.Clone())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *ACLToken) IsExpired(asOf time.Time) bool {
|
||||
if asOf.IsZero() || t.ExpirationTime.IsZero() {
|
||||
return false
|
||||
|
@ -214,6 +301,7 @@ func (t *ACLToken) IsExpired(asOf time.Time) bool {
|
|||
|
||||
func (t *ACLToken) UsesNonLegacyFields() bool {
|
||||
return len(t.Policies) > 0 ||
|
||||
len(t.ServiceIdentities) > 0 ||
|
||||
t.Type == "" ||
|
||||
!t.ExpirationTime.IsZero() ||
|
||||
t.ExpirationTTL != 0
|
||||
|
@ -280,6 +368,10 @@ func (t *ACLToken) SetHash(force bool) []byte {
|
|||
hash.Write([]byte(link.ID))
|
||||
}
|
||||
|
||||
for _, srvid := range t.ServiceIdentities {
|
||||
srvid.AddToHash(hash)
|
||||
}
|
||||
|
||||
// Finalize the hash
|
||||
hashVal := hash.Sum(nil)
|
||||
|
||||
|
@ -295,6 +387,12 @@ func (t *ACLToken) EstimateSize() int {
|
|||
for _, link := range t.Policies {
|
||||
size += len(link.ID) + len(link.Name)
|
||||
}
|
||||
for _, srvid := range t.ServiceIdentities {
|
||||
size += len(srvid.ServiceName)
|
||||
for _, dc := range srvid.Datacenters {
|
||||
size += len(dc)
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
|
@ -304,7 +402,8 @@ type ACLTokens []*ACLToken
|
|||
type ACLTokenListStub struct {
|
||||
AccessorID string
|
||||
Description string
|
||||
Policies []ACLTokenPolicyLink
|
||||
Policies []ACLTokenPolicyLink `json:",omitempty"`
|
||||
ServiceIdentities []*ACLServiceIdentity `json:",omitempty"`
|
||||
Local bool
|
||||
ExpirationTime time.Time `json:",omitempty"`
|
||||
CreateTime time.Time `json:",omitempty"`
|
||||
|
@ -321,6 +420,7 @@ func (token *ACLToken) Stub() *ACLTokenListStub {
|
|||
AccessorID: token.AccessorID,
|
||||
Description: token.Description,
|
||||
Policies: token.Policies,
|
||||
ServiceIdentities: token.ServiceIdentities,
|
||||
Local: token.Local,
|
||||
ExpirationTime: token.ExpirationTime,
|
||||
CreateTime: token.CreateTime,
|
||||
|
@ -381,11 +481,7 @@ type ACLPolicy struct {
|
|||
|
||||
func (p *ACLPolicy) Clone() *ACLPolicy {
|
||||
p2 := *p
|
||||
p2.Datacenters = nil
|
||||
if len(p.Datacenters) > 0 {
|
||||
p2.Datacenters = make([]string, len(p.Datacenters))
|
||||
copy(p2.Datacenters, p.Datacenters)
|
||||
}
|
||||
p2.Datacenters = cloneStringSlice(p.Datacenters)
|
||||
return &p2
|
||||
}
|
||||
|
||||
|
@ -765,3 +861,12 @@ type ACLPolicyBatchSetRequest struct {
|
|||
type ACLPolicyBatchDeleteRequest struct {
|
||||
PolicyIDs []string
|
||||
}
|
||||
|
||||
func cloneStringSlice(s []string) []string {
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(s))
|
||||
copy(out, s)
|
||||
return out
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@ func (a *ACL) Convert() *ACLToken {
|
|||
SecretID: a.ID,
|
||||
Description: a.Name,
|
||||
Policies: nil,
|
||||
ServiceIdentities: nil,
|
||||
Type: a.Type,
|
||||
Rules: a.Rules,
|
||||
Local: false,
|
||||
|
|
|
@ -140,6 +140,69 @@ func TestStructs_ACLToken_EmbeddedPolicy(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestStructs_ACLServiceIdentity_SyntheticPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
serviceName string
|
||||
datacenters []string
|
||||
expectRules string
|
||||
}{
|
||||
{"web", nil, `
|
||||
service "web" {
|
||||
policy = "write"
|
||||
}
|
||||
service "web-sidecar-proxy" {
|
||||
policy = "write"
|
||||
}
|
||||
service_prefix "" {
|
||||
policy = "read"
|
||||
}
|
||||
node_prefix "" {
|
||||
policy = "read"
|
||||
}`},
|
||||
{"companion-cube-99", []string{"dc1", "dc2"}, `
|
||||
service "companion-cube-99" {
|
||||
policy = "write"
|
||||
}
|
||||
service "companion-cube-99-sidecar-proxy" {
|
||||
policy = "write"
|
||||
}
|
||||
service_prefix "" {
|
||||
policy = "read"
|
||||
}
|
||||
node_prefix "" {
|
||||
policy = "read"
|
||||
}`},
|
||||
} {
|
||||
name := test.serviceName
|
||||
if len(test.datacenters) > 0 {
|
||||
name += " [" + strings.Join(test.datacenters, ", ") + "]"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svcid := &ACLServiceIdentity{
|
||||
ServiceName: test.serviceName,
|
||||
Datacenters: test.datacenters,
|
||||
}
|
||||
|
||||
expect := &ACLPolicy{
|
||||
Syntax: acl.SyntaxCurrent,
|
||||
Datacenters: test.datacenters,
|
||||
Rules: test.expectRules,
|
||||
}
|
||||
|
||||
got := svcid.SyntheticPolicy()
|
||||
require.NotEmpty(t, got.ID)
|
||||
require.Equal(t, got.Name, "synthetic-policy-"+got.ID)
|
||||
// strip irrelevant fields before equality
|
||||
got.ID = ""
|
||||
got.Name = ""
|
||||
got.Hash = nil
|
||||
require.Equal(t, expect, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructs_ACLToken_SetHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
14
api/acl.go
14
api/acl.go
|
@ -27,7 +27,8 @@ type ACLToken struct {
|
|||
AccessorID string
|
||||
SecretID string
|
||||
Description string
|
||||
Policies []*ACLTokenPolicyLink
|
||||
Policies []*ACLTokenPolicyLink `json:",omitempty"`
|
||||
ServiceIdentities []*ACLServiceIdentity `json:",omitempty"`
|
||||
Local bool
|
||||
ExpirationTTL time.Duration `json:",omitempty"`
|
||||
ExpirationTime time.Time `json:",omitempty"`
|
||||
|
@ -44,7 +45,8 @@ type ACLTokenListEntry struct {
|
|||
ModifyIndex uint64
|
||||
AccessorID string
|
||||
Description string
|
||||
Policies []*ACLTokenPolicyLink
|
||||
Policies []*ACLTokenPolicyLink `json:",omitempty"`
|
||||
ServiceIdentities []*ACLServiceIdentity `json:",omitempty"`
|
||||
Local bool
|
||||
ExpirationTime time.Time `json:",omitempty"`
|
||||
CreateTime time.Time
|
||||
|
@ -75,6 +77,14 @@ type ACLReplicationStatus struct {
|
|||
LastError time.Time
|
||||
}
|
||||
|
||||
// ACLServiceIdentity represents a high-level grant of all necessary privileges
|
||||
// to assume the identity of the named Service in the Catalog and within
|
||||
// Connect.
|
||||
type ACLServiceIdentity struct {
|
||||
ServiceName string
|
||||
Datacenters []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// ACLPolicy represents an ACL Policy.
|
||||
type ACLPolicy struct {
|
||||
ID string
|
||||
|
|
|
@ -27,6 +27,14 @@ func PrintToken(token *api.ACLToken, ui cli.Ui, showMeta bool) {
|
|||
for _, policy := range token.Policies {
|
||||
ui.Info(fmt.Sprintf(" %s - %s", policy.ID, policy.Name))
|
||||
}
|
||||
ui.Info(fmt.Sprintf("Service Identities:"))
|
||||
for _, svcid := range token.ServiceIdentities {
|
||||
if len(svcid.Datacenters) > 0 {
|
||||
ui.Info(fmt.Sprintf(" %s (Datacenters: %s)", svcid.ServiceName, strings.Join(svcid.Datacenters, ", ")))
|
||||
} else {
|
||||
ui.Info(fmt.Sprintf(" %s (Datacenters: all)", svcid.ServiceName))
|
||||
}
|
||||
}
|
||||
if token.Rules != "" {
|
||||
ui.Info(fmt.Sprintf("Rules:"))
|
||||
ui.Info(token.Rules)
|
||||
|
@ -51,6 +59,14 @@ func PrintTokenListEntry(token *api.ACLTokenListEntry, ui cli.Ui, showMeta bool)
|
|||
for _, policy := range token.Policies {
|
||||
ui.Info(fmt.Sprintf(" %s - %s", policy.ID, policy.Name))
|
||||
}
|
||||
ui.Info(fmt.Sprintf("Service Identities:"))
|
||||
for _, svcid := range token.ServiceIdentities {
|
||||
if len(svcid.Datacenters) > 0 {
|
||||
ui.Info(fmt.Sprintf(" %s (Datacenters: %s)", svcid.ServiceName, strings.Join(svcid.Datacenters, ", ")))
|
||||
} else {
|
||||
ui.Info(fmt.Sprintf(" %s (Datacenters: all)", svcid.ServiceName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func PrintPolicy(policy *api.ACLPolicy, ui cli.Ui, showMeta bool) {
|
||||
|
@ -191,3 +207,24 @@ func GetRulesFromLegacyToken(client *api.Client, tokenID string, isSecret bool)
|
|||
|
||||
return token.Rules, nil
|
||||
}
|
||||
|
||||
func ExtractServiceIdentities(serviceIdents []string) ([]*api.ACLServiceIdentity, error) {
|
||||
var out []*api.ACLServiceIdentity
|
||||
for _, svcidRaw := range serviceIdents {
|
||||
parts := strings.Split(svcidRaw, ":")
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
out = append(out, &api.ACLServiceIdentity{
|
||||
ServiceName: parts[0],
|
||||
Datacenters: strings.Split(parts[1], ","),
|
||||
})
|
||||
case 1:
|
||||
out = append(out, &api.ACLServiceIdentity{
|
||||
ServiceName: parts[0],
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("Malformed -service-identity argument: %q", svcidRaw)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ type cmd struct {
|
|||
|
||||
policyIDs []string
|
||||
policyNames []string
|
||||
serviceIdents []string
|
||||
expirationTTL time.Duration
|
||||
description string
|
||||
local bool
|
||||
|
@ -41,6 +42,9 @@ func (c *cmd) init() {
|
|||
"policy to use for this token. May be specified multiple times")
|
||||
c.flags.Var((*flags.AppendSliceValue)(&c.policyNames), "policy-name", "Name of a "+
|
||||
"policy to use for this token. May be specified multiple times")
|
||||
c.flags.Var((*flags.AppendSliceValue)(&c.serviceIdents), "service-identity", "Name of a "+
|
||||
"service identity to use for this token. May be specified multiple times. Format is "+
|
||||
"the SERVICENAME or SERVICENAME:DATACENTER1,DATACENTER2,...")
|
||||
c.flags.DurationVar(&c.expirationTTL, "expires-ttl", 0, "Duration of time this "+
|
||||
"token should be valid for")
|
||||
c.http = &flags.HTTPFlags{}
|
||||
|
@ -54,8 +58,9 @@ func (c *cmd) Run(args []string) int {
|
|||
return 1
|
||||
}
|
||||
|
||||
if len(c.policyNames) == 0 && len(c.policyIDs) == 0 {
|
||||
c.UI.Error(fmt.Sprintf("Cannot create a token without specifying -policy-name or -policy-id at least once"))
|
||||
if len(c.policyNames) == 0 && len(c.policyIDs) == 0 &&
|
||||
len(c.serviceIdents) == 0 {
|
||||
c.UI.Error(fmt.Sprintf("Cannot create a token without specifying -policy-name, -policy-id, or -service-identity at least once"))
|
||||
return 1
|
||||
}
|
||||
|
||||
|
@ -73,6 +78,13 @@ func (c *cmd) Run(args []string) int {
|
|||
newToken.ExpirationTTL = c.expirationTTL
|
||||
}
|
||||
|
||||
parsedServiceIdents, err := acl.ExtractServiceIdentities(c.serviceIdents)
|
||||
if err != nil {
|
||||
c.UI.Error(err.Error())
|
||||
return 1
|
||||
}
|
||||
newToken.ServiceIdentities = parsedServiceIdents
|
||||
|
||||
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.
|
||||
|
@ -119,4 +131,7 @@ Usage: consul acl token create [options]
|
|||
$ consul acl token create -description "Replication token" \
|
||||
-policy-id b52fc3de-5 \
|
||||
-policy-name "acl-replication"
|
||||
-policy-name "acl-replication" \
|
||||
-service-identity "web" \
|
||||
-service-identity "db:east,west"
|
||||
`
|
||||
|
|
|
@ -25,8 +25,10 @@ type cmd struct {
|
|||
tokenID string
|
||||
policyIDs []string
|
||||
policyNames []string
|
||||
serviceIdents []string
|
||||
description string
|
||||
mergePolicies bool
|
||||
mergeServiceIdents bool
|
||||
showMeta bool
|
||||
upgradeLegacy bool
|
||||
}
|
||||
|
@ -37,6 +39,8 @@ func (c *cmd) init() {
|
|||
"as the content hash and raft indices should be shown for each entry")
|
||||
c.flags.BoolVar(&c.mergePolicies, "merge-policies", false, "Merge the new policies "+
|
||||
"with the existing policies")
|
||||
c.flags.BoolVar(&c.mergeServiceIdents, "merge-service-identities", false, "Merge the new service identities "+
|
||||
"with the existing service identities")
|
||||
c.flags.StringVar(&c.tokenID, "id", "", "The Accessor ID of the token to read. "+
|
||||
"It may be specified as a unique ID prefix but will error if the prefix "+
|
||||
"matches multiple token Accessor IDs")
|
||||
|
@ -45,6 +49,9 @@ func (c *cmd) init() {
|
|||
"policy to use for this token. May be specified multiple times")
|
||||
c.flags.Var((*flags.AppendSliceValue)(&c.policyNames), "policy-name", "Name of a "+
|
||||
"policy to use for this token. May be specified multiple times")
|
||||
c.flags.Var((*flags.AppendSliceValue)(&c.serviceIdents), "service-identity", "Name of a "+
|
||||
"service identity to use for this token. May be specified multiple times. Format is "+
|
||||
"the SERVICENAME or SERVICENAME:DATACENTER1,DATACENTER2,...")
|
||||
c.flags.BoolVar(&c.upgradeLegacy, "upgrade-legacy", false, "Add new polices "+
|
||||
"to a legacy token replacing all existing rules. This will cause the legacy "+
|
||||
"token to behave exactly like a new token but keep the same Secret.\n"+
|
||||
|
@ -107,6 +114,12 @@ func (c *cmd) Run(args []string) int {
|
|||
token.Description = c.description
|
||||
}
|
||||
|
||||
parsedServiceIdents, err := acl.ExtractServiceIdentities(c.serviceIdents)
|
||||
if err != nil {
|
||||
c.UI.Error(err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
if c.mergePolicies {
|
||||
for _, policyName := range c.policyNames {
|
||||
found := false
|
||||
|
@ -162,6 +175,26 @@ func (c *cmd) Run(args []string) int {
|
|||
}
|
||||
}
|
||||
|
||||
if c.mergeServiceIdents {
|
||||
for _, svcid := range parsedServiceIdents {
|
||||
found := -1
|
||||
for i, link := range token.ServiceIdentities {
|
||||
if link.ServiceName == svcid.ServiceName {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found != -1 {
|
||||
token.ServiceIdentities[found] = svcid
|
||||
} else {
|
||||
token.ServiceIdentities = append(token.ServiceIdentities, svcid)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
token.ServiceIdentities = parsedServiceIdents
|
||||
}
|
||||
|
||||
token, _, err = client.ACL().TokenUpdate(token, nil)
|
||||
if err != nil {
|
||||
c.UI.Error(fmt.Sprintf("Failed to update token %s: %v", tokenID, err))
|
||||
|
|
Loading…
Reference in New Issue