diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 1c5a241a5a1..6f7a0eb76cb 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.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) diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 1132039126b..d50627721cd 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -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 diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index 0b77580947d..2e40100ebe8 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -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) {} diff --git a/pkg/services/authn/authnimpl/service_test.go b/pkg/services/authn/authnimpl/service_test.go index c65bc7d7cc9..045ebe6accf 100644 --- a/pkg/services/authn/authnimpl/service_test.go +++ b/pkg/services/authn/authnimpl/service_test.go @@ -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 { diff --git a/pkg/services/authn/authntest/mock.go b/pkg/services/authn/authntest/mock.go index 2d8d13b93e5..5568bb0f8da 100644 --- a/pkg/services/authn/authntest/mock.go +++ b/pkg/services/authn/authntest/mock.go @@ -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 { diff --git a/pkg/services/authn/clients/api_key.go b/pkg/services/authn/clients/api_key.go index 40050056311..c8582d02fda 100644 --- a/pkg/services/authn/clients/api_key.go +++ b/pkg/services/authn/clients/api_key.go @@ -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}, + } +} diff --git a/pkg/services/authn/clients/api_key_test.go b/pkg/services/authn/clients/api_key_test.go index 5cb8ae63118..2905503a633 100644 --- a/pkg/services/authn/clients/api_key_test.go +++ b/pkg/services/authn/clients/api_key_test.go @@ -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 } diff --git a/pkg/services/authn/clients/identity.go b/pkg/services/authn/clients/identity.go index 33011d1fd53..3a6fa745bc0 100644 --- a/pkg/services/authn/clients/identity.go +++ b/pkg/services/authn/clients/identity.go @@ -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 } diff --git a/pkg/services/authn/error.go b/pkg/services/authn/error.go index 053ceeacfab..5420ccd19ad 100644 --- a/pkg/services/authn/error.go +++ b/pkg/services/authn/error.go @@ -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") ) diff --git a/pkg/services/authn/identity.go b/pkg/services/authn/identity.go index d0d31f7286a..59b23faefbb 100644 --- a/pkg/services/authn/identity.go +++ b/pkg/services/authn/identity.go @@ -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 } diff --git a/pkg/services/authn/namespace.go b/pkg/services/authn/namespace.go new file mode 100644 index 00000000000..c46da890e86 --- /dev/null +++ b/pkg/services/authn/namespace.go @@ -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) +}