From 824302be3805a0a3df64269dd5a9938de7cf268c Mon Sep 17 00:00:00 2001 From: Nityananda Gohain Date: Wed, 21 May 2025 17:21:19 +0530 Subject: [PATCH] chore: update API key (#7959) * chore: update API key * fix: delete api key on user delete * fix: migration * fix: api * fix: address comments * fix: address comments * fix: update structs * fix: minor changes * fix: error message * fix: address comments * fix: integration tests * fix: minor issues * fix: integration tests --- ee/http/middleware/pat.go | 15 +- ee/modules/user/impluser/handler.go | 202 ++++++++++++++++++ ee/modules/user/impluser/module.go | 27 ++- ee/query-service/app/api/api.go | 8 +- ee/query-service/app/api/cloudIntegrations.go | 29 ++- ee/query-service/app/api/pat.go | 186 ---------------- ee/query-service/dao/interface.go | 9 - ee/query-service/dao/sqlite/pat.go | 201 ----------------- ee/query-service/model/pat.go | 7 - ee/sqlstore/postgressqlstore/dialect.go | 4 + ee/types/personal_access_token.go | 76 ------- pkg/modules/user/impluser/handler.go | 20 ++ pkg/modules/user/impluser/module.go | 20 ++ pkg/modules/user/impluser/store.go | 116 ++++++++++ pkg/modules/user/user.go | 13 ++ pkg/query-service/utils/testutils.go | 1 + pkg/signoz/provider.go | 1 + pkg/sqlmigration/033_api_keys.go | 162 ++++++++++++++ pkg/sqlmigration/sqlmigration.go | 1 + pkg/sqlstore/sqlitesqlstore/dialect.go | 4 + pkg/types/factor_api_key.go | 135 ++++++++++++ pkg/types/user.go | 13 +- tests/integration/src/bootstrap/d_apikey.py | 2 +- 23 files changed, 744 insertions(+), 508 deletions(-) delete mode 100644 ee/query-service/app/api/pat.go delete mode 100644 ee/query-service/dao/sqlite/pat.go delete mode 100644 ee/query-service/model/pat.go delete mode 100644 ee/types/personal_access_token.go create mode 100644 pkg/sqlmigration/033_api_keys.go create mode 100644 pkg/types/factor_api_key.go diff --git a/ee/http/middleware/pat.go b/ee/http/middleware/pat.go index f99bfdf3b8..59a18a2b79 100644 --- a/ee/http/middleware/pat.go +++ b/ee/http/middleware/pat.go @@ -4,7 +4,6 @@ import ( "net/http" "time" - eeTypes "github.com/SigNoz/signoz/ee/types" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" @@ -25,7 +24,7 @@ func (p *Pat) Wrap(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var values []string var patToken string - var pat eeTypes.StorablePersonalAccessToken + var pat types.StorableAPIKey for _, header := range p.headers { values = append(values, r.Header.Get(header)) @@ -48,7 +47,7 @@ func (p *Pat) Wrap(next http.Handler) http.Handler { return } - if pat.ExpiresAt < time.Now().Unix() && pat.ExpiresAt != 0 { + if pat.ExpiresAt.Before(time.Now()) { next.ServeHTTP(w, r) return } @@ -61,15 +60,9 @@ func (p *Pat) Wrap(next http.Handler) http.Handler { return } - role, err := types.NewRole(user.Role) - if err != nil { - next.ServeHTTP(w, r) - return - } - jwt := authtypes.Claims{ UserID: user.ID.String(), - Role: role, + Role: pat.Role, Email: user.Email, OrgID: user.OrgID, } @@ -80,7 +73,7 @@ func (p *Pat) Wrap(next http.Handler) http.Handler { next.ServeHTTP(w, r) - pat.LastUsed = time.Now().Unix() + pat.LastUsed = time.Now() _, err = p.store.BunDB().NewUpdate().Model(&pat).Column("last_used").Where("token = ?", patToken).Where("revoked = false").Exec(r.Context()) if err != nil { zap.L().Error("Failed to update PAT last used in db, err: %v", zap.Error(err)) diff --git a/ee/modules/user/impluser/handler.go b/ee/modules/user/impluser/handler.go index 6599c1e491..79ebe46b4c 100644 --- a/ee/modules/user/impluser/handler.go +++ b/ee/modules/user/impluser/handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "slices" "time" "github.com/SigNoz/signoz/pkg/errors" @@ -11,6 +12,8 @@ import ( "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" "github.com/gorilla/mux" ) @@ -201,3 +204,202 @@ func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) { render.Success(w, http.StatusOK, gettableInvite) return } + +func (h *Handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + userID, err := valuer.NewUUID(claims.UserID) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7")) + return + } + + req := new(types.PostableAPIKey) + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key")) + return + } + + apiKey, err := types.NewStorableAPIKey( + req.Name, + userID, + req.Role, + req.ExpiresInDays, + ) + if err != nil { + render.Error(w, err) + return + } + + err = h.module.CreateAPIKey(ctx, apiKey) + if err != nil { + render.Error(w, err) + return + } + + // just corrected the status code, response is same, + render.Success(w, http.StatusCreated, apiKey) +} + +func (h *Handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7")) + return + } + + apiKeys, err := h.module.ListAPIKeys(ctx, orgID) + if err != nil { + render.Error(w, err) + return + } + + // for backward compatibility + if len(apiKeys) == 0 { + render.Success(w, http.StatusOK, []types.GettableAPIKey{}) + return + } + + result := make([]*types.GettableAPIKey, len(apiKeys)) + for i, apiKey := range apiKeys { + result[i] = types.NewGettableAPIKeyFromStorableAPIKey(apiKey) + } + + render.Success(w, http.StatusOK, result) + +} + +func (h *Handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7")) + return + } + + userID, err := valuer.NewUUID(claims.UserID) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7")) + return + } + + req := types.StorableAPIKey{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key")) + return + } + + idStr := mux.Vars(r)["id"] + id, err := valuer.NewUUID(idStr) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7")) + return + } + + //get the API Key + existingAPIKey, err := h.module.GetAPIKey(ctx, orgID, id) + if err != nil { + render.Error(w, err) + return + } + + // get the user + createdByUser, err := h.module.GetUserByID(ctx, orgID.String(), existingAPIKey.UserID.String()) + if err != nil { + render.Error(w, err) + return + } + + if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email)) { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked")) + return + } + + err = h.module.UpdateAPIKey(ctx, id, &req, userID) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusNoContent, nil) +} + +func (h *Handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + idStr := mux.Vars(r)["id"] + id, err := valuer.NewUUID(idStr) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7")) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7")) + return + } + + userID, err := valuer.NewUUID(claims.UserID) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7")) + return + } + + //get the API Key + existingAPIKey, err := h.module.GetAPIKey(ctx, orgID, id) + if err != nil { + render.Error(w, err) + return + } + + // get the user + createdByUser, err := h.module.GetUserByID(ctx, orgID.String(), existingAPIKey.UserID.String()) + if err != nil { + render.Error(w, err) + return + } + + if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email)) { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked")) + return + } + + if err := h.module.RevokeAPIKey(ctx, id, userID); err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusNoContent, nil) +} diff --git a/ee/modules/user/impluser/module.go b/ee/modules/user/impluser/module.go index dcc3bcab6d..07d9c0a9e5 100644 --- a/ee/modules/user/impluser/module.go +++ b/ee/modules/user/impluser/module.go @@ -12,17 +12,18 @@ import ( baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" "go.uber.org/zap" ) // EnterpriseModule embeds the base module implementation type Module struct { - *baseimpl.Module // Embed the base module implementation - store types.UserStore + user.Module // Embed the base module implementation + store types.UserStore } func NewModule(store types.UserStore) user.Module { - baseModule := baseimpl.NewModule(store).(*baseimpl.Module) + baseModule := baseimpl.NewModule(store) return &Module{ Module: baseModule, store: store, @@ -227,3 +228,23 @@ func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types } return gettableDomain, nil } + +func (m *Module) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error { + return m.store.CreateAPIKey(ctx, apiKey) +} + +func (m *Module) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error { + return m.store.UpdateAPIKey(ctx, id, apiKey, updaterID) +} + +func (m *Module) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) { + return m.store.ListAPIKeys(ctx, orgID) +} + +func (m *Module) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) { + return m.store.GetAPIKey(ctx, orgID, id) +} + +func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error { + return m.store.RevokeAPIKey(ctx, id, removedByUserID) +} diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index a9a9226b6c..812b83b73d 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -146,10 +146,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v1/login", am.OpenAccess(ah.loginUser)).Methods(http.MethodPost) // PAT APIs - router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.createPAT)).Methods(http.MethodPost) - router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.getPATs)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(ah.updatePAT)).Methods(http.MethodPut) - router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(ah.revokePAT)).Methods(http.MethodDelete) + router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.Signoz.Handlers.User.CreateAPIKey)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.Signoz.Handlers.User.ListAPIKeys)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(ah.Signoz.Handlers.User.UpdateAPIKey)).Methods(http.MethodPut) + router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(ah.Signoz.Handlers.User.RevokeAPIKey)).Methods(http.MethodDelete) router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.checkout)).Methods(http.MethodPost) router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet) diff --git a/ee/query-service/app/api/cloudIntegrations.go b/ee/query-service/app/api/cloudIntegrations.go index 8f451608fa..1251a52403 100644 --- a/ee/query-service/app/api/cloudIntegrations.go +++ b/ee/query-service/app/api/cloudIntegrations.go @@ -11,12 +11,12 @@ import ( "time" "github.com/SigNoz/signoz/ee/query-service/constants" - eeTypes "github.com/SigNoz/signoz/ee/types" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/render" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" "github.com/google/uuid" "github.com/gorilla/mux" "go.uber.org/zap" @@ -116,14 +116,21 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId return "", apiErr } - allPats, err := ah.AppDao().ListPATs(ctx, orgId) + orgIdUUID, err := valuer.NewUUID(orgId) + if err != nil { + return "", basemodel.InternalError(fmt.Errorf( + "couldn't parse orgId: %w", err, + )) + } + + allPats, err := ah.Signoz.Modules.User.ListAPIKeys(ctx, orgIdUUID) if err != nil { return "", basemodel.InternalError(fmt.Errorf( "couldn't list PATs: %w", err, )) } for _, p := range allPats { - if p.UserID == integrationUser.ID.String() && p.Name == integrationPATName { + if p.UserID == integrationUser.ID && p.Name == integrationPATName { return p.Token, nil } } @@ -133,19 +140,25 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId zap.String("cloudProvider", cloudProvider), ) - newPAT := eeTypes.NewGettablePAT( + newPAT, err := types.NewStorableAPIKey( integrationPATName, - types.RoleViewer.String(), - integrationUser.ID.String(), + integrationUser.ID, + types.RoleViewer, 0, ) - integrationPAT, err := ah.AppDao().CreatePAT(ctx, orgId, newPAT) if err != nil { return "", basemodel.InternalError(fmt.Errorf( "couldn't create cloud integration PAT: %w", err, )) } - return integrationPAT.Token, nil + + err = ah.Signoz.Modules.User.CreateAPIKey(ctx, newPAT) + if err != nil { + return "", basemodel.InternalError(fmt.Errorf( + "couldn't create cloud integration PAT: %w", err, + )) + } + return newPAT.Token, nil } func (ah *APIHandler) getOrCreateCloudIntegrationUser( diff --git a/ee/query-service/app/api/pat.go b/ee/query-service/app/api/pat.go deleted file mode 100644 index 6fc736110d..0000000000 --- a/ee/query-service/app/api/pat.go +++ /dev/null @@ -1,186 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "net/http" - "slices" - "time" - - "github.com/SigNoz/signoz/ee/query-service/model" - eeTypes "github.com/SigNoz/signoz/ee/types" - "github.com/SigNoz/signoz/pkg/errors" - errorsV2 "github.com/SigNoz/signoz/pkg/errors" - "github.com/SigNoz/signoz/pkg/http/render" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "github.com/SigNoz/signoz/pkg/valuer" - "github.com/gorilla/mux" - "go.uber.org/zap" -) - -func (ah *APIHandler) createPAT(w http.ResponseWriter, r *http.Request) { - claims, err := authtypes.ClaimsFromContext(r.Context()) - if err != nil { - render.Error(w, err) - return - } - - req := model.CreatePATRequestBody{} - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - pat := eeTypes.NewGettablePAT( - req.Name, - req.Role, - claims.UserID, - req.ExpiresInDays, - ) - err = validatePATRequest(pat) - if err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - zap.L().Info("Got Create PAT request", zap.Any("pat", pat)) - var apierr basemodel.BaseApiError - if pat, apierr = ah.AppDao().CreatePAT(r.Context(), claims.OrgID, pat); apierr != nil { - RespondError(w, apierr, nil) - return - } - - ah.Respond(w, &pat) -} - -func validatePATRequest(req eeTypes.GettablePAT) error { - _, err := types.NewRole(req.Role) - if err != nil { - return err - } - - if req.ExpiresAt < 0 { - return fmt.Errorf("valid expiresAt is required") - } - - if req.Name == "" { - return fmt.Errorf("valid name is required") - } - - return nil -} - -func (ah *APIHandler) updatePAT(w http.ResponseWriter, r *http.Request) { - claims, err := authtypes.ClaimsFromContext(r.Context()) - if err != nil { - render.Error(w, err) - return - } - - req := eeTypes.GettablePAT{} - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - idStr := mux.Vars(r)["id"] - id, err := valuer.NewUUID(idStr) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7")) - return - } - - //get the pat - existingPAT, err := ah.AppDao().GetPATByID(r.Context(), claims.OrgID, id) - if err != nil { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error())) - return - } - - // get the user - createdByUser, err := ah.Signoz.Modules.User.GetUserByID(r.Context(), claims.OrgID, existingPAT.UserID) - if err != nil { - render.Error(w, err) - return - } - - if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email)) { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "integration user pat cannot be updated")) - return - } - - err = validatePATRequest(req) - if err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - req.UpdatedByUserID = claims.UserID - req.UpdatedAt = time.Now() - var apierr basemodel.BaseApiError - if apierr = ah.AppDao().UpdatePAT(r.Context(), claims.OrgID, req, id); apierr != nil { - RespondError(w, apierr, nil) - return - } - - ah.Respond(w, map[string]string{"data": "pat updated successfully"}) -} - -func (ah *APIHandler) getPATs(w http.ResponseWriter, r *http.Request) { - claims, err := authtypes.ClaimsFromContext(r.Context()) - if err != nil { - render.Error(w, err) - return - } - - pats, apierr := ah.AppDao().ListPATs(r.Context(), claims.OrgID) - if apierr != nil { - RespondError(w, apierr, nil) - return - } - - ah.Respond(w, pats) -} - -func (ah *APIHandler) revokePAT(w http.ResponseWriter, r *http.Request) { - claims, err := authtypes.ClaimsFromContext(r.Context()) - if err != nil { - render.Error(w, err) - return - } - - idStr := mux.Vars(r)["id"] - id, err := valuer.NewUUID(idStr) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7")) - return - } - - //get the pat - existingPAT, paterr := ah.AppDao().GetPATByID(r.Context(), claims.OrgID, id) - if paterr != nil { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, paterr.Error())) - return - } - - // get the user - createdByUser, err := ah.Signoz.Modules.User.GetUserByID(r.Context(), claims.OrgID, existingPAT.UserID) - if err != nil { - render.Error(w, err) - return - } - - if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email)) { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "integration user pat cannot be updated")) - return - } - - zap.L().Info("Revoke PAT with id", zap.String("id", id.StringValue())) - if apierr := ah.AppDao().RevokePAT(r.Context(), claims.OrgID, id, claims.UserID); apierr != nil { - RespondError(w, apierr, nil) - return - } - ah.Respond(w, map[string]string{"data": "pat revoked successfully"}) -} diff --git a/ee/query-service/dao/interface.go b/ee/query-service/dao/interface.go index e9a4b1f631..2e40abcf21 100644 --- a/ee/query-service/dao/interface.go +++ b/ee/query-service/dao/interface.go @@ -4,10 +4,8 @@ import ( "context" "net/url" - eeTypes "github.com/SigNoz/signoz/ee/types" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/valuer" "github.com/google/uuid" ) @@ -22,11 +20,4 @@ type ModelDao interface { UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) basemodel.BaseApiError DeleteDomain(ctx context.Context, id uuid.UUID) basemodel.BaseApiError GetDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, basemodel.BaseApiError) - - CreatePAT(ctx context.Context, orgID string, p eeTypes.GettablePAT) (eeTypes.GettablePAT, basemodel.BaseApiError) - UpdatePAT(ctx context.Context, orgID string, p eeTypes.GettablePAT, id valuer.UUID) basemodel.BaseApiError - GetPAT(ctx context.Context, pat string) (*eeTypes.GettablePAT, basemodel.BaseApiError) - GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*eeTypes.GettablePAT, basemodel.BaseApiError) - ListPATs(ctx context.Context, orgID string) ([]eeTypes.GettablePAT, basemodel.BaseApiError) - RevokePAT(ctx context.Context, orgID string, id valuer.UUID, userID string) basemodel.BaseApiError } diff --git a/ee/query-service/dao/sqlite/pat.go b/ee/query-service/dao/sqlite/pat.go deleted file mode 100644 index 7b9cc3ae4d..0000000000 --- a/ee/query-service/dao/sqlite/pat.go +++ /dev/null @@ -1,201 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - "time" - - "github.com/SigNoz/signoz/ee/query-service/model" - "github.com/SigNoz/signoz/ee/types" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - ossTypes "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/valuer" - - "go.uber.org/zap" -) - -func (m *modelDao) CreatePAT(ctx context.Context, orgID string, p types.GettablePAT) (types.GettablePAT, basemodel.BaseApiError) { - p.StorablePersonalAccessToken.OrgID = orgID - p.StorablePersonalAccessToken.ID = valuer.GenerateUUID() - _, err := m.sqlStore.BunDB().NewInsert(). - Model(&p.StorablePersonalAccessToken). - Exec(ctx) - if err != nil { - zap.L().Error("Failed to insert PAT in db, err: %v", zap.Error(err)) - return types.GettablePAT{}, model.InternalError(fmt.Errorf("PAT insertion failed")) - } - - createdByUser, _ := m.userModule.GetUserByID(ctx, orgID, p.UserID) - if createdByUser == nil { - p.CreatedByUser = types.PatUser{ - NotFound: true, - } - } else { - p.CreatedByUser = types.PatUser{ - User: ossTypes.User{ - Identifiable: ossTypes.Identifiable{ - ID: createdByUser.ID, - }, - DisplayName: createdByUser.DisplayName, - Email: createdByUser.Email, - TimeAuditable: ossTypes.TimeAuditable{ - CreatedAt: createdByUser.CreatedAt, - UpdatedAt: createdByUser.UpdatedAt, - }, - }, - NotFound: false, - } - } - return p, nil -} - -func (m *modelDao) UpdatePAT(ctx context.Context, orgID string, p types.GettablePAT, id valuer.UUID) basemodel.BaseApiError { - _, err := m.sqlStore.BunDB().NewUpdate(). - Model(&p.StorablePersonalAccessToken). - Column("role", "name", "updated_at", "updated_by_user_id"). - Where("id = ?", id.StringValue()). - Where("org_id = ?", orgID). - Where("revoked = false"). - Exec(ctx) - if err != nil { - zap.L().Error("Failed to update PAT in db, err: %v", zap.Error(err)) - return model.InternalError(fmt.Errorf("PAT update failed")) - } - return nil -} - -func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.GettablePAT, basemodel.BaseApiError) { - pats := []types.StorablePersonalAccessToken{} - - if err := m.sqlStore.BunDB().NewSelect(). - Model(&pats). - Where("revoked = false"). - Where("org_id = ?", orgID). - Order("updated_at DESC"). - Scan(ctx); err != nil { - zap.L().Error("Failed to fetch PATs err: %v", zap.Error(err)) - return nil, model.InternalError(fmt.Errorf("failed to fetch PATs")) - } - - patsWithUsers := []types.GettablePAT{} - for i := range pats { - patWithUser := types.GettablePAT{ - StorablePersonalAccessToken: pats[i], - } - - createdByUser, _ := m.userModule.GetUserByID(ctx, orgID, pats[i].UserID) - if createdByUser == nil { - patWithUser.CreatedByUser = types.PatUser{ - NotFound: true, - } - } else { - patWithUser.CreatedByUser = types.PatUser{ - User: ossTypes.User{ - Identifiable: ossTypes.Identifiable{ - ID: createdByUser.ID, - }, - DisplayName: createdByUser.DisplayName, - Email: createdByUser.Email, - TimeAuditable: ossTypes.TimeAuditable{ - CreatedAt: createdByUser.CreatedAt, - UpdatedAt: createdByUser.UpdatedAt, - }, - }, - NotFound: false, - } - } - - updatedByUser, _ := m.userModule.GetUserByID(ctx, orgID, pats[i].UpdatedByUserID) - if updatedByUser == nil { - patWithUser.UpdatedByUser = types.PatUser{ - NotFound: true, - } - } else { - patWithUser.UpdatedByUser = types.PatUser{ - User: ossTypes.User{ - Identifiable: ossTypes.Identifiable{ - ID: updatedByUser.ID, - }, - DisplayName: updatedByUser.DisplayName, - Email: updatedByUser.Email, - TimeAuditable: ossTypes.TimeAuditable{ - CreatedAt: updatedByUser.CreatedAt, - UpdatedAt: updatedByUser.UpdatedAt, - }, - }, - NotFound: false, - } - } - - patsWithUsers = append(patsWithUsers, patWithUser) - } - return patsWithUsers, nil -} - -func (m *modelDao) RevokePAT(ctx context.Context, orgID string, id valuer.UUID, userID string) basemodel.BaseApiError { - updatedAt := time.Now().Unix() - _, err := m.sqlStore.BunDB().NewUpdate(). - Model(&types.StorablePersonalAccessToken{}). - Set("revoked = ?", true). - Set("updated_by_user_id = ?", userID). - Set("updated_at = ?", updatedAt). - Where("id = ?", id.StringValue()). - Where("org_id = ?", orgID). - Exec(ctx) - if err != nil { - zap.L().Error("Failed to revoke PAT in db, err: %v", zap.Error(err)) - return model.InternalError(fmt.Errorf("PAT revoke failed")) - } - return nil -} - -func (m *modelDao) GetPAT(ctx context.Context, token string) (*types.GettablePAT, basemodel.BaseApiError) { - pats := []types.StorablePersonalAccessToken{} - - if err := m.sqlStore.BunDB().NewSelect(). - Model(&pats). - Where("token = ?", token). - Where("revoked = false"). - Scan(ctx); err != nil { - return nil, model.InternalError(fmt.Errorf("failed to fetch PAT")) - } - - if len(pats) != 1 { - return nil, &model.ApiError{ - Typ: model.ErrorInternal, - Err: fmt.Errorf("found zero or multiple PATs with same token, %s", token), - } - } - - patWithUser := types.GettablePAT{ - StorablePersonalAccessToken: pats[0], - } - - return &patWithUser, nil -} - -func (m *modelDao) GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*types.GettablePAT, basemodel.BaseApiError) { - pats := []types.StorablePersonalAccessToken{} - - if err := m.sqlStore.BunDB().NewSelect(). - Model(&pats). - Where("id = ?", id.StringValue()). - Where("org_id = ?", orgID). - Where("revoked = false"). - Scan(ctx); err != nil { - return nil, model.InternalError(fmt.Errorf("failed to fetch PAT")) - } - - if len(pats) != 1 { - return nil, &model.ApiError{ - Typ: model.ErrorInternal, - Err: fmt.Errorf("found zero or multiple PATs with same token"), - } - } - - patWithUser := types.GettablePAT{ - StorablePersonalAccessToken: pats[0], - } - - return &patWithUser, nil -} diff --git a/ee/query-service/model/pat.go b/ee/query-service/model/pat.go deleted file mode 100644 index 5eca7a8724..0000000000 --- a/ee/query-service/model/pat.go +++ /dev/null @@ -1,7 +0,0 @@ -package model - -type CreatePATRequestBody struct { - Name string `json:"name"` - Role string `json:"role"` - ExpiresInDays int64 `json:"expiresInDays"` -} diff --git a/ee/sqlstore/postgressqlstore/dialect.go b/ee/sqlstore/postgressqlstore/dialect.go index 28786a4cb3..7edd48dd67 100644 --- a/ee/sqlstore/postgressqlstore/dialect.go +++ b/ee/sqlstore/postgressqlstore/dialect.go @@ -19,6 +19,7 @@ var ( var ( Org = "org" User = "user" + UserNoCascade = "user_no_cascade" FactorPassword = "factor_password" CloudIntegration = "cloud_integration" ) @@ -26,6 +27,7 @@ var ( var ( OrgReference = `("org_id") REFERENCES "organizations" ("id")` UserReference = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE` + UserReferenceNoCascade = `("user_id") REFERENCES "users" ("id")` FactorPasswordReference = `("password_id") REFERENCES "factor_password" ("id")` CloudIntegrationReference = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE` ) @@ -266,6 +268,8 @@ func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.I fkReferences = append(fkReferences, OrgReference) } else if reference == User && !slices.Contains(fkReferences, UserReference) { fkReferences = append(fkReferences, UserReference) + } else if reference == UserNoCascade && !slices.Contains(fkReferences, UserReferenceNoCascade) { + fkReferences = append(fkReferences, UserReferenceNoCascade) } else if reference == FactorPassword && !slices.Contains(fkReferences, FactorPasswordReference) { fkReferences = append(fkReferences, FactorPasswordReference) } else if reference == CloudIntegration && !slices.Contains(fkReferences, CloudIntegrationReference) { diff --git a/ee/types/personal_access_token.go b/ee/types/personal_access_token.go deleted file mode 100644 index ff666740f9..0000000000 --- a/ee/types/personal_access_token.go +++ /dev/null @@ -1,76 +0,0 @@ -package types - -import ( - "crypto/rand" - "encoding/base64" - "time" - - "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/valuer" - "github.com/uptrace/bun" -) - -type GettablePAT struct { - CreatedByUser PatUser `json:"createdByUser"` - UpdatedByUser PatUser `json:"updatedByUser"` - - StorablePersonalAccessToken -} - -type PatUser struct { - types.User - NotFound bool `json:"notFound"` -} - -func NewGettablePAT(name, role, userID string, expiresAt int64) GettablePAT { - return GettablePAT{ - StorablePersonalAccessToken: NewStorablePersonalAccessToken(name, role, userID, expiresAt), - } -} - -type StorablePersonalAccessToken struct { - bun.BaseModel `bun:"table:personal_access_token"` - types.Identifiable - types.TimeAuditable - OrgID string `json:"orgId" bun:"org_id,type:text,notnull"` - Role string `json:"role" bun:"role,type:text,notnull,default:'ADMIN'"` - UserID string `json:"userId" bun:"user_id,type:text,notnull"` - Token string `json:"token" bun:"token,type:text,notnull,unique"` - Name string `json:"name" bun:"name,type:text,notnull"` - ExpiresAt int64 `json:"expiresAt" bun:"expires_at,notnull,default:0"` - LastUsed int64 `json:"lastUsed" bun:"last_used,notnull,default:0"` - Revoked bool `json:"revoked" bun:"revoked,notnull,default:false"` - UpdatedByUserID string `json:"updatedByUserId" bun:"updated_by_user_id,type:text,notnull,default:''"` -} - -func NewStorablePersonalAccessToken(name, role, userID string, expiresAt int64) StorablePersonalAccessToken { - now := time.Now() - if expiresAt != 0 { - // convert expiresAt to unix timestamp from days - expiresAt = now.Unix() + (expiresAt * 24 * 60 * 60) - } - - // Generate a 32-byte random token. - token := make([]byte, 32) - rand.Read(token) - // Encode the token in base64. - encodedToken := base64.StdEncoding.EncodeToString(token) - - return StorablePersonalAccessToken{ - Token: encodedToken, - Name: name, - Role: role, - UserID: userID, - ExpiresAt: expiresAt, - LastUsed: 0, - Revoked: false, - UpdatedByUserID: "", - TimeAuditable: types.TimeAuditable{ - CreatedAt: now, - UpdatedAt: now, - }, - Identifiable: types.Identifiable{ - ID: valuer.GenerateUUID(), - }, - } -} diff --git a/pkg/modules/user/impluser/handler.go b/pkg/modules/user/impluser/handler.go index eb1d506f7d..f695826d3a 100644 --- a/pkg/modules/user/impluser/handler.go +++ b/pkg/modules/user/impluser/handler.go @@ -470,3 +470,23 @@ func (h *handler) GetCurrentUserFromJWT(w http.ResponseWriter, r *http.Request) render.Success(w, http.StatusOK, user) } + +// CreateAPIKey implements user.Handler. +func (h *handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { + render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "not implemented")) +} + +// ListAPIKeys implements user.Handler. +func (h *handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) { + render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "not implemented")) +} + +// RevokeAPIKey implements user.Handler. +func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) { + render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "not implemented")) +} + +// UpdateAPIKey implements user.Handler. +func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) { + render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "not implemented")) +} diff --git a/pkg/modules/user/impluser/module.go b/pkg/modules/user/impluser/module.go index 5cbdb28d74..3635c01e03 100644 --- a/pkg/modules/user/impluser/module.go +++ b/pkg/modules/user/impluser/module.go @@ -392,3 +392,23 @@ func (m *Module) CanUsePassword(ctx context.Context, email string) (bool, error) func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) { return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported") } + +func (m *Module) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error { + return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") +} + +func (m *Module) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error { + return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") +} + +func (m *Module) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) { + return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") +} + +func (m *Module) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) { + return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") +} + +func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error { + return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") +} diff --git a/pkg/modules/user/impluser/store.go b/pkg/modules/user/impluser/store.go index 61a6dc8817..71a4103cb1 100644 --- a/pkg/modules/user/impluser/store.go +++ b/pkg/modules/user/impluser/store.go @@ -3,12 +3,14 @@ package impluser import ( "context" "database/sql" + "sort" "time" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" ) type Store struct { @@ -333,6 +335,15 @@ func (s *Store) DeleteUser(ctx context.Context, orgID string, id string) error { return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password") } + // delete api keys + _, err = tx.NewDelete(). + Model(&types.StorableAPIKey{}). + Where("user_id = ?", id). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete API keys") + } + // delete user _, err = tx.NewDelete(). Model(new(types.User)). @@ -474,3 +485,108 @@ func (s *Store) UpdatePassword(ctx context.Context, userID string, password stri func (s *Store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) { return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not supported") } + +// --- API KEY --- +func (s *Store) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error { + _, err := s.sqlstore.BunDB().NewInsert(). + Model(apiKey). + Exec(ctx) + if err != nil { + return s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrAPIKeyAlreadyExists, "API key with token: %s already exists", apiKey.Token) + } + + return nil +} + +func (s *Store) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error { + apiKey.UpdatedBy = updaterID.String() + apiKey.UpdatedAt = time.Now() + _, err := s.sqlstore.BunDB().NewUpdate(). + Model(apiKey). + Column("role", "name", "updated_at", "updated_by"). + Where("id = ?", id). + Where("revoked = false"). + Exec(ctx) + if err != nil { + return s.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) + } + return nil +} + +func (s *Store) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) { + orgUserAPIKeys := new(types.OrgUserAPIKey) + + if err := s.sqlstore.BunDB().NewSelect(). + Model(orgUserAPIKeys). + Relation("Users"). + Relation("Users.APIKeys", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("revoked = false") + }, + ). + Relation("Users.APIKeys.CreatedByUser"). + Relation("Users.APIKeys.UpdatedByUser"). + Where("id = ?", orgID). + Scan(ctx); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to fetch API keys") + } + + // Flatten the API keys from all users + var allAPIKeys []*types.StorableAPIKeyUser + for _, user := range orgUserAPIKeys.Users { + if user.APIKeys != nil { + allAPIKeys = append(allAPIKeys, user.APIKeys...) + } + } + + // sort the API keys by updated_at + sort.Slice(allAPIKeys, func(i, j int) bool { + return allAPIKeys[i].UpdatedAt.After(allAPIKeys[j].UpdatedAt) + }) + + return allAPIKeys, nil +} + +func (s *Store) RevokeAPIKey(ctx context.Context, id, revokedByUserID valuer.UUID) error { + updatedAt := time.Now().Unix() + _, err := s.sqlstore.BunDB().NewUpdate(). + Model(&types.StorableAPIKey{}). + Set("revoked = ?", true). + Set("updated_by = ?", revokedByUserID). + Set("updated_at = ?", updatedAt). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to revoke API key") + } + return nil +} + +func (s *Store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) { + apiKey := new(types.OrgUserAPIKey) + if err := s.sqlstore.BunDB().NewSelect(). + Model(apiKey). + Relation("Users"). + Relation("Users.APIKeys", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("revoked = false").Where("storable_api_key.id = ?", id). + OrderExpr("storable_api_key.updated_at DESC").Limit(1) + }, + ). + Relation("Users.APIKeys.CreatedByUser"). + Relation("Users.APIKeys.UpdatedByUser"). + Scan(ctx); err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) + } + + // flatten the API keys + flattenedAPIKeys := []*types.StorableAPIKeyUser{} + for _, user := range apiKey.Users { + if user.APIKeys != nil { + flattenedAPIKeys = append(flattenedAPIKeys, user.APIKeys...) + } + } + if len(flattenedAPIKeys) == 0 { + return nil, s.sqlstore.WrapNotFoundErrf(errors.New(errors.TypeNotFound, errors.CodeNotFound, "API key with id: %s does not exist"), types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) + } + + return flattenedAPIKeys[0], nil +} diff --git a/pkg/modules/user/user.go b/pkg/modules/user/user.go index aad4e95c24..79ea97090f 100644 --- a/pkg/modules/user/user.go +++ b/pkg/modules/user/user.go @@ -47,6 +47,13 @@ type Module interface { // Auth Domain GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) + + // API KEY + CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error + UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error + ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) + RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error + GetAPIKey(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.StorableAPIKeyUser, error) } type Handler interface { @@ -72,4 +79,10 @@ type Handler interface { GetResetPasswordToken(http.ResponseWriter, *http.Request) ResetPassword(http.ResponseWriter, *http.Request) ChangePassword(http.ResponseWriter, *http.Request) + + // API KEY + CreateAPIKey(http.ResponseWriter, *http.Request) + ListAPIKeys(http.ResponseWriter, *http.Request) + UpdateAPIKey(http.ResponseWriter, *http.Request) + RevokeAPIKey(http.ResponseWriter, *http.Request) } diff --git a/pkg/query-service/utils/testutils.go b/pkg/query-service/utils/testutils.go index fb52a2df5c..b7cf1fbcbc 100644 --- a/pkg/query-service/utils/testutils.go +++ b/pkg/query-service/utils/testutils.go @@ -65,6 +65,7 @@ func NewTestSqliteDB(t *testing.T) (sqlStore sqlstore.SQLStore, testDBFilePath s sqlmigration.NewCreateQuickFiltersFactory(sqlStore), sqlmigration.NewUpdateQuickFiltersFactory(sqlStore), sqlmigration.NewAuthRefactorFactory(sqlStore), + sqlmigration.NewMigratePATToFactorAPIKey(sqlStore), ), ) if err != nil { diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 644d8ff390..80032170e1 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -77,6 +77,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM sqlmigration.NewCreateQuickFiltersFactory(sqlstore), sqlmigration.NewUpdateQuickFiltersFactory(sqlstore), sqlmigration.NewAuthRefactorFactory(sqlstore), + sqlmigration.NewMigratePATToFactorAPIKey(sqlstore), ) } diff --git a/pkg/sqlmigration/033_api_keys.go b/pkg/sqlmigration/033_api_keys.go new file mode 100644 index 0000000000..b10fb04f96 --- /dev/null +++ b/pkg/sqlmigration/033_api_keys.go @@ -0,0 +1,162 @@ +package sqlmigration + +import ( + "context" + "database/sql" + "time" + + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types" + "github.com/uptrace/bun" + "github.com/uptrace/bun/migrate" +) + +type migratePATToFactorAPIKey struct { + store sqlstore.SQLStore +} + +func NewMigratePATToFactorAPIKey(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("migrate_pat_to_factor_api_key"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return newMigratePATToFactorAPIKey(ctx, ps, c, sqlstore) + }) +} + +func newMigratePATToFactorAPIKey(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) { + return &migratePATToFactorAPIKey{store: store}, nil +} + +func (migration *migratePATToFactorAPIKey) Register(migrations *migrate.Migrations) error { + if err := migrations.Register(migration.Up, migration.Down); err != nil { + return err + } + + return nil +} + +type existingPersonalAccessToken33 struct { + bun.BaseModel `bun:"table:personal_access_token"` + + types.Identifiable + types.TimeAuditable + OrgID string `json:"orgId" bun:"org_id,type:text,notnull"` + Role string `json:"role" bun:"role,type:text,notnull,default:'ADMIN'"` + UserID string `json:"userId" bun:"user_id,type:text,notnull"` + Token string `json:"token" bun:"token,type:text,notnull,unique"` + Name string `json:"name" bun:"name,type:text,notnull"` + ExpiresAt int64 `json:"expiresAt" bun:"expires_at,notnull,default:0"` + LastUsed int64 `json:"lastUsed" bun:"last_used,notnull,default:0"` + Revoked bool `json:"revoked" bun:"revoked,notnull,default:false"` + UpdatedByUserID string `json:"updatedByUserId" bun:"updated_by_user_id,type:text,notnull,default:''"` +} + +// we are removing the connection with org, +// the reason we are doing this is the api keys should just have +// one foreign key, we don't want a dangling state where, an API key +// belongs to one org and some user which doesn't belong to that org. +// so going ahead with directly attaching it to user will help dangling states. +type newFactorAPIKey33 struct { + bun.BaseModel `bun:"table:factor_api_key"` + + types.Identifiable + CreatedAt time.Time `bun:"created_at,notnull,nullzero,type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `bun:"updated_at,notnull,nullzero,type:timestamptz" json:"updatedAt"` + CreatedBy string `bun:"created_by,notnull" json:"createdBy"` + UpdatedBy string `bun:"updated_by,notnull" json:"updatedBy"` + Token string `json:"token" bun:"token,type:text,notnull,unique"` + Role string `json:"role" bun:"role,type:text,notnull"` + Name string `json:"name" bun:"name,type:text,notnull"` + ExpiresAt time.Time `json:"expiresAt" bun:"expires_at,notnull,nullzero,type:timestamptz"` + LastUsed time.Time `json:"lastUsed" bun:"last_used,notnull,nullzero,type:timestamptz"` + Revoked bool `json:"revoked" bun:"revoked,notnull,default:false"` + UserID string `json:"userId" bun:"user_id,type:text,notnull"` +} + +func (migration *migratePATToFactorAPIKey) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + + defer tx.Rollback() + + err = migration. + store. + Dialect(). + RenameTableAndModifyModel(ctx, tx, new(existingPersonalAccessToken33), new(newFactorAPIKey33), []string{UserReferenceNoCascade}, func(ctx context.Context) error { + existingAPIKeys := make([]*existingPersonalAccessToken33, 0) + err = tx. + NewSelect(). + Model(&existingAPIKeys). + Scan(ctx) + if err != nil && err != sql.ErrNoRows { + return err + } + + if err == nil && len(existingAPIKeys) > 0 { + newAPIKeys, err := migration. + CopyOldPatToFactorAPIKey(ctx, tx, existingAPIKeys) + if err != nil { + return err + } + _, err = tx. + NewInsert(). + Model(&newAPIKeys). + Exec(ctx) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (migration *migratePATToFactorAPIKey) Down(ctx context.Context, db *bun.DB) error { + return nil +} + +func (migration *migratePATToFactorAPIKey) CopyOldPatToFactorAPIKey(ctx context.Context, tx bun.IDB, existingAPIKeys []*existingPersonalAccessToken33) ([]*newFactorAPIKey33, error) { + newAPIKeys := make([]*newFactorAPIKey33, 0) + for _, apiKey := range existingAPIKeys { + + if apiKey.CreatedAt.IsZero() { + apiKey.CreatedAt = time.Now() + } + if apiKey.UpdatedAt.IsZero() { + apiKey.UpdatedAt = time.Now() + } + + // convert expiresAt and lastUsed to time.Time + expiresAt := time.Unix(apiKey.ExpiresAt, 0) + lastUsed := time.Unix(apiKey.LastUsed, 0) + if apiKey.LastUsed == 0 { + lastUsed = apiKey.CreatedAt + } + + newAPIKeys = append(newAPIKeys, &newFactorAPIKey33{ + Identifiable: apiKey.Identifiable, + CreatedAt: apiKey.CreatedAt, + UpdatedAt: apiKey.UpdatedAt, + CreatedBy: apiKey.UserID, + UpdatedBy: apiKey.UpdatedByUserID, + Token: apiKey.Token, + Role: apiKey.Role, + Name: apiKey.Name, + ExpiresAt: expiresAt, + LastUsed: lastUsed, + Revoked: apiKey.Revoked, + UserID: apiKey.UserID, + }) + } + return newAPIKeys, nil +} diff --git a/pkg/sqlmigration/sqlmigration.go b/pkg/sqlmigration/sqlmigration.go index f16a4c74c4..7a5b5eb70d 100644 --- a/pkg/sqlmigration/sqlmigration.go +++ b/pkg/sqlmigration/sqlmigration.go @@ -27,6 +27,7 @@ var ( var ( OrgReference = "org" UserReference = "user" + UserReferenceNoCascade = "user_no_cascade" FactorPasswordReference = "factor_password" CloudIntegrationReference = "cloud_integration" ) diff --git a/pkg/sqlstore/sqlitesqlstore/dialect.go b/pkg/sqlstore/sqlitesqlstore/dialect.go index e326e6b921..0e5ee364cf 100644 --- a/pkg/sqlstore/sqlitesqlstore/dialect.go +++ b/pkg/sqlstore/sqlitesqlstore/dialect.go @@ -20,6 +20,7 @@ const ( const ( Org string = "org" User string = "user" + UserNoCascade string = "user_no_cascade" FactorPassword string = "factor_password" CloudIntegration string = "cloud_integration" ) @@ -27,6 +28,7 @@ const ( const ( OrgReference string = `("org_id") REFERENCES "organizations" ("id")` UserReference string = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE` + UserNoCascadeReference string = `("user_id") REFERENCES "users" ("id")` FactorPasswordReference string = `("password_id") REFERENCES "factor_password" ("id")` CloudIntegrationReference string = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE` ) @@ -261,6 +263,8 @@ func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.I fkReferences = append(fkReferences, OrgReference) } else if reference == User && !slices.Contains(fkReferences, UserReference) { fkReferences = append(fkReferences, UserReference) + } else if reference == UserNoCascade && !slices.Contains(fkReferences, UserNoCascadeReference) { + fkReferences = append(fkReferences, UserNoCascadeReference) } else if reference == FactorPassword && !slices.Contains(fkReferences, FactorPasswordReference) { fkReferences = append(fkReferences, FactorPasswordReference) } else if reference == CloudIntegration && !slices.Contains(fkReferences, CloudIntegrationReference) { diff --git a/pkg/types/factor_api_key.go b/pkg/types/factor_api_key.go new file mode 100644 index 0000000000..01eb6165fc --- /dev/null +++ b/pkg/types/factor_api_key.go @@ -0,0 +1,135 @@ +package types + +import ( + "crypto/rand" + "encoding/base64" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" +) + +type PostableAPIKey struct { + Name string `json:"name"` + Role Role `json:"role"` + ExpiresInDays int64 `json:"expiresInDays"` +} + +type GettableAPIKey struct { + Identifiable + TimeAuditable + UserAuditable + Token string `json:"token"` + Role Role `json:"role"` + Name string `json:"name"` + ExpiresAt int64 `json:"expiresAt"` + LastUsed int64 `json:"lastUsed"` + Revoked bool `json:"revoked"` + UserID string `json:"userId"` + CreatedBy *User `json:"createdBy"` + UpdatedBy *User `json:"updatedBy"` +} + +type OrgUserAPIKey struct { + *Organization `bun:",extend"` + Users []*UserWithAPIKey `bun:"rel:has-many,join:id=org_id"` +} + +type UserWithAPIKey struct { + *User `bun:",extend"` + APIKeys []*StorableAPIKeyUser `bun:"rel:has-many,join:id=user_id"` +} + +type StorableAPIKeyUser struct { + StorableAPIKey `bun:",extend"` + + CreatedByUser *User `json:"createdByUser" bun:"created_by_user,rel:belongs-to,join:created_by=id"` + UpdatedByUser *User `json:"updatedByUser" bun:"updated_by_user,rel:belongs-to,join:updated_by=id"` +} + +type StorableAPIKey struct { + bun.BaseModel `bun:"table:factor_api_key"` + + Identifiable + TimeAuditable + UserAuditable + Token string `json:"token" bun:"token,type:text,notnull,unique"` + Role Role `json:"role" bun:"role,type:text,notnull,default:'ADMIN'"` + Name string `json:"name" bun:"name,type:text,notnull"` + ExpiresAt time.Time `json:"-" bun:"expires_at,notnull,nullzero,type:timestamptz"` + LastUsed time.Time `json:"-" bun:"last_used,notnull,nullzero,type:timestamptz"` + Revoked bool `json:"revoked" bun:"revoked,notnull,default:false"` + UserID valuer.UUID `json:"userId" bun:"user_id,type:text,notnull"` +} + +func NewStorableAPIKey(name string, userID valuer.UUID, role Role, expiresAt int64) (*StorableAPIKey, error) { + // validate + if expiresAt <= 0 { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "expiresAt must be greater than 0") + } + + if name == "" { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "name cannot be empty") + } + + if role == "" { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "role cannot be empty") + } + + now := time.Now() + // convert expiresAt to unix timestamp from days + // expiresAt = now.Unix() + (expiresAt * 24 * 60 * 60) + expiresAtTime := now.AddDate(0, 0, int(expiresAt)) + + // Generate a 32-byte random token. + token := make([]byte, 32) + _, err := rand.Read(token) + if err != nil { + return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to generate token") + } + // Encode the token in base64. + encodedToken := base64.StdEncoding.EncodeToString(token) + + return &StorableAPIKey{ + Identifiable: Identifiable{ + ID: valuer.GenerateUUID(), + }, + TimeAuditable: TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + UserAuditable: UserAuditable{ + CreatedBy: userID.String(), + UpdatedBy: userID.String(), + }, + Token: encodedToken, + Name: name, + Role: role, + UserID: userID, + ExpiresAt: expiresAtTime, + LastUsed: now, + Revoked: false, + }, nil +} + +func NewGettableAPIKeyFromStorableAPIKey(storableAPIKey *StorableAPIKeyUser) *GettableAPIKey { + lastUsed := storableAPIKey.LastUsed.Unix() + if storableAPIKey.LastUsed == storableAPIKey.CreatedAt { + lastUsed = 0 + } + return &GettableAPIKey{ + Identifiable: storableAPIKey.Identifiable, + TimeAuditable: storableAPIKey.TimeAuditable, + UserAuditable: storableAPIKey.UserAuditable, + Token: storableAPIKey.Token, + Role: storableAPIKey.Role, + Name: storableAPIKey.Name, + ExpiresAt: storableAPIKey.ExpiresAt.Unix(), + LastUsed: lastUsed, + Revoked: storableAPIKey.Revoked, + UserID: storableAPIKey.UserID.String(), + CreatedBy: storableAPIKey.CreatedByUser, + UpdatedBy: storableAPIKey.UpdatedByUser, + } +} diff --git a/pkg/types/user.go b/pkg/types/user.go index b5d297cb26..9b146cfb60 100644 --- a/pkg/types/user.go +++ b/pkg/types/user.go @@ -22,6 +22,8 @@ var ( ErrResetPasswordTokenAlreadyExists = errors.MustNewCode("reset_password_token_already_exists") ErrPasswordNotFound = errors.MustNewCode("password_not_found") ErrResetPasswordTokenNotFound = errors.MustNewCode("reset_password_token_not_found") + ErrAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists") + ErrAPIKeyNotFound = errors.MustNewCode("api_key_not_found") ) type UserStore interface { @@ -58,6 +60,13 @@ type UserStore interface { // Temporary func for SSO GetDefaultOrgID(ctx context.Context) (string, error) + + // API KEY + CreateAPIKey(ctx context.Context, apiKey *StorableAPIKey) error + UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *StorableAPIKey, updaterID valuer.UUID) error + ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*StorableAPIKeyUser, error) + RevokeAPIKey(ctx context.Context, id valuer.UUID, revokedByUserID valuer.UUID) error + GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*StorableAPIKeyUser, error) } type GettableUser struct { @@ -73,7 +82,7 @@ type User struct { DisplayName string `bun:"display_name,type:text,notnull" json:"displayName"` Email string `bun:"email,type:text,notnull,unique:org_email" json:"email"` Role string `bun:"role,type:text,notnull" json:"role"` - OrgID string `bun:"org_id,type:text,notnull,unique:org_email,references:org(id),on_delete:CASCADE" json:"orgId"` + OrgID string `bun:"org_id,type:text,notnull,unique:org_email" json:"orgId"` } func NewUser(displayName string, email string, role string, orgID string) (*User, error) { @@ -186,7 +195,7 @@ type ResetPasswordRequest struct { Identifiable Token string `bun:"token,type:text,notnull" json:"token"` - PasswordID string `bun:"password_id,type:text,notnull,unique,references:factor_password(id)" json:"passwordId"` + PasswordID string `bun:"password_id,type:text,notnull,unique" json:"passwordId"` } func NewResetPasswordRequest(passwordID string) (*ResetPasswordRequest, error) { diff --git a/tests/integration/src/bootstrap/d_apikey.py b/tests/integration/src/bootstrap/d_apikey.py index 54470494df..808108d842 100644 --- a/tests/integration/src/bootstrap/d_apikey.py +++ b/tests/integration/src/bootstrap/d_apikey.py @@ -18,7 +18,7 @@ def test_api_key(signoz: types.SigNoz, get_jwt_token) -> None: }, ) - assert response.status_code == HTTPStatus.OK + assert response.status_code == HTTPStatus.CREATED pat_response = response.json() assert "data" in pat_response assert "token" in pat_response["data"]