Authn: Identity resolvers (#85930)

* AuthN: Add NamespaceID struct. We should replace the usage of encoded namespaceID with this one

* AuthN: Add optional interface that clients can implement to be able to resolve identity for a namespace

* Authn: Implement IdentityResolverClient for api keys

* AuthN: use idenity resolvers

Co-authored-by: Misi <mgyongyosi@users.noreply.github.com>
pull/86030/head
Karl Persson 1 year ago committed by GitHub
parent c837d95677
commit 73fecc8d80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      pkg/services/accesscontrol/accesscontrol.go
  2. 14
      pkg/services/authn/authn.go
  3. 64
      pkg/services/authn/authnimpl/service.go
  4. 60
      pkg/services/authn/authnimpl/service_test.go
  5. 30
      pkg/services/authn/authntest/mock.go
  6. 101
      pkg/services/authn/clients/api_key.go
  7. 94
      pkg/services/authn/clients/api_key_test.go
  8. 17
      pkg/services/authn/clients/identity.go
  9. 1
      pkg/services/authn/error.go
  10. 20
      pkg/services/authn/identity.go
  11. 89
      pkg/services/authn/namespace.go

@ -175,7 +175,9 @@ func HasGlobalAccess(ac AccessControl, authnService authn.Service, c *contextmod
var targetOrgID int64 = GlobalOrgID
orgUser, err := authnService.ResolveIdentity(c.Req.Context(), targetOrgID, c.SignedInUser.GetID())
if err != nil {
deny(c, nil, fmt.Errorf("failed to authenticate user in target org: %w", err))
// This will be an common error for entities that can't authenticate in global scope
c.Logger.Debug("Failed to authenticate user in global scope", "error", err)
return false
}
hasAccess, err := ac.Evaluate(c.Req.Context(), orgUser, evaluator)

@ -108,7 +108,7 @@ type Client interface {
}
// ContextAwareClient is an optional interface that auth client can implement.
// Clients that implements this interface will be tried during request authentication
// Clients that implements this interface will be tried during request authentication.
type ContextAwareClient interface {
Client
// Test should return true if client can be used to authenticate request
@ -127,7 +127,7 @@ type HookClient interface {
// RedirectClient is an optional interface that auth clients can implement.
// Clients that implements this interface can be used to generate redirect urls
// for authentication flows, e.g. oauth clients
// for authentication flows, e.g. oauth clients.
type RedirectClient interface {
Client
RedirectURL(ctx context.Context, r *Request) (*Redirect, error)
@ -150,12 +150,20 @@ type ProxyClient interface {
}
// UsageStatClient is an optional interface that auth clients can implement.
// Clients that implements this interface can specify a usage stat collection hook
// Clients that implements this interface can specify a usage stat collection hook.
type UsageStatClient interface {
Client
UsageStatFn(ctx context.Context) (map[string]any, error)
}
// IdentityResolverClient is an optional interface that auth clients can implement.
// Clients that implements this interface can resolve an full identity from an orgID and namespaceID.
type IdentityResolverClient interface {
Client
Namespace() string
ResolveIdentity(ctx context.Context, orgID int64, namespaceID NamespaceID) (*Identity, error)
}
type Request struct {
// OrgID will be populated by authn.Service
OrgID int64

@ -49,15 +49,16 @@ func ProvideService(
sessionService auth.UserTokenService, usageStats usagestats.Service, registerer prometheus.Registerer,
) *Service {
s := &Service{
log: log.New("authn.service"),
cfg: cfg,
clients: make(map[string]authn.Client),
clientQueue: newQueue[authn.ContextAwareClient](),
tracer: tracer,
metrics: newMetrics(registerer),
sessionService: sessionService,
postAuthHooks: newQueue[authn.PostAuthHookFn](),
postLoginHooks: newQueue[authn.PostLoginHookFn](),
log: log.New("authn.service"),
cfg: cfg,
clients: make(map[string]authn.Client),
clientQueue: newQueue[authn.ContextAwareClient](),
idenityResolverClients: make(map[string]authn.IdentityResolverClient),
tracer: tracer,
metrics: newMetrics(registerer),
sessionService: sessionService,
postAuthHooks: newQueue[authn.PostAuthHookFn](),
postLoginHooks: newQueue[authn.PostLoginHookFn](),
}
usageStats.RegisterMetricsFunc(s.getUsageStats)
@ -71,6 +72,8 @@ type Service struct {
clients map[string]authn.Client
clientQueue *queue[authn.ContextAwareClient]
idenityResolverClients map[string]authn.IdentityResolverClient
tracer tracing.Tracer
metrics *metrics
@ -292,19 +295,29 @@ func (s *Service) ResolveIdentity(ctx context.Context, orgID int64, namespaceID
// hack to not update last seen
r.SetMeta(authn.MetaKeyIsLogin, "true")
identity, err := s.authenticate(ctx, clients.ProvideIdentity(namespaceID), r)
id, err := authn.ParseNamespaceID(namespaceID)
if err != nil {
return nil, err
}
return identity, nil
identity, err := s.resolveIdenity(ctx, orgID, id)
if err != nil {
return nil, err
}
return s.authenticate(ctx, clients.ProvideIdentity(identity), r)
}
func (s *Service) RegisterClient(c authn.Client) {
s.clients[c.Name()] = c
if cac, ok := c.(authn.ContextAwareClient); ok {
s.clientQueue.insert(cac, cac.Priority())
}
if rc, ok := c.(authn.IdentityResolverClient); ok {
s.idenityResolverClients[rc.Namespace()] = rc
}
}
func (s *Service) SyncIdentity(ctx context.Context, identity *authn.Identity) error {
@ -314,6 +327,35 @@ func (s *Service) SyncIdentity(ctx context.Context, identity *authn.Identity) er
return s.runPostAuthHooks(ctx, identity, r)
}
func (s *Service) resolveIdenity(ctx context.Context, orgID int64, namespaceID authn.NamespaceID) (*authn.Identity, error) {
if namespaceID.IsNamespace(authn.NamespaceUser) {
return &authn.Identity{
OrgID: orgID,
ID: namespaceID.String(),
ClientParams: authn.ClientParams{
AllowGlobalOrg: true,
FetchSyncedUser: true,
SyncPermissions: true,
}}, nil
}
if namespaceID.IsNamespace(authn.NamespaceServiceAccount) {
return &authn.Identity{
ID: namespaceID.String(),
OrgID: orgID,
ClientParams: authn.ClientParams{
FetchSyncedUser: true,
SyncPermissions: true,
}}, nil
}
resolver, ok := s.idenityResolverClients[namespaceID.Namespace()]
if !ok {
return nil, authn.ErrUnsupportedIdentity.Errorf("no resolver for : %s", namespaceID.Namespace())
}
return resolver.ResolveIdentity(ctx, orgID, namespaceID)
}
func (s *Service) errorLogFunc(ctx context.Context, err error) func(msg string, ctx ...any) {
if errors.Is(err, context.Canceled) {
return func(msg string, ctx ...any) {}

@ -383,6 +383,49 @@ func TestService_Logout(t *testing.T) {
}
}
func TestService_ResolveIdentity(t *testing.T) {
t.Run("should return error for for unknown namespace", func(t *testing.T) {
svc := setupTests(t)
_, err := svc.ResolveIdentity(context.Background(), 1, "some:1")
assert.ErrorIs(t, err, authn.ErrInvalidNamepsaceID)
})
t.Run("should return error for for namespace that don't have a resolver", func(t *testing.T) {
svc := setupTests(t)
_, err := svc.ResolveIdentity(context.Background(), 1, "api-key:1")
assert.ErrorIs(t, err, authn.ErrUnsupportedIdentity)
})
t.Run("should resolve for user", func(t *testing.T) {
svc := setupTests(t)
identity, err := svc.ResolveIdentity(context.Background(), 1, "user:1")
assert.NoError(t, err)
assert.NotNil(t, identity)
})
t.Run("should resolve for service account", func(t *testing.T) {
svc := setupTests(t)
identity, err := svc.ResolveIdentity(context.Background(), 1, "service-account:1")
assert.NoError(t, err)
assert.NotNil(t, identity)
})
t.Run("should resolve for valid namespace if client is registered", func(t *testing.T) {
svc := setupTests(t, func(svc *Service) {
svc.RegisterClient(&authntest.MockClient{
NamespaceFunc: func() string { return authn.NamespaceAPIKey },
ResolveIdentityFunc: func(ctx context.Context, orgID int64, namespaceID authn.NamespaceID) (*authn.Identity, error) {
return &authn.Identity{}, nil
},
})
})
identity, err := svc.ResolveIdentity(context.Background(), 1, "api-key:1")
assert.NoError(t, err)
assert.NotNil(t, identity)
})
}
func mustParseURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
@ -395,14 +438,15 @@ func setupTests(t *testing.T, opts ...func(svc *Service)) *Service {
t.Helper()
s := &Service{
log: log.NewNopLogger(),
cfg: setting.NewCfg(),
clients: map[string]authn.Client{},
clientQueue: newQueue[authn.ContextAwareClient](),
tracer: tracing.InitializeTracerForTest(),
metrics: newMetrics(nil),
postAuthHooks: newQueue[authn.PostAuthHookFn](),
postLoginHooks: newQueue[authn.PostLoginHookFn](),
log: log.NewNopLogger(),
cfg: setting.NewCfg(),
clients: make(map[string]authn.Client),
clientQueue: newQueue[authn.ContextAwareClient](),
idenityResolverClients: make(map[string]authn.IdentityResolverClient),
tracer: tracing.InitializeTracerForTest(),
metrics: newMetrics(nil),
postAuthHooks: newQueue[authn.PostAuthHookFn](),
postLoginHooks: newQueue[authn.PostLoginHookFn](),
}
for _, o := range opts {

@ -60,14 +60,17 @@ func (m *MockService) SyncIdentity(ctx context.Context, identity *authn.Identity
var _ authn.HookClient = new(MockClient)
var _ authn.LogoutClient = new(MockClient)
var _ authn.ContextAwareClient = new(MockClient)
var _ authn.IdentityResolverClient = new(MockClient)
type MockClient struct {
NameFunc func() string
AuthenticateFunc func(ctx context.Context, r *authn.Request) (*authn.Identity, error)
TestFunc func(ctx context.Context, r *authn.Request) bool
PriorityFunc func() uint
HookFunc func(ctx context.Context, identity *authn.Identity, r *authn.Request) error
LogoutFunc func(ctx context.Context, user identity.Requester) (*authn.Redirect, bool)
NameFunc func() string
AuthenticateFunc func(ctx context.Context, r *authn.Request) (*authn.Identity, error)
TestFunc func(ctx context.Context, r *authn.Request) bool
PriorityFunc func() uint
HookFunc func(ctx context.Context, identity *authn.Identity, r *authn.Request) error
LogoutFunc func(ctx context.Context, user identity.Requester) (*authn.Redirect, bool)
NamespaceFunc func() string
ResolveIdentityFunc func(ctx context.Context, orgID int64, namespaceID authn.NamespaceID) (*authn.Identity, error)
}
func (m MockClient) Name() string {
@ -112,6 +115,21 @@ func (m *MockClient) Logout(ctx context.Context, user identity.Requester) (*auth
return nil, false
}
func (m *MockClient) Namespace() string {
if m.NamespaceFunc != nil {
return m.NamespaceFunc()
}
return ""
}
// ResolveIdentity implements authn.IdentityResolverClient.
func (m *MockClient) ResolveIdentity(ctx context.Context, orgID int64, namespaceID authn.NamespaceID) (*authn.Identity, error) {
if m.ResolveIdentityFunc != nil {
return m.ResolveIdentityFunc(ctx, orgID, namespaceID)
}
return nil, nil
}
var _ authn.ProxyClient = new(MockProxyClient)
type MockProxyClient struct {

@ -27,6 +27,7 @@ var (
var _ authn.HookClient = new(APIKey)
var _ authn.ContextAwareClient = new(APIKey)
var _ authn.IdentityResolverClient = new(APIKey)
func ProvideAPIKey(apiKeyService apikey.Service) *APIKey {
return &APIKey{
@ -45,7 +46,7 @@ func (s *APIKey) Name() string {
}
func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
apiKey, err := s.getAPIKey(ctx, getTokenFromRequest(r))
key, err := s.getAPIKey(ctx, getTokenFromRequest(r))
if err != nil {
if errors.Is(err, apikeygen.ErrInvalidApiKey) {
return nil, errAPIKeyInvalid.Errorf("API key is invalid")
@ -53,37 +54,20 @@ func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Ide
return nil, err
}
if apiKey.Expires != nil && *apiKey.Expires <= time.Now().Unix() {
return nil, errAPIKeyExpired.Errorf("API key has expired")
}
if apiKey.IsRevoked != nil && *apiKey.IsRevoked {
return nil, errAPIKeyRevoked.Errorf("Api key is revoked")
if r.OrgID == 0 {
r.OrgID = key.OrgID
}
if r.OrgID == 0 {
r.OrgID = apiKey.OrgID
} else if r.OrgID != apiKey.OrgID {
return nil, errAPIKeyOrgMismatch.Errorf("API does not belong in Organization %v", r.OrgID)
if err := validateApiKey(r.OrgID, key); err != nil {
return nil, err
}
// if the api key don't belong to a service account construct the identity and return it
if apiKey.ServiceAccountId == nil || *apiKey.ServiceAccountId < 1 {
return &authn.Identity{
ID: authn.NamespacedID(authn.NamespaceAPIKey, apiKey.ID),
OrgID: apiKey.OrgID,
OrgRoles: map[int64]org.RoleType{apiKey.OrgID: apiKey.Role},
ClientParams: authn.ClientParams{SyncPermissions: true},
AuthenticatedBy: login.APIKeyAuthModule,
}, nil
if key.ServiceAccountId == nil || *key.ServiceAccountId < 1 {
return newAPIKeyIdentity(key), nil
}
return &authn.Identity{
ID: authn.NamespacedID(authn.NamespaceServiceAccount, *apiKey.ServiceAccountId),
OrgID: apiKey.OrgID,
AuthenticatedBy: login.APIKeyAuthModule,
ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true},
}, nil
return newServiceAccountIdentity(key), nil
}
func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, error) {
@ -147,6 +131,38 @@ func (s *APIKey) Priority() uint {
return 30
}
func (s *APIKey) Namespace() string {
return authn.NamespaceAPIKey
}
func (s *APIKey) ResolveIdentity(ctx context.Context, orgID int64, namespaceID authn.NamespaceID) (*authn.Identity, error) {
if !namespaceID.IsNamespace(authn.NamespaceAPIKey) {
return nil, authn.ErrInvalidNamepsaceID.Errorf("got unspected namespace: %s", namespaceID.Namespace())
}
apiKeyID, err := namespaceID.ParseInt()
if err != nil {
return nil, err
}
key, err := s.apiKeyService.GetApiKeyById(ctx, &apikey.GetByIDQuery{
ApiKeyID: apiKeyID,
})
if err != nil {
return nil, err
}
if err := validateApiKey(orgID, key); err != nil {
return nil, err
}
if key.ServiceAccountId != nil && *key.ServiceAccountId >= 1 {
return nil, authn.ErrInvalidNamepsaceID.Errorf("api key belongs to service account")
}
return newAPIKeyIdentity(key), nil
}
func (s *APIKey) Hook(ctx context.Context, identity *authn.Identity, r *authn.Request) error {
id, exists := s.getAPIKeyID(ctx, identity, r)
@ -217,3 +233,38 @@ func getTokenFromRequest(r *authn.Request) string {
}
return ""
}
func validateApiKey(orgID int64, key *apikey.APIKey) error {
if key.Expires != nil && *key.Expires <= time.Now().Unix() {
return errAPIKeyExpired.Errorf("API key has expired")
}
if key.IsRevoked != nil && *key.IsRevoked {
return errAPIKeyRevoked.Errorf("Api key is revoked")
}
if orgID != key.OrgID {
return errAPIKeyOrgMismatch.Errorf("API does not belong in Organization")
}
return nil
}
func newAPIKeyIdentity(key *apikey.APIKey) *authn.Identity {
return &authn.Identity{
ID: authn.NamespacedID(authn.NamespaceAPIKey, key.ID),
OrgID: key.OrgID,
OrgRoles: map[int64]org.RoleType{key.OrgID: key.Role},
ClientParams: authn.ClientParams{SyncPermissions: true},
AuthenticatedBy: login.APIKeyAuthModule,
}
}
func newServiceAccountIdentity(key *apikey.APIKey) *authn.Identity {
return &authn.Identity{
ID: authn.NamespacedID(authn.NamespaceServiceAccount, *key.ServiceAccountId),
OrgID: key.OrgID,
AuthenticatedBy: login.APIKeyAuthModule,
ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true},
}
}

@ -283,6 +283,100 @@ func TestAPIKey_GetAPIKeyIDFromIdentity(t *testing.T) {
}
}
func TestAPIKey_ResolveIdentity(t *testing.T) {
type testCase struct {
desc string
namespaceID authn.NamespaceID
exptedApiKey *apikey.APIKey
expectedIdenity *authn.Identity
expectedErr error
}
tests := []testCase{
{
desc: "should return error for invalid namespace",
namespaceID: authn.MustParseNamespaceID("user:1"),
expectedErr: authn.ErrInvalidNamepsaceID,
},
{
desc: "should return error when api key has expired",
namespaceID: authn.MustParseNamespaceID("api-key:1"),
exptedApiKey: &apikey.APIKey{
ID: 1,
OrgID: 1,
Expires: intPtr(0),
},
expectedErr: errAPIKeyExpired,
},
{
desc: "should return error when api key is revoked",
namespaceID: authn.MustParseNamespaceID("api-key:1"),
exptedApiKey: &apikey.APIKey{
ID: 1,
OrgID: 1,
IsRevoked: boolPtr(true),
},
expectedErr: errAPIKeyRevoked,
},
{
desc: "should return error when api key is connected to service account",
namespaceID: authn.MustParseNamespaceID("api-key:1"),
exptedApiKey: &apikey.APIKey{
ID: 1,
OrgID: 1,
ServiceAccountId: intPtr(1),
},
expectedErr: authn.ErrInvalidNamepsaceID,
},
{
desc: "should return error when api key is belongs to different org",
namespaceID: authn.MustParseNamespaceID("api-key:1"),
exptedApiKey: &apikey.APIKey{
ID: 1,
OrgID: 2,
ServiceAccountId: intPtr(1),
},
expectedErr: errAPIKeyOrgMismatch,
},
{
desc: "should return valid idenitty",
namespaceID: authn.MustParseNamespaceID("api-key:1"),
exptedApiKey: &apikey.APIKey{
ID: 1,
OrgID: 1,
Role: org.RoleEditor,
},
expectedIdenity: &authn.Identity{
OrgID: 1,
OrgRoles: map[int64]org.RoleType{1: org.RoleEditor},
ID: "api-key:1",
AuthenticatedBy: login.APIKeyAuthModule,
ClientParams: authn.ClientParams{SyncPermissions: true},
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvideAPIKey(&apikeytest.Service{
ExpectedAPIKey: tt.exptedApiKey,
})
identity, err := c.ResolveIdentity(context.Background(), 1, tt.namespaceID)
if tt.expectedErr != nil {
assert.Nil(t, identity)
assert.ErrorIs(t, err, tt.expectedErr)
return
}
assert.NoError(t, err)
assert.EqualValues(t, *tt.expectedIdenity, *identity)
})
}
}
func intPtr(n int64) *int64 {
return &n
}

@ -8,27 +8,18 @@ import (
var _ authn.Client = (*IdentityClient)(nil)
func ProvideIdentity(namespaceID string) *IdentityClient {
return &IdentityClient{namespaceID}
func ProvideIdentity(identity *authn.Identity) *IdentityClient {
return &IdentityClient{identity}
}
type IdentityClient struct {
namespaceID string
identity *authn.Identity
}
func (i *IdentityClient) Name() string {
return "identity"
}
// Authenticate implements authn.Client.
func (i *IdentityClient) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
return &authn.Identity{
OrgID: r.OrgID,
ID: i.namespaceID,
ClientParams: authn.ClientParams{
AllowGlobalOrg: true,
FetchSyncedUser: true,
SyncPermissions: true,
},
}, nil
return i.identity, nil
}

@ -8,4 +8,5 @@ var (
ErrClientNotConfigured = errutil.BadRequest("auth.client.notConfigured")
ErrUnsupportedIdentity = errutil.NotImplemented("auth.identity.unsupported")
ErrExpiredAccessToken = errutil.Unauthorized("oauth.expired-token", errutil.WithPublicMessage("OAuth access token expired"))
ErrInvalidNamepsaceID = errutil.BadRequest("auth.identity.invalid-namespace-id")
)

@ -16,24 +16,7 @@ import (
"github.com/grafana/grafana/pkg/services/user"
)
// NamespacedID builds a namespaced ID from a namespace and an ID.
func NamespacedID(namespace string, id int64) string {
return fmt.Sprintf("%s:%d", namespace, id)
}
const (
NamespaceUser = identity.NamespaceUser
NamespaceAPIKey = identity.NamespaceAPIKey
NamespaceServiceAccount = identity.NamespaceServiceAccount
NamespaceAnonymous = identity.NamespaceAnonymous
NamespaceRenderService = identity.NamespaceRenderService
NamespaceAccessPolicy = identity.NamespaceAccessPolicy
)
const (
AnonymousNamespaceID = NamespaceAnonymous + ":0"
GlobalOrgID = int64(0)
)
const GlobalOrgID = int64(0)
var _ identity.Requester = (*Identity)(nil)
@ -145,7 +128,6 @@ func (i *Identity) GetLogin() string {
return i.Login
}
// GetOrgID implements identity.Requester.
func (i *Identity) GetOrgID() int64 {
return i.OrgID
}

@ -0,0 +1,89 @@
package authn
import (
"fmt"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/services/auth/identity"
)
const (
NamespaceUser = identity.NamespaceUser
NamespaceAPIKey = identity.NamespaceAPIKey
NamespaceServiceAccount = identity.NamespaceServiceAccount
NamespaceAnonymous = identity.NamespaceAnonymous
NamespaceRenderService = identity.NamespaceRenderService
NamespaceAccessPolicy = identity.NamespaceAccessPolicy
AnonymousNamespaceID = NamespaceAnonymous + ":0"
)
var namespaceLookup = map[string]struct{}{
NamespaceUser: {},
NamespaceAPIKey: {},
NamespaceServiceAccount: {},
NamespaceAnonymous: {},
NamespaceRenderService: {},
NamespaceAccessPolicy: {},
}
// NamespacedID builds a namespaced ID from a namespace and an ID.
func NamespacedID(namespace string, id int64) string {
return fmt.Sprintf("%s:%d", namespace, id)
}
func ParseNamespaceID(str string) (NamespaceID, error) {
var namespaceID NamespaceID
parts := strings.Split(str, ":")
if len(parts) != 2 {
return namespaceID, ErrInvalidNamepsaceID.Errorf("expected namespace id to have 2 parts")
}
namespace, id := parts[0], parts[1]
if _, ok := namespaceLookup[namespace]; !ok {
return namespaceID, ErrInvalidNamepsaceID.Errorf("got invalid namespace %s", namespace)
}
namespaceID.id = id
namespaceID.namespace = namespace
return namespaceID, nil
}
// MustParseNamespaceID parses namespace id, it will panic it failes to do so.
// Sutable to use in tests or when we can garantuee that we pass a correct format.
func MustParseNamespaceID(str string) NamespaceID {
namespaceID, err := ParseNamespaceID(str)
if err != nil {
panic(err)
}
return namespaceID
}
// FIXME: use this instead of encoded string through the codebase
type NamespaceID struct {
id string
namespace string
}
func (ni NamespaceID) ID() string {
return ni.id
}
func (ni NamespaceID) ParseInt() (int64, error) {
return strconv.ParseInt(ni.id, 10, 64)
}
func (ni NamespaceID) Namespace() string {
return ni.namespace
}
func (ni NamespaceID) IsNamespace(expected ...string) bool {
return identity.IsNamespace(ni.namespace, expected...)
}
func (ni NamespaceID) String() string {
return fmt.Sprintf("%s:%s", ni.namespace, ni.id)
}
Loading…
Cancel
Save