diff --git a/cmd/authz.go b/cmd/authz.go index c17137841c6..c7dcc95375e 100644 --- a/cmd/authz.go +++ b/cmd/authz.go @@ -96,6 +96,7 @@ func runGenerateAuthz(_ context.Context) error { coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true, coretypes.NewResourceRef(coretypes.ResourceRole).String(): true, coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true, + coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true, coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true, coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true, coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true, diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 973f40ae10d..88ffeab9444 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -5743,6 +5743,218 @@ components: - total - endTimeBeforeRetention type: object + LicensetypesFeature: + properties: + active: + type: boolean + name: + type: string + route: + type: string + usage: + format: int64 + type: integer + usage_limit: + format: int64 + type: integer + type: object + LicensetypesGettableActiveLicense: + properties: + createdAt: + format: date-time + type: string + eventQueue: + $ref: '#/components/schemas/LicensetypesLicenseEventQueue' + features: + items: + $ref: '#/components/schemas/LicensetypesFeature' + type: array + freeUntil: + format: date-time + type: string + id: + type: string + plan: + $ref: '#/components/schemas/LicensetypesLicensePlan' + platform: + type: string + state: + type: string + status: + type: string + updatedAt: + format: date-time + type: string + validFrom: + format: int64 + type: integer + validUntil: + format: int64 + type: integer + required: + - id + - validFrom + - validUntil + - status + - state + - platform + - freeUntil + - createdAt + - updatedAt + - plan + - features + - eventQueue + type: object + LicensetypesGettableLicense: + properties: + createdAt: + format: date-time + type: string + eventQueue: + $ref: '#/components/schemas/LicensetypesLicenseEventQueue' + features: + items: + $ref: '#/components/schemas/LicensetypesFeature' + type: array + freeUntil: + format: date-time + type: string + id: + type: string + plan: + $ref: '#/components/schemas/LicensetypesLicensePlan' + platform: + type: string + state: + type: string + status: + type: string + updatedAt: + format: date-time + type: string + validFrom: + format: int64 + type: integer + validUntil: + format: int64 + type: integer + required: + - id + - validFrom + - validUntil + - status + - state + - platform + - freeUntil + - createdAt + - updatedAt + - plan + - features + - eventQueue + type: object + LicensetypesGettableLicenseWithKey: + properties: + createdAt: + format: date-time + type: string + eventQueue: + $ref: '#/components/schemas/LicensetypesLicenseEventQueue' + features: + items: + $ref: '#/components/schemas/LicensetypesFeature' + type: array + freeUntil: + format: date-time + type: string + id: + type: string + key: + format: password + type: string + plan: + $ref: '#/components/schemas/LicensetypesLicensePlan' + platform: + type: string + state: + type: string + status: + type: string + updatedAt: + format: date-time + type: string + validFrom: + format: int64 + type: integer + validUntil: + format: int64 + type: integer + required: + - id + - validFrom + - validUntil + - status + - state + - platform + - freeUntil + - createdAt + - updatedAt + - plan + - features + - eventQueue + - key + type: object + LicensetypesLicenseEventQueue: + properties: + createdAt: + format: date-time + type: string + event: + type: string + scheduledAt: + format: date-time + type: string + status: + type: string + updatedAt: + format: date-time + type: string + required: + - event + - status + - scheduledAt + - createdAt + - updatedAt + type: object + LicensetypesLicensePlan: + properties: + createdAt: + format: date-time + type: string + description: + type: string + id: + type: string + isActive: + type: boolean + name: + type: string + updatedAt: + format: date-time + type: string + required: + - id + - name + - description + - isActive + - createdAt + - updatedAt + type: object + LicensetypesPostableLicense: + properties: + key: + format: password + type: string + type: object LlmpricingruletypesGettablePricingRules: properties: items: @@ -24372,6 +24584,110 @@ paths: summary: Put profile in Zeus for a deployment. tags: - zeus + /api/v3/licenses: + post: + deprecated: true + description: This endpoint validates the license key with the upstream server + and activates the license for the organization. + operationId: ActivateLicenseDeprecated + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LicensetypesPostableLicense' + responses: + "202": + description: Accepted + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - license:create + - tokenizer: + - license:create + summary: Activate a license. + tags: + - licenses + put: + deprecated: true + description: This endpoint refreshes the active license of the organization + from the upstream server. + operationId: RefreshLicenseDeprecated + responses: + "204": + description: No Content + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - license:update + - tokenizer: + - license:update + summary: Refresh a license. + tags: + - licenses /api/v3/metrics/dashboards: get: deprecated: false @@ -24510,6 +24826,359 @@ paths: summary: Get flamegraph view for a trace tags: - tracedetail + /api/v4/licenses: + get: + deprecated: false + description: This endpoint lists all the licenses of the organization. + operationId: ListLicenses + responses: + "200": + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/LicensetypesGettableLicense' + type: array + status: + type: string + required: + - status + - data + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - license:list + - tokenizer: + - license:list + summary: List licenses. + tags: + - licenses + post: + deprecated: false + description: This endpoint validates the license key with the upstream server + and activates the license for the organization. + operationId: ActivateLicense + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LicensetypesPostableLicense' + responses: + "201": + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/TypesIdentifiable' + status: + type: string + required: + - status + - data + type: object + description: Created + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - license:create + - tokenizer: + - license:create + summary: Activate a license. + tags: + - licenses + /api/v4/licenses/{id}: + delete: + deprecated: false + description: This endpoint deletes the license by id. Licenses managed by SigNoz + Cloud cannot be deleted. + operationId: DeleteLicense + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - license:delete + - tokenizer: + - license:delete + summary: Delete a license. + tags: + - licenses + get: + deprecated: false + description: This endpoint gets the license by id. + operationId: GetLicense + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/LicensetypesGettableLicenseWithKey' + status: + type: string + required: + - status + - data + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - license:read + - tokenizer: + - license:read + summary: Get a license. + tags: + - licenses + put: + deprecated: false + description: This endpoint refreshes the active license of the organization + from the upstream server. + operationId: RefreshLicense + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - license:update + - tokenizer: + - license:update + summary: Refresh a license. + tags: + - licenses + /api/v4/licenses/active: + get: + deprecated: false + description: This endpoint gets the active license of the organization. + operationId: GetActiveLicense + responses: + "200": + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/LicensetypesGettableActiveLicense' + status: + type: string + required: + - status + - data + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Implemented + security: + - api_key: [] + - tokenizer: [] + summary: Get the active license. + tags: + - licenses /api/v4/traces/{traceID}/waterfall: post: deprecated: false diff --git a/ee/licensing/httplicensing/api.go b/ee/licensing/httplicensing/api.go index 9f9bc1f5da9..0050eb5985a 100644 --- a/ee/licensing/httplicensing/api.go +++ b/ee/licensing/httplicensing/api.go @@ -22,89 +22,6 @@ func NewLicensingAPI(licensing licensing.Licensing) licensing.API { return &licensingAPI{licensing: licensing} } -func (api *licensingAPI) Activate(rw 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(rw, err) - return - } - - orgID, err := valuer.NewUUID(claims.OrgID) - if err != nil { - render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) - return - } - - req := new(licensetypes.PostableLicense) - err = json.NewDecoder(r.Body).Decode(&req) - if err != nil { - render.Error(rw, err) - return - } - - err = api.licensing.Activate(r.Context(), orgID, req.Key) - if err != nil { - render.Error(rw, err) - return - } - - render.Success(rw, http.StatusAccepted, nil) -} - -func (api *licensingAPI) GetActive(rw 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(rw, err) - return - } - - orgID, err := valuer.NewUUID(claims.OrgID) - if err != nil { - render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) - return - } - - license, err := api.licensing.GetActive(r.Context(), orgID) - if err != nil { - render.Error(rw, err) - return - } - - gettableLicense := licensetypes.NewGettableLicense(license.Data, license.Key) - render.Success(rw, http.StatusOK, gettableLicense) -} - -func (api *licensingAPI) Refresh(rw 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(rw, err) - return - } - - orgID, err := valuer.NewUUID(claims.OrgID) - if err != nil { - render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) - return - } - - err = api.licensing.Refresh(r.Context(), orgID) - if err != nil { - render.Error(rw, err) - return - } - - render.Success(rw, http.StatusNoContent, nil) -} - func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() diff --git a/ee/licensing/httplicensing/provider.go b/ee/licensing/httplicensing/provider.go index edbe3e12460..0e9d3ec0894 100644 --- a/ee/licensing/httplicensing/provider.go +++ b/ee/licensing/httplicensing/provider.go @@ -95,24 +95,65 @@ func (provider *provider) Validate(ctx context.Context) error { return nil } -func (provider *provider) Activate(ctx context.Context, organizationID valuer.UUID, key string) error { - data, err := provider.zeus.GetLicense(ctx, key) +func (provider *provider) Activate(ctx context.Context, organizationID valuer.UUID, key string) (*licensetypes.License, error) { + zeusLicense, err := provider.zeus.GetLicense(ctx, key) if err != nil { - return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch license data with upstream server") + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch license data with upstream server") } - license, err := licensetypes.NewLicense(data, organizationID) + license, err := licensetypes.NewLicense(zeusLicense, organizationID) if err != nil { - return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity") + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity") } storableLicense := licensetypes.NewStorableLicenseFromLicense(license) err = provider.store.Create(ctx, storableLicense) + if err != nil { + return nil, err + } + + return license, nil +} + +func (provider *provider) Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.License, error) { + storableLicense, err := provider.store.Get(ctx, organizationID, licenseID) + if err != nil { + return nil, err + } + + return licensetypes.NewLicenseFromStorableLicense(storableLicense) +} + +func (provider *provider) List(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.License, error) { + storableLicenses, err := provider.store.GetAll(ctx, organizationID) + if err != nil { + return nil, err + } + + licenses := make([]*licensetypes.License, 0, len(storableLicenses)) + for _, storableLicense := range storableLicenses { + license, err := licensetypes.NewLicenseFromStorableLicense(storableLicense) + if err != nil { + return nil, err + } + + licenses = append(licenses, license) + } + + return licenses, nil +} + +func (provider *provider) Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error { + license, err := provider.Get(ctx, organizationID, licenseID) if err != nil { return err } - return nil + if err := license.ErrIfCloud(); err != nil { + return errors.WithAdditionalf(err, "license %s cannot be deleted", licenseID.StringValue()) + } + + return provider.store.Delete(ctx, organizationID, licenseID) } func (provider *provider) GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) { @@ -139,7 +180,7 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI return err } - data, err := provider.zeus.GetLicense(ctx, activeLicense.Key) + zeusLicense, err := provider.zeus.GetLicense(ctx, activeLicense.Key) if err != nil { if time.Since(activeLicense.LastValidatedAt) > time.Duration(provider.config.FailureThreshold)*provider.config.PollInterval { activeLicense.UpdateFeatures(licensetypes.BasicPlan) @@ -154,7 +195,7 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI return err } - err = activeLicense.Update(data) + err = activeLicense.Update(zeusLicense) if err != nil { return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity from license data") } diff --git a/ee/licensing/licensingstore/sqllicensingstore/store.go b/ee/licensing/licensingstore/sqllicensingstore/store.go index dfbb257a934..41168782a33 100644 --- a/ee/licensing/licensingstore/sqllicensingstore/store.go +++ b/ee/licensing/licensingstore/sqllicensingstore/store.go @@ -64,6 +64,22 @@ func (store *store) GetAll(ctx context.Context, organizationID valuer.UUID) ([]* return storableLicenses, nil } +func (store *store) Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error { + _, err := store. + sqlstore. + BunDB(). + NewDelete(). + Model(new(licensetypes.StorableLicense)). + Where("org_id = ?", organizationID). + Where("id = ?", licenseID). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to delete license with ID: %s", licenseID) + } + + return nil +} + func (store *store) Update(ctx context.Context, organizationID valuer.UUID, storableLicense *licensetypes.StorableLicense) error { _, err := store. sqlstore. diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 87079173589..65d85431321 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -76,11 +76,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet) router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost) - // v3 - router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost) - router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut) - router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.LicensingAPI.GetActive)).Methods(http.MethodGet) - // v4 router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost) diff --git a/ee/zeus/httpzeus/provider.go b/ee/zeus/httpzeus/provider.go index 569c416d48f..89eaa24bbe4 100644 --- a/ee/zeus/httpzeus/provider.go +++ b/ee/zeus/httpzeus/provider.go @@ -51,7 +51,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config }, nil } -func (provider *Provider) GetLicense(ctx context.Context, key string) ([]byte, error) { +func (provider *Provider) GetLicense(ctx context.Context, key string) (*zeustypes.License, error) { response, err := provider.do( ctx, provider.config.URL.JoinPath("/v2/licenses/me"), @@ -63,7 +63,12 @@ func (provider *Provider) GetLicense(ctx context.Context, key string) ([]byte, e return nil, err } - return []byte(gjson.GetBytes(response, "data").String()), nil + license := new(zeustypes.License) + if err := json.Unmarshal([]byte(gjson.GetBytes(response, "data").String()), license); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "failed to unmarshal license data") + } + + return license, nil } func (provider *Provider) GetCheckoutURL(ctx context.Context, key string, body []byte) ([]byte, error) { diff --git a/frontend/src/AppRoutes/__tests__/Private.test.tsx b/frontend/src/AppRoutes/__tests__/Private.test.tsx index 8656794f05b..0c6c29d7274 100644 --- a/frontend/src/AppRoutes/__tests__/Private.test.tsx +++ b/frontend/src/AppRoutes/__tests__/Private.test.tsx @@ -103,30 +103,30 @@ function createMockLicense( overrides: Partial = {}, ): LicenseResModel { return { - key: 'test-key', - event_queue: { - created_at: '0', + id: 'test-license-id', + eventQueue: { + createdAt: '0', event: LicenseEvent.NO_EVENT, - scheduled_at: '0', + scheduledAt: '0', status: '', - updated_at: '0', + updatedAt: '0', }, state: LicenseState.ACTIVATED, status: LicenseStatus.VALID, platform: LicensePlatform.CLOUD, - created_at: '0', + createdAt: '0', plan: { - created_at: '0', + id: '0', + createdAt: '0', description: '', - is_active: true, + isActive: true, name: '', - updated_at: '0', + updatedAt: '0', }, - plan_id: '0', - free_until: '0', - updated_at: '0', - valid_from: 0, - valid_until: 0, + freeUntil: '0', + updatedAt: '0', + validFrom: 0, + validUntil: 0, ...overrides, }; } @@ -850,6 +850,22 @@ describe('PrivateRoute', () => { assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED); }); + it('should keep a custom role (ANONYMOUS) on workspace locked instead of bouncing to unauthorized', () => { + renderPrivateRoute({ + initialRoute: ROUTES.WORKSPACE_LOCKED, + appContext: { + isLoggedIn: true, + isFetchingActiveLicense: false, + activeLicense: createMockLicense({ platform: LicensePlatform.CLOUD }), + trialInfo: createMockTrialInfo({ workSpaceBlock: true }), + user: createMockUser({ role: USER_ROLES.ANONYMOUS as ROLES }), + }, + isCloudUser: true, + }); + + assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED); + }); + it('should not redirect self-hosted users to workspace locked even when workSpaceBlock is true', () => { renderPrivateRoute({ initialRoute: ROUTES.HOME, @@ -1024,6 +1040,24 @@ describe('PrivateRoute', () => { assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED); }); + it('should keep a custom role (ANONYMOUS) on workspace suspended instead of bouncing to unauthorized', () => { + renderPrivateRoute({ + initialRoute: ROUTES.WORKSPACE_SUSPENDED, + appContext: { + isLoggedIn: true, + isFetchingActiveLicense: false, + activeLicense: createMockLicense({ + platform: LicensePlatform.CLOUD, + state: LicenseState.DEFAULTED, + }), + user: createMockUser({ role: USER_ROLES.ANONYMOUS as ROLES }), + }, + isCloudUser: true, + }); + + assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED); + }); + it('should not redirect self-hosted users to workspace suspended when license is defaulted', () => { renderPrivateRoute({ initialRoute: ROUTES.HOME, @@ -1580,6 +1614,18 @@ describe('PrivateRoute', () => { path: ROUTES.SUPPORT, deniedRoles: [USER_ROLES.AUTHOR as ROLES], }, + WORKSPACE_LOCKED: { + path: ROUTES.WORKSPACE_LOCKED, + deniedRoles: DENIED_ROLES, + }, + WORKSPACE_SUSPENDED: { + path: ROUTES.WORKSPACE_SUSPENDED, + deniedRoles: DENIED_ROLES, + }, + WORKSPACE_ACCESS_RESTRICTED: { + path: ROUTES.WORKSPACE_ACCESS_RESTRICTED, + deniedRoles: DENIED_ROLES, + }, }; const authzRouteRolePairs: [string, string, ROLES][] = Object.entries( diff --git a/frontend/src/api/generated/services/licenses/index.ts b/frontend/src/api/generated/services/licenses/index.ts new file mode 100644 index 00000000000..2fce2efca67 --- /dev/null +++ b/frontend/src/api/generated/services/licenses/index.ts @@ -0,0 +1,702 @@ +/** + * ! Do not edit manually + * * The file has been auto-generated using Orval for SigNoz + * * regenerate with 'pnpm generate:api' + * SigNoz + */ +import { useMutation, useQuery } from 'react-query'; +import type { + InvalidateOptions, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from 'react-query'; + +import type { + ActivateLicense201, + DeleteLicensePathParameters, + GetActiveLicense200, + GetLicense200, + GetLicensePathParameters, + LicensetypesPostableLicenseDTO, + ListLicenses200, + RefreshLicensePathParameters, + RenderErrorResponseDTO, +} from '../sigNoz.schemas'; + +import { GeneratedAPIInstance } from '../../../generatedAPIInstance'; +import type { ErrorType, BodyType } from '../../../generatedAPIInstance'; + +/** + * This endpoint validates the license key with the upstream server and activates the license for the organization. + * @deprecated + * @summary Activate a license. + */ +export const activateLicenseDeprecated = ( + licensetypesPostableLicenseDTO?: BodyType, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v3/licenses`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: licensetypesPostableLicenseDTO, + signal, + }); +}; + +export const getActivateLicenseDeprecatedMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext +> => { + const mutationKey = ['activateLicenseDeprecated']; + const { mutation: mutationOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data?: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return activateLicenseDeprecated(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ActivateLicenseDeprecatedMutationResult = NonNullable< + Awaited> +>; +export type ActivateLicenseDeprecatedMutationBody = + | BodyType + | undefined; +export type ActivateLicenseDeprecatedMutationError = + ErrorType; + +/** + * @deprecated + * @summary Activate a license. + */ +export const useActivateLicenseDeprecated = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { data?: BodyType }, + TContext +> => { + return useMutation(getActivateLicenseDeprecatedMutationOptions(options)); +}; +/** + * This endpoint refreshes the active license of the organization from the upstream server. + * @deprecated + * @summary Refresh a license. + */ +export const refreshLicenseDeprecated = (signal?: AbortSignal) => { + return GeneratedAPIInstance({ + url: `/api/v3/licenses`, + method: 'PUT', + signal, + }); +}; + +export const getRefreshLicenseDeprecatedMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ['refreshLicenseDeprecated']; + const { mutation: mutationOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return refreshLicenseDeprecated(); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RefreshLicenseDeprecatedMutationResult = NonNullable< + Awaited> +>; + +export type RefreshLicenseDeprecatedMutationError = + ErrorType; + +/** + * @deprecated + * @summary Refresh a license. + */ +export const useRefreshLicenseDeprecated = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + return useMutation(getRefreshLicenseDeprecatedMutationOptions(options)); +}; +/** + * This endpoint lists all the licenses of the organization. + * @summary List licenses. + */ +export const listLicenses = (signal?: AbortSignal) => { + return GeneratedAPIInstance({ + url: `/api/v4/licenses`, + method: 'GET', + signal, + }); +}; + +export const getListLicensesQueryKey = () => { + return [`/api/v4/licenses`] as const; +}; + +export const getListLicensesQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; +}) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListLicensesQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listLicenses(signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListLicensesQueryResult = NonNullable< + Awaited> +>; +export type ListLicensesQueryError = ErrorType; + +/** + * @summary List licenses. + */ + +export function useListLicenses< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListLicensesQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary List licenses. + */ +export const invalidateListLicenses = async ( + queryClient: QueryClient, + options?: InvalidateOptions, +): Promise => { + await queryClient.invalidateQueries( + { queryKey: getListLicensesQueryKey() }, + options, + ); + + return queryClient; +}; + +/** + * This endpoint validates the license key with the upstream server and activates the license for the organization. + * @summary Activate a license. + */ +export const activateLicense = ( + licensetypesPostableLicenseDTO?: BodyType, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v4/licenses`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: licensetypesPostableLicenseDTO, + signal, + }); +}; + +export const getActivateLicenseMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext +> => { + const mutationKey = ['activateLicense']; + const { mutation: mutationOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data?: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return activateLicense(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ActivateLicenseMutationResult = NonNullable< + Awaited> +>; +export type ActivateLicenseMutationBody = + | BodyType + | undefined; +export type ActivateLicenseMutationError = ErrorType; + +/** + * @summary Activate a license. + */ +export const useActivateLicense = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data?: BodyType }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { data?: BodyType }, + TContext +> => { + return useMutation(getActivateLicenseMutationOptions(options)); +}; +/** + * This endpoint deletes the license by id. Licenses managed by SigNoz Cloud cannot be deleted. + * @summary Delete a license. + */ +export const deleteLicense = ( + { id }: DeleteLicensePathParameters, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v4/licenses/${id}`, + method: 'DELETE', + signal, + }); +}; + +export const getDeleteLicenseMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { pathParams: DeleteLicensePathParameters }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { pathParams: DeleteLicensePathParameters }, + TContext +> => { + const mutationKey = ['deleteLicense']; + const { mutation: mutationOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { pathParams: DeleteLicensePathParameters } + > = (props) => { + const { pathParams } = props ?? {}; + + return deleteLicense(pathParams); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteLicenseMutationResult = NonNullable< + Awaited> +>; + +export type DeleteLicenseMutationError = ErrorType; + +/** + * @summary Delete a license. + */ +export const useDeleteLicense = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { pathParams: DeleteLicensePathParameters }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { pathParams: DeleteLicensePathParameters }, + TContext +> => { + return useMutation(getDeleteLicenseMutationOptions(options)); +}; +/** + * This endpoint gets the license by id. + * @summary Get a license. + */ +export const getLicense = ( + { id }: GetLicensePathParameters, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v4/licenses/${id}`, + method: 'GET', + signal, + }); +}; + +export const getGetLicenseQueryKey = ({ id }: GetLicensePathParameters) => { + return [`/api/v4/licenses/${id}`] as const; +}; + +export const getGetLicenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + { id }: GetLicensePathParameters, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetLicenseQueryKey({ id }); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getLicense({ id }, signal); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: QueryKey; + }; +}; + +export type GetLicenseQueryResult = NonNullable< + Awaited> +>; +export type GetLicenseQueryError = ErrorType; + +/** + * @summary Get a license. + */ + +export function useGetLicense< + TData = Awaited>, + TError = ErrorType, +>( + { id }: GetLicensePathParameters, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetLicenseQueryOptions({ id }, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get a license. + */ +export const invalidateGetLicense = async ( + queryClient: QueryClient, + { id }: GetLicensePathParameters, + options?: InvalidateOptions, +): Promise => { + await queryClient.invalidateQueries( + { queryKey: getGetLicenseQueryKey({ id }) }, + options, + ); + + return queryClient; +}; + +/** + * This endpoint refreshes the active license of the organization from the upstream server. + * @summary Refresh a license. + */ +export const refreshLicense = ( + { id }: RefreshLicensePathParameters, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v4/licenses/${id}`, + method: 'PUT', + signal, + }); +}; + +export const getRefreshLicenseMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { pathParams: RefreshLicensePathParameters }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { pathParams: RefreshLicensePathParameters }, + TContext +> => { + const mutationKey = ['refreshLicense']; + const { mutation: mutationOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { pathParams: RefreshLicensePathParameters } + > = (props) => { + const { pathParams } = props ?? {}; + + return refreshLicense(pathParams); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RefreshLicenseMutationResult = NonNullable< + Awaited> +>; + +export type RefreshLicenseMutationError = ErrorType; + +/** + * @summary Refresh a license. + */ +export const useRefreshLicense = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { pathParams: RefreshLicensePathParameters }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { pathParams: RefreshLicensePathParameters }, + TContext +> => { + return useMutation(getRefreshLicenseMutationOptions(options)); +}; +/** + * This endpoint gets the active license of the organization. + * @summary Get the active license. + */ +export const getActiveLicense = (signal?: AbortSignal) => { + return GeneratedAPIInstance({ + url: `/api/v4/licenses/active`, + method: 'GET', + signal, + }); +}; + +export const getGetActiveLicenseQueryKey = () => { + return [`/api/v4/licenses/active`] as const; +}; + +export const getGetActiveLicenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; +}) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetActiveLicenseQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getActiveLicense(signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetActiveLicenseQueryResult = NonNullable< + Awaited> +>; +export type GetActiveLicenseQueryError = ErrorType; + +/** + * @summary Get the active license. + */ + +export function useGetActiveLicense< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetActiveLicenseQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get the active license. + */ +export const invalidateGetActiveLicense = async ( + queryClient: QueryClient, + options?: InvalidateOptions, +): Promise => { + await queryClient.invalidateQueries( + { queryKey: getGetActiveLicenseQueryKey() }, + options, + ); + + return queryClient; +}; diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 0822eed1142..b1d5ace6c71 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -7313,6 +7313,249 @@ export interface InframonitoringtypesVolumesDTO { warning?: Querybuildertypesv5QueryWarnDataDTO; } +export interface LicensetypesFeatureDTO { + /** + * @type boolean + */ + active?: boolean; + /** + * @type string + */ + name?: string; + /** + * @type string + */ + route?: string; + /** + * @type integer + * @format int64 + */ + usage?: number; + /** + * @type integer + * @format int64 + */ + usage_limit?: number; +} + +export interface LicensetypesLicenseEventQueueDTO { + /** + * @type string + * @format date-time + */ + createdAt: string; + /** + * @type string + */ + event: string; + /** + * @type string + * @format date-time + */ + scheduledAt: string; + /** + * @type string + */ + status: string; + /** + * @type string + * @format date-time + */ + updatedAt: string; +} + +export interface LicensetypesLicensePlanDTO { + /** + * @type string + * @format date-time + */ + createdAt: string; + /** + * @type string + */ + description: string; + /** + * @type string + */ + id: string; + /** + * @type boolean + */ + isActive: boolean; + /** + * @type string + */ + name: string; + /** + * @type string + * @format date-time + */ + updatedAt: string; +} + +export interface LicensetypesGettableActiveLicenseDTO { + /** + * @type string + * @format date-time + */ + createdAt: string; + eventQueue: LicensetypesLicenseEventQueueDTO; + /** + * @type array + */ + features: LicensetypesFeatureDTO[]; + /** + * @type string + * @format date-time + */ + freeUntil: string; + /** + * @type string + */ + id: string; + plan: LicensetypesLicensePlanDTO; + /** + * @type string + */ + platform: string; + /** + * @type string + */ + state: string; + /** + * @type string + */ + status: string; + /** + * @type string + * @format date-time + */ + updatedAt: string; + /** + * @type integer + * @format int64 + */ + validFrom: number; + /** + * @type integer + * @format int64 + */ + validUntil: number; +} + +export interface LicensetypesGettableLicenseDTO { + /** + * @type string + * @format date-time + */ + createdAt: string; + eventQueue: LicensetypesLicenseEventQueueDTO; + /** + * @type array + */ + features: LicensetypesFeatureDTO[]; + /** + * @type string + * @format date-time + */ + freeUntil: string; + /** + * @type string + */ + id: string; + plan: LicensetypesLicensePlanDTO; + /** + * @type string + */ + platform: string; + /** + * @type string + */ + state: string; + /** + * @type string + */ + status: string; + /** + * @type string + * @format date-time + */ + updatedAt: string; + /** + * @type integer + * @format int64 + */ + validFrom: number; + /** + * @type integer + * @format int64 + */ + validUntil: number; +} + +export interface LicensetypesGettableLicenseWithKeyDTO { + /** + * @type string + * @format date-time + */ + createdAt: string; + eventQueue: LicensetypesLicenseEventQueueDTO; + /** + * @type array + */ + features: LicensetypesFeatureDTO[]; + /** + * @type string + * @format date-time + */ + freeUntil: string; + /** + * @type string + */ + id: string; + /** + * @type string + * @format password + */ + key: string; + plan: LicensetypesLicensePlanDTO; + /** + * @type string + */ + platform: string; + /** + * @type string + */ + state: string; + /** + * @type string + */ + status: string; + /** + * @type string + * @format date-time + */ + updatedAt: string; + /** + * @type integer + * @format int64 + */ + validFrom: number; + /** + * @type integer + * @format int64 + */ + validUntil: number; +} + +export interface LicensetypesPostableLicenseDTO { + /** + * @type string + * @format password + */ + key?: string; +} + /** * @nullable */ @@ -12738,6 +12981,50 @@ export type GetFlamegraph200 = { status: string; }; +export type ListLicenses200 = { + /** + * @type array + */ + data: LicensetypesGettableLicenseDTO[]; + /** + * @type string + */ + status: string; +}; + +export type ActivateLicense201 = { + data: TypesIdentifiableDTO; + /** + * @type string + */ + status: string; +}; + +export type DeleteLicensePathParameters = { + id: string; +}; +export type GetLicensePathParameters = { + id: string; +}; +export type GetLicense200 = { + data: LicensetypesGettableLicenseWithKeyDTO; + /** + * @type string + */ + status: string; +}; + +export type RefreshLicensePathParameters = { + id: string; +}; +export type GetActiveLicense200 = { + data: LicensetypesGettableActiveLicenseDTO; + /** + * @type string + */ + status: string; +}; + export type GetWaterfallV4PathParameters = { traceID: string; }; diff --git a/frontend/src/api/v3/licenses/active/get.ts b/frontend/src/api/v3/licenses/active/get.ts deleted file mode 100644 index 7bf73e95cad..00000000000 --- a/frontend/src/api/v3/licenses/active/get.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ApiV3Instance as axios } from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { - LicenseEventQueueResModel, - PayloadProps, -} from 'types/api/licensesV3/getActive'; - -const getActive = async (): Promise< - SuccessResponseV2 -> => { - try { - const response = await axios.get('/licenses/active'); - - return { - httpStatusCode: response.status, - data: response.data.data, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default getActive; diff --git a/frontend/src/api/v3/licenses/post.ts b/frontend/src/api/v3/licenses/post.ts deleted file mode 100644 index 4cd971acc0e..00000000000 --- a/frontend/src/api/v3/licenses/post.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ApiV3Instance as axios } from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { PayloadProps, Props } from 'types/api/licenses/apply'; - -const apply = async ( - props: Props, -): Promise> => { - try { - const response = await axios.post('/licenses', { - key: props.key, - }); - - return { - httpStatusCode: response.status, - data: response.data, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default apply; diff --git a/frontend/src/api/v3/licenses/put.ts b/frontend/src/api/v3/licenses/put.ts deleted file mode 100644 index d07ad428de7..00000000000 --- a/frontend/src/api/v3/licenses/put.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ApiV3Instance as axios } from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { PayloadProps } from 'types/api/licenses/apply'; - -const apply = async (): Promise> => { - try { - const response = await axios.put('/licenses'); - - return { - httpStatusCode: response.status, - data: response.data, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default apply; diff --git a/frontend/src/components/RefreshPaymentStatus/RefreshPaymentStatus.tsx b/frontend/src/components/RefreshPaymentStatus/RefreshPaymentStatus.tsx index 0c5dccd5713..7f3e661f1aa 100644 --- a/frontend/src/components/RefreshPaymentStatus/RefreshPaymentStatus.tsx +++ b/frontend/src/components/RefreshPaymentStatus/RefreshPaymentStatus.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import refreshPaymentStatus from 'api/v3/licenses/put'; +import { refreshLicense } from 'api/generated/services/licenses'; import { Button } from '@signozhq/ui/button'; import { TooltipSimple } from '@signozhq/ui/tooltip'; import { RefreshCcw } from '@signozhq/icons'; @@ -14,17 +14,21 @@ function RefreshPaymentStatus({ className?: string; }): JSX.Element { const { t } = useTranslation(['failedPayment']); - const { activeLicenseRefetch } = useAppContext(); + const { activeLicense, activeLicenseRefetch } = useAppContext(); const [isLoading, setIsLoading] = useState(false); const handleRefreshPaymentStatus = async (): Promise => { + if (!activeLicense) { + return; + } + setIsLoading(true); try { - await refreshPaymentStatus(); + await refreshLicense({ id: activeLicense.id }); - await Promise.all([activeLicenseRefetch()]); + activeLicenseRefetch(); } catch (e) { console.error(e); } diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index d23a0de085e..8c63baed5b0 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -28,7 +28,6 @@ export const REACT_QUERY_KEY = { DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE', GET_HOST_LIST: 'GET_HOST_LIST', UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE', - GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3', GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL', GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL', GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS', diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 3b293e88633..9bd67d1cb9d 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -453,7 +453,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { if ( !isFetchingActiveLicense && !isNull(activeLicense) && - activeLicense?.event_queue?.event === LicenseEvent.DEFAULT + activeLicense?.eventQueue?.event === LicenseEvent.DEFAULT ) { setShowPaymentFailedWarning(true); } @@ -820,7 +820,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element { Your bill payment has failed. Your workspace will get suspended on{' '} {getFormattedDateWithMinutes( - dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(), + activeLicense?.eventQueue?.scheduledAt + ? dayjs(activeLicense.eventQueue.scheduledAt).unix() + : dayjs().unix(), )} . diff --git a/frontend/src/container/BillingContainer/BillingContainer.test.tsx b/frontend/src/container/BillingContainer/BillingContainer.test.tsx index 817c088639c..65dd9aec0ef 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.test.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.test.tsx @@ -15,6 +15,11 @@ import { getFormattedDate } from 'utils/timeUtils'; import BillingContainer from './BillingContainer'; +jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey', () => ({ + __esModule: true, + default: jest.fn(() => ({ licenseKey: 'test-key', isLoading: false })), +})); + window.ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx index d6c8fc3cf8c..33b0fa19b82 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -30,6 +30,7 @@ import useAxiosError from 'hooks/useAxiosError'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import { useNotifications } from 'hooks/useNotifications'; import { isEmpty, pick } from 'lodash-es'; +import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey'; import { useAppContext } from 'providers/App/App'; import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api'; import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; @@ -145,6 +146,7 @@ export default function BillingContainer(): JSX.Element { activeLicense, activeLicenseFetchError, } = useAppContext(); + const { licenseKey } = useActiveLicenseKey(); const { notifications } = useNotifications(); const handleError = useAxiosError(); @@ -207,9 +209,9 @@ export default function BillingContainer(): JSX.Element { isFetching: isFetchingBillingData, data: billingData, } = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], { - queryFn: () => getUsage(activeLicense?.key || ''), + queryFn: () => getUsage(licenseKey || ''), onError: handleError, - enabled: activeLicense !== null, + enabled: !!licenseKey, onSuccess: processUsageData, }); diff --git a/frontend/src/container/GeneralSettings/GeneralSettings.tsx b/frontend/src/container/GeneralSettings/GeneralSettings.tsx index 6b4509c08c0..a6fac2fdd16 100644 --- a/frontend/src/container/GeneralSettings/GeneralSettings.tsx +++ b/frontend/src/container/GeneralSettings/GeneralSettings.tsx @@ -16,6 +16,8 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import { useNotifications } from 'hooks/useNotifications'; import { StatusCodes } from 'http-status-codes'; import find from 'lodash-es/find'; +import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent'; +import { buildLicenseReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions'; import { useAppContext } from 'providers/App/App'; import { ErrorResponse, @@ -673,17 +675,21 @@ function GeneralSettings({ - {(showCustomDomainSettings || activeLicense?.key) && ( + {(showCustomDomainSettings || activeLicense) && (
{showCustomDomainSettings && } - {showCustomDomainSettings && activeLicense?.key && ( + {showCustomDomainSettings && activeLicense && (
)} - {activeLicense?.key && ( - <> - - - + {activeLicense && ( + + <> + + + + )}
)} diff --git a/frontend/src/container/GeneralSettings/LicenseKeyRow/LicenseKeyRow.tsx b/frontend/src/container/GeneralSettings/LicenseKeyRow/LicenseKeyRow.tsx index 99266a858c3..0d20b3a2a37 100644 --- a/frontend/src/container/GeneralSettings/LicenseKeyRow/LicenseKeyRow.tsx +++ b/frontend/src/container/GeneralSettings/LicenseKeyRow/LicenseKeyRow.tsx @@ -2,16 +2,16 @@ import { useCopyToClipboard } from 'react-use'; import { Copy, KeyRound } from '@signozhq/icons'; import { Button } from '@signozhq/ui/button'; import { toast } from '@signozhq/ui/sonner'; -import { useAppContext } from 'providers/App/App'; +import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey'; import { getMaskedKey } from 'utils/maskedKey'; import './LicenseKeyRow.styles.scss'; function LicenseKeyRow(): JSX.Element | null { - const { activeLicense } = useAppContext(); + const { licenseKey } = useActiveLicenseKey(); const [, copyToClipboard] = useCopyToClipboard(); - if (!activeLicense?.key) { + if (!licenseKey) { return null; } @@ -27,16 +27,14 @@ function LicenseKeyRow(): JSX.Element | null { SigNoz License Key - - {getMaskedKey(activeLicense.key)} - + {getMaskedKey(licenseKey)} diff --git a/frontend/src/container/GeneralSettings/LicenseKeyRow/__tests__/LicenseKeyRow.test.tsx b/frontend/src/container/GeneralSettings/LicenseKeyRow/__tests__/LicenseKeyRow.test.tsx index 97ddb9f256f..c4059b394c7 100644 --- a/frontend/src/container/GeneralSettings/LicenseKeyRow/__tests__/LicenseKeyRow.test.tsx +++ b/frontend/src/container/GeneralSettings/LicenseKeyRow/__tests__/LicenseKeyRow.test.tsx @@ -1,7 +1,13 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey'; import LicenseKeyRow from '../LicenseKeyRow'; +jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey'); +const mockUseActiveLicenseKey = useActiveLicenseKey as jest.MockedFunction< + typeof useActiveLicenseKey +>; + const mockCopyToClipboard = jest.fn(); jest.mock('react-use', () => ({ @@ -23,20 +29,22 @@ describe('LicenseKeyRow', () => { jest.clearAllMocks(); }); - it('renders nothing when activeLicense key is absent', () => { - const { container } = render(, undefined, { - appContextOverrides: { activeLicense: null }, + it('renders nothing when the license key is absent', () => { + mockUseActiveLicenseKey.mockReturnValue({ + licenseKey: undefined, + isLoading: false, }); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); - it('renders label and masked key when activeLicense key exists', () => { - render(, undefined, { - appContextOverrides: { - activeLicense: { key: 'abcdefghij' } as any, - }, + it('renders label and masked key when the license key exists', () => { + mockUseActiveLicenseKey.mockReturnValue({ + licenseKey: 'abcdefghij', + isLoading: false, }); + render(); expect(screen.getByText('SigNoz License Key')).toBeInTheDocument(); expect(screen.getByText('ab·······ij')).toBeInTheDocument(); @@ -45,6 +53,10 @@ describe('LicenseKeyRow', () => { it('calls copyToClipboard and shows success toast when clipboard is available', async () => { const user = userEvent.setup({ pointerEventsCheck: 0 }); + mockUseActiveLicenseKey.mockReturnValue({ + licenseKey: 'test-key', + isLoading: false, + }); render(); await user.click(screen.getByRole('button', { name: /copy license key/i })); diff --git a/frontend/src/container/Licenses/ApplyLicenseForm.tsx b/frontend/src/container/Licenses/ApplyLicenseForm.tsx index f5fd4fa514e..983e4f7c365 100644 --- a/frontend/src/container/Licenses/ApplyLicenseForm.tsx +++ b/frontend/src/container/Licenses/ApplyLicenseForm.tsx @@ -2,9 +2,9 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Input } from '@signozhq/ui/input'; import { Button, Form } from 'antd'; -import apply from 'api/v3/licenses/post'; +import { activateLicense } from 'api/generated/services/licenses'; import { useNotifications } from 'hooks/useNotifications'; -import APIError from 'types/api/error'; +import { toAPIError } from 'utils/errorUtils'; import { requireErrorMessage } from 'utils/form/requireErrorMessage'; import { @@ -26,7 +26,7 @@ function ApplyLicenseForm({ const isDisabled = isLoading || !key; - const onFinish = async (values: unknown | { key: string }): Promise => { + const onFinish = async (values: unknown): Promise => { const params = values as { key: string }; if (params.key === '' || !params.key) { notifications.error({ @@ -38,18 +38,19 @@ function ApplyLicenseForm({ setIsLoading(true); try { - await apply({ + await activateLicense({ key: params.key, }); - await Promise.all([licenseRefetch()]); + licenseRefetch(); notifications.success({ message: 'Success', description: t('license_applied'), }); } catch (e) { + const apiError = toAPIError(e as Parameters[0]); notifications.error({ - message: (e as APIError).getErrorCode(), - description: (e as APIError).getErrorMessage(), + message: apiError.getErrorCode(), + description: apiError.getErrorMessage(), }); } setIsLoading(false); diff --git a/frontend/src/container/MySettings/LicenseSection/LicenseSection.tsx b/frontend/src/container/MySettings/LicenseSection/LicenseSection.tsx index 51ec73e59cd..38780e8ddf8 100644 --- a/frontend/src/container/MySettings/LicenseSection/LicenseSection.tsx +++ b/frontend/src/container/MySettings/LicenseSection/LicenseSection.tsx @@ -3,13 +3,16 @@ import { Button } from '@signozhq/ui/button'; import { Typography } from '@signozhq/ui/typography'; import { useNotifications } from 'hooks/useNotifications'; import { Copy } from '@signozhq/icons'; +import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey'; +import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent'; +import { buildLicenseReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions'; import { useAppContext } from 'providers/App/App'; import { getMaskedKey } from 'utils/maskedKey'; import './LicenseSection.styles.scss'; -function LicenseSection(): JSX.Element | null { - const { activeLicense } = useAppContext(); +function LicenseSectionContent(): JSX.Element | null { + const { licenseKey } = useActiveLicenseKey(); const { notifications } = useNotifications(); const [, handleCopyToClipboard] = useCopyToClipboard(); @@ -20,7 +23,41 @@ function LicenseSection(): JSX.Element | null { }); }; - if (!activeLicense?.key) { + if (!licenseKey) { + return null; + } + + return ( +
+
+
+ License key + + {getMaskedKey(licenseKey)} + + +
+ +
+ Your SigNoz license key. +
+
+
+ ); +} + +function LicenseSection(): JSX.Element | null { + const { activeLicense } = useAppContext(); + + if (!activeLicense) { return <>; } @@ -30,29 +67,9 @@ function LicenseSection(): JSX.Element | null {
License
-
-
-
- License key - - {getMaskedKey(activeLicense.key)} - - -
- -
- Your SigNoz license key. -
-
-
+ + + ); } diff --git a/frontend/src/container/MySettings/__tests__/MySettings.test.tsx b/frontend/src/container/MySettings/__tests__/MySettings.test.tsx index 0fd0c24f1a4..3c822de5866 100644 --- a/frontend/src/container/MySettings/__tests__/MySettings.test.tsx +++ b/frontend/src/container/MySettings/__tests__/MySettings.test.tsx @@ -1,5 +1,11 @@ import userEvent from '@testing-library/user-event'; import MySettingsContainer from 'container/MySettings'; +import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey'; +import { + setupAuthzAdmin, + setupAuthzDenyAll, +} from 'lib/authz/utils/authz-test-utils'; +import { server } from 'mocks-server/server'; import { logEventMock } from '__tests__/logEventMock'; import { act, @@ -12,6 +18,11 @@ import { import APIError from 'types/api/error'; import { toast } from '@signozhq/ui/sonner'; +jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey'); +const mockUseActiveLicenseKey = useActiveLicenseKey as jest.MockedFunction< + typeof useActiveLicenseKey +>; + const toggleThemeFunction = jest.fn(); const copyToClipboardFn = jest.fn(); const editUserFn = jest.fn(); @@ -87,6 +98,10 @@ describe('MySettings Flows', () => { jest.clearAllMocks(); editUserFn.mockResolvedValue({}); updateMyPasswordFn.mockResolvedValue({}); + mockUseActiveLicenseKey.mockReturnValue({ + licenseKey: 'test-key', + isLoading: false, + }); render(); }); @@ -361,17 +376,27 @@ describe('MySettings Flows', () => { }); describe('License section', () => { - it('Should render license section content when license key exists', () => { + beforeEach(() => { + server.use(setupAuthzAdmin()); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('Should render license section content when license key exists', async () => { expect(screen.getByText('License')).toBeInTheDocument(); - expect(screen.getByText('License key')).toBeInTheDocument(); + await expect(screen.findByText('License key')).resolves.toBeInTheDocument(); expect(screen.getByText('Your SigNoz license key.')).toBeInTheDocument(); }); - it('Should not render license section when license key is missing', () => { + it('Should not render license section when there is no active license', () => { + mockUseActiveLicenseKey.mockReturnValue({ + licenseKey: undefined, + isLoading: false, + }); const { container } = render(, undefined, { - appContextOverrides: { - activeLicense: null, - }, + appContextOverrides: { activeLicense: null }, }); const scoped = within(container); @@ -382,41 +407,53 @@ describe('MySettings Flows', () => { ).not.toBeInTheDocument(); }); - it('Should mask license key in the UI', () => { - const { container } = render(, undefined, { - appContextOverrides: { - activeLicense: { - key: 'abcd', - } as any, - }, + it('Should show permission denied instead of the license key when read is denied', async () => { + server.use(setupAuthzDenyAll()); + const { container } = render(); + + const scoped = within(container); + await expect( + scoped.findByText(/not authorized/i), + ).resolves.toBeInTheDocument(); + expect(scoped.getByText('License')).toBeInTheDocument(); + expect(scoped.queryByText('License key')).not.toBeInTheDocument(); + }); + + it('Should mask license key in the UI', async () => { + mockUseActiveLicenseKey.mockReturnValue({ + licenseKey: 'abcd', + isLoading: false, }); + const { container } = render(); - expect(within(container).getByText('ab·······cd')).toBeInTheDocument(); + await expect( + within(container).findByText('ab·······cd'), + ).resolves.toBeInTheDocument(); }); - it('Should not mask license key if it is too short', () => { - const { container } = render(, undefined, { - appContextOverrides: { - activeLicense: { - key: 'abc', - } as any, - }, + it('Should not mask license key if it is too short', async () => { + mockUseActiveLicenseKey.mockReturnValue({ + licenseKey: 'abc', + isLoading: false, }); + const { container } = render(); - expect(within(container).getByText('abc')).toBeInTheDocument(); + await expect( + within(container).findByText('abc'), + ).resolves.toBeInTheDocument(); }); it('Should copy license key and show success toast', async () => { const user = userEvent.setup(); - const { container } = render(, undefined, { - appContextOverrides: { - activeLicense: { - key: 'test-license-key-12345', - } as any, - }, + mockUseActiveLicenseKey.mockReturnValue({ + licenseKey: 'test-license-key-12345', + isLoading: false, }); + const { container } = render(); - await user.click(within(container).getByTestId('license-key-copy-btn')); + await user.click( + await within(container).findByTestId('license-key-copy-btn'), + ); await waitFor(() => { expect(copyToClipboardFn).toHaveBeenCalledWith('test-license-key-12345'); diff --git a/frontend/src/container/RolesSettings/hooks/__tests__/useRolePermissions.test.ts b/frontend/src/container/RolesSettings/hooks/__tests__/useRolePermissions.test.ts index df30eaa6f90..0eef99dba30 100644 --- a/frontend/src/container/RolesSettings/hooks/__tests__/useRolePermissions.test.ts +++ b/frontend/src/container/RolesSettings/hooks/__tests__/useRolePermissions.test.ts @@ -329,11 +329,12 @@ describe('transformTransactionGroupsToResourcePermissions', () => { it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => { const result = transformTransactionGroupsToResourcePermissions([]); - expect(result).toHaveLength(7); + expect(result).toHaveLength(8); expect(result.map((r) => r.resourceKind)).toStrictEqual([ 'factor-api-key', 'role', 'serviceaccount', + 'license', 'logs', 'traces', 'metrics', @@ -418,11 +419,12 @@ describe('createEmptyRolePermissions', () => { it('creates permissions for all resources in RESOURCE_ORDER', () => { const result = createEmptyRolePermissions(); - expect(result).toHaveLength(7); + expect(result).toHaveLength(8); expect(result.map((r) => r.resourceKind)).toStrictEqual([ 'factor-api-key', 'role', 'serviceaccount', + 'license', 'logs', 'traces', 'metrics', diff --git a/frontend/src/container/RolesSettings/permissions.config.ts b/frontend/src/container/RolesSettings/permissions.config.ts index b1ce4b858bb..942507b2009 100644 --- a/frontend/src/container/RolesSettings/permissions.config.ts +++ b/frontend/src/container/RolesSettings/permissions.config.ts @@ -2,6 +2,7 @@ import { Bot, ChartLine, DraftingCompass, + FileKey, Gauge, Key, Logs, @@ -61,6 +62,13 @@ export const RESOURCE_PANELS: Record = { 'Type service account ID, separate multiple with comma or space', docsAnchor: 'service-account', }, + license: { + label: 'Licenses', + description: 'Licenses of the workspace, including the license key.', + icon: FileKey, + selectorPlaceholder: 'Type license ID, separate multiple with comma or space', + docsAnchor: 'license', + }, logs: { label: 'Logs', description: 'Log data collected across the workspace.', diff --git a/frontend/src/hooks/useActiveLicense/useActiveLicense.tsx b/frontend/src/hooks/useActiveLicense/useActiveLicense.tsx new file mode 100644 index 00000000000..d64c035bf4d --- /dev/null +++ b/frontend/src/hooks/useActiveLicense/useActiveLicense.tsx @@ -0,0 +1,29 @@ +import { useQuery, UseQueryResult } from 'react-query'; +import { + getActiveLicense, + getGetActiveLicenseQueryKey, +} from 'api/generated/services/licenses'; +import APIError from 'types/api/error'; +import { LicenseResModel } from 'types/api/licensesV3/getActive'; +import { toAPIError } from 'utils/errorUtils'; + +import { toLicenseResModel } from './utils'; + +const useActiveLicense = ( + isLoggedIn: boolean, +): UseQueryResult => + useQuery({ + queryFn: async (): Promise => { + try { + const response = await getActiveLicense(); + return toLicenseResModel(response.data); + } catch (error) { + throw toAPIError(error as Parameters[0]); + } + }, + queryKey: getGetActiveLicenseQueryKey(), + enabled: !!isLoggedIn, + retry: false, + }); + +export default useActiveLicense; diff --git a/frontend/src/hooks/useActiveLicense/utils.ts b/frontend/src/hooks/useActiveLicense/utils.ts new file mode 100644 index 00000000000..401ecb35d68 --- /dev/null +++ b/frontend/src/hooks/useActiveLicense/utils.ts @@ -0,0 +1,37 @@ +import { LicensetypesGettableActiveLicenseDTO } from 'api/generated/services/sigNoz.schemas'; +import { + LicenseEvent, + LicensePlatform, + LicenseResModel, + LicenseState, + LicenseStatus, +} from 'types/api/licensesV3/getActive'; + +export const toLicenseResModel = ( + dto: LicensetypesGettableActiveLicenseDTO, +): LicenseResModel => ({ + id: dto.id, + status: dto.status as LicenseStatus, + state: dto.state as LicenseState, + platform: dto.platform as LicensePlatform, + plan: { + id: dto.plan.id, + name: dto.plan.name, + description: dto.plan.description, + isActive: dto.plan.isActive, + createdAt: dto.plan.createdAt, + updatedAt: dto.plan.updatedAt, + }, + eventQueue: { + event: dto.eventQueue.event as LicenseEvent, + status: dto.eventQueue.status, + scheduledAt: dto.eventQueue.scheduledAt, + createdAt: dto.eventQueue.createdAt, + updatedAt: dto.eventQueue.updatedAt, + }, + freeUntil: dto.freeUntil, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + validFrom: dto.validFrom, + validUntil: dto.validUntil, +}); diff --git a/frontend/src/hooks/useActiveLicenseKey/useActiveLicenseKey.tsx b/frontend/src/hooks/useActiveLicenseKey/useActiveLicenseKey.tsx new file mode 100644 index 00000000000..710a78d2165 --- /dev/null +++ b/frontend/src/hooks/useActiveLicenseKey/useActiveLicenseKey.tsx @@ -0,0 +1,35 @@ +import { useMemo } from 'react'; +import { useGetLicense } from 'api/generated/services/licenses'; +import { buildLicenseReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions'; +import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ'; +import { useAppContext } from 'providers/App/App'; + +interface UseActiveLicenseKey { + licenseKey: string | undefined; + isLoading: boolean; +} + +const useActiveLicenseKey = (): UseActiveLicenseKey => { + const { activeLicense } = useAppContext(); + + const permissions = useMemo( + () => (activeLicense ? [buildLicenseReadPermission(activeLicense.id)] : []), + [activeLicense], + ); + const { allowed, isLoading: isAuthZLoading } = useAuthZ(permissions, { + enabled: !!activeLicense, + }); + + const { data, isLoading: isLicenseLoading } = useGetLicense( + { id: activeLicense?.id ?? '' }, + { query: { enabled: !!activeLicense && allowed } }, + ); + + return { + licenseKey: data?.data.key, + isLoading: + !!activeLicense && (isAuthZLoading || (allowed && isLicenseLoading)), + }; +}; + +export default useActiveLicenseKey; diff --git a/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx b/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx deleted file mode 100644 index def94b7eedd..00000000000 --- a/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useQuery, UseQueryResult } from 'react-query'; -import getActive from 'api/v3/licenses/active/get'; -import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -import { SuccessResponseV2 } from 'types/api'; -import APIError from 'types/api/error'; -import { LicenseResModel } from 'types/api/licensesV3/getActive'; - -const useActiveLicenseV3 = (isLoggedIn: boolean): UseLicense => - useQuery({ - queryFn: getActive, - queryKey: [REACT_QUERY_KEY.GET_ACTIVE_LICENSE_V3], - enabled: !!isLoggedIn, - retry: false, - }); - -type UseLicense = UseQueryResult, APIError>; - -export default useActiveLicenseV3; diff --git a/frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts b/frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts index a6562d4711b..f49d0ce390c 100644 --- a/frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts +++ b/frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts @@ -8,6 +8,11 @@ export default { type: 'metaresource', allowedVerbs: ['create', 'delete', 'list', 'read', 'update'], }, + { + kind: 'license', + type: 'metaresource', + allowedVerbs: ['create', 'delete', 'list', 'read', 'update'], + }, { kind: 'role', type: 'role', diff --git a/frontend/src/lib/authz/hooks/useAuthZ/permissions/license.permissions.ts b/frontend/src/lib/authz/hooks/useAuthZ/permissions/license.permissions.ts new file mode 100644 index 00000000000..1ecfd41b25e --- /dev/null +++ b/frontend/src/lib/authz/hooks/useAuthZ/permissions/license.permissions.ts @@ -0,0 +1,6 @@ +import { buildPermission } from '../utils'; +import type { BrandedPermission } from '../types'; + +// Resource-level — require a specific license id +export const buildLicenseReadPermission = (id: string): BrandedPermission => + buildPermission('read', `license:${id}`); diff --git a/frontend/src/lib/authz/utils/authz-test-utils.ts b/frontend/src/lib/authz/utils/authz-test-utils.ts index 99fb7caa04d..e6d741b1650 100644 --- a/frontend/src/lib/authz/utils/authz-test-utils.ts +++ b/frontend/src/lib/authz/utils/authz-test-utils.ts @@ -127,30 +127,30 @@ export function buildLicense( overrides?: Partial, ): LicenseResModel { return { - key: 'test-key', + id: 'test-license-id', status: LicenseStatus.VALID, state: LicenseState.ACTIVATED, platform: LicensePlatform.CLOUD, - event_queue: { - created_at: '0', + eventQueue: { + createdAt: '0', event: LicenseEvent.NO_EVENT, - scheduled_at: '0', + scheduledAt: '0', status: '', - updated_at: '0', + updatedAt: '0', }, plan: { - created_at: '0', + id: '0', + createdAt: '0', description: '', - is_active: true, + isActive: true, name: '', - updated_at: '0', + updatedAt: '0', }, - plan_id: '0', - free_until: '0', - updated_at: '0', - valid_from: 0, - valid_until: 0, - created_at: '0', + freeUntil: '0', + updatedAt: '0', + validFrom: 0, + validUntil: 0, + createdAt: '0', ...overrides, }; } diff --git a/frontend/src/providers/App/App.tsx b/frontend/src/providers/App/App.tsx index ded917d343d..28ba2c043cd 100644 --- a/frontend/src/providers/App/App.tsx +++ b/frontend/src/providers/App/App.tsx @@ -22,7 +22,7 @@ import listUserPreferences from 'api/v1/user/preferences/list'; import getUserVersion from 'api/v1/version/get'; import { LOCALSTORAGE } from 'constants/localStorage'; import dayjs from 'dayjs'; -import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3'; +import useActiveLicense from 'hooks/useActiveLicense/useActiveLicense'; import { IsAdminPermission, IsEditorPermission, @@ -210,35 +210,34 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element { } }, [userData, isFetchingUserData]); - // fetcher for licenses v3 + // fetcher for the active license const { data: activeLicenseData, isFetching: isFetchingActiveLicense, error: activeLicenseFetchError, refetch: activeLicenseRefetch, - } = useActiveLicenseV3(isLoggedIn); + } = useActiveLicense(isLoggedIn); useEffect(() => { - if (!isFetchingActiveLicense && activeLicenseData && activeLicenseData.data) { - setActiveLicense(activeLicenseData.data); + if (!isFetchingActiveLicense && activeLicenseData) { + setActiveLicense(activeLicenseData); - const isOnTrial = dayjs( - activeLicenseData.data.free_until || Date.now(), - ).isAfter(dayjs()); + const freeUntilUnix = dayjs(activeLicenseData.freeUntil).unix(); + const scheduledAtUnix = dayjs( + activeLicenseData.eventQueue.scheduledAt, + ).unix(); const trialInfo: TrialInfo = { - trialStart: activeLicenseData.data.valid_from, - trialEnd: dayjs(activeLicenseData.data.free_until || Date.now()).unix(), - onTrial: isOnTrial, + trialStart: activeLicenseData.validFrom, + trialEnd: freeUntilUnix > 0 ? freeUntilUnix : dayjs().unix(), + onTrial: dayjs(activeLicenseData.freeUntil).isAfter(dayjs()), workSpaceBlock: - activeLicenseData.data.state === LicenseState.EVALUATION_EXPIRED && - activeLicenseData.data.platform === LicensePlatform.CLOUD, + activeLicenseData.state === LicenseState.EVALUATION_EXPIRED && + activeLicenseData.platform === LicensePlatform.CLOUD, trialConvertedToSubscription: - activeLicenseData.data.state !== LicenseState.ISSUED && - activeLicenseData.data.state !== LicenseState.EVALUATING && - activeLicenseData.data.state !== LicenseState.EVALUATION_EXPIRED, - gracePeriodEnd: dayjs( - activeLicenseData.data.event_queue.scheduled_at || Date.now(), - ).unix(), + activeLicenseData.state !== LicenseState.ISSUED && + activeLicenseData.state !== LicenseState.EVALUATING && + activeLicenseData.state !== LicenseState.EVALUATION_EXPIRED, + gracePeriodEnd: scheduledAtUnix > 0 ? scheduledAtUnix : dayjs().unix(), }; setTrialInfo(trialInfo); diff --git a/frontend/src/tests/test-utils.tsx b/frontend/src/tests/test-utils.tsx index 3898565edf8..8fac8590e62 100644 --- a/frontend/src/tests/test-utils.tsx +++ b/frontend/src/tests/test-utils.tsx @@ -158,30 +158,30 @@ export function getAppContextMock( ): IAppContext { return { activeLicense: { - key: 'test-key', - event_queue: { - created_at: '0', + id: 'test-license-id', + eventQueue: { + createdAt: '0', event: LicenseEvent.NO_EVENT, - scheduled_at: '0', + scheduledAt: '0', status: '', - updated_at: '0', + updatedAt: '0', }, state: LicenseState.ACTIVATED, status: LicenseStatus.VALID, platform: LicensePlatform.CLOUD, - created_at: '0', + createdAt: '0', plan: { - created_at: '0', + id: '0', + createdAt: '0', description: '', - is_active: true, + isActive: true, name: '', - updated_at: '0', + updatedAt: '0', }, - plan_id: '0', - free_until: '0', - updated_at: '0', - valid_from: 0, - valid_until: 0, + freeUntil: '0', + updatedAt: '0', + validFrom: 0, + validUntil: 0, }, trialInfo: { trialStart: -1, diff --git a/frontend/src/types/api/licenses/apply.ts b/frontend/src/types/api/licenses/apply.ts deleted file mode 100644 index 5a08fa6f03e..00000000000 --- a/frontend/src/types/api/licenses/apply.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { License } from './def'; - -export interface Props { - key: string; -} - -export interface PayloadProps { - status: string; - data: License; -} diff --git a/frontend/src/types/api/licenses/def.ts b/frontend/src/types/api/licenses/def.ts deleted file mode 100644 index 3242077788c..00000000000 --- a/frontend/src/types/api/licenses/def.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface License { - key: string; - ValidFrom: Date; - ValidUntil: Date; - planKey: string; - status: string; - isCurrent: boolean; -} diff --git a/frontend/src/types/api/licensesV3/getActive.ts b/frontend/src/types/api/licensesV3/getActive.ts index cc12feb15b8..99c87088662 100644 --- a/frontend/src/types/api/licensesV3/getActive.ts +++ b/frontend/src/types/api/licensesV3/getActive.ts @@ -1,57 +1,59 @@ export enum LicenseEvent { NO_EVENT = '', - DEFAULT = 'DEFAULT', + DEFAULT = 'default', } export enum LicenseStatus { - SUSPENDED = 'SUSPENDED', - VALID = 'VALID', - INVALID = 'INVALID', + SUSPENDED = 'suspended', + VALID = 'valid', + INVALID = 'invalid', } export enum LicenseState { - DEFAULTED = 'DEFAULTED', - ACTIVATED = 'ACTIVATED', - EXPIRED = 'EXPIRED', - ISSUED = 'ISSUED', - EVALUATING = 'EVALUATING', - EVALUATION_EXPIRED = 'EVALUATION_EXPIRED', - TERMINATED = 'TERMINATED', - CANCELLED = 'CANCELLED', + DEFAULTED = 'defaulted', + ACTIVATED = 'activated', + EXPIRED = 'expired', + ISSUED = 'issued', + EVALUATING = 'evaluating', + EVALUATION_EXPIRED = 'evaluation_expired', + TERMINATED = 'terminated', + CANCELLED = 'cancelled', } export enum LicensePlatform { - SELF_HOSTED = 'SELF_HOSTED', - CLOUD = 'CLOUD', + SELF_HOSTED = 'self_hosted', + CLOUD = 'cloud', } +export type LicensePlanResModel = { + id: string; + name: string; + description: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +}; + export type LicenseEventQueueResModel = { event: LicenseEvent; status: string; - scheduled_at: string; - created_at: string; - updated_at: string; + scheduledAt: string; + createdAt: string; + updatedAt: string; }; export type LicenseResModel = { - key: string; + id: string; status: LicenseStatus; state: LicenseState; - event_queue: LicenseEventQueueResModel; platform: LicensePlatform; - created_at: string; - plan: { - created_at: string; - description: string; - is_active: boolean; - name: string; - updated_at: string; - }; - plan_id: string; - free_until: string; - updated_at: string; - valid_from: number; - valid_until: number; + plan: LicensePlanResModel; + eventQueue: LicenseEventQueueResModel; + freeUntil: string; + createdAt: string; + updatedAt: string; + validFrom: number; + validUntil: number; }; // Duplicate of old licenses API response, need to improve this later @@ -63,8 +65,3 @@ export type TrialInfo = { trialConvertedToSubscription: boolean; gracePeriodEnd: number; }; - -export interface PayloadProps { - data: LicenseEventQueueResModel; - status: string; -} diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index f3751901f03..bab004986b3 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -184,4 +184,7 @@ export const routeWithInitialAuthZSupport = { METRICS_EXPLORER_VOLUME_CONTROL: true, METER_EXPLORER: true, METER: true, + WORKSPACE_LOCKED: true, + WORKSPACE_SUSPENDED: true, + WORKSPACE_ACCESS_RESTRICTED: true, } as const satisfies Partial>; diff --git a/pkg/apiserver/signozapiserver/licensing.go b/pkg/apiserver/signozapiserver/licensing.go new file mode 100644 index 00000000000..a1ed8316674 --- /dev/null +++ b/pkg/apiserver/signozapiserver/licensing.go @@ -0,0 +1,219 @@ +package signozapiserver + +import ( + "net/http" + + "github.com/SigNoz/signoz/pkg/http/handler" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/coretypes" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/gorilla/mux" +) + +func (provider *provider) addLicensingRoutes(router *mux.Router) error { + if err := router.Handle("/api/v4/licenses", handler.New( + provider.authzMiddleware.CheckResources(provider.licensingHandler.Create, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "ActivateLicense", + Tags: []string{"licenses"}, + Summary: "Activate a license.", + Description: "This endpoint validates the license key with the upstream server and activates the license for the organization.", + Request: new(licensetypes.PostableLicense), + RequestContentType: "application/json", + Response: new(types.Identifiable), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusCreated, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbCreate)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceLicense, + Verb: coretypes.VerbCreate, + Category: coretypes.ActionCategoryConfigurationChange, + ID: coretypes.ResponseJSONPath("data.id"), + Selector: coretypes.WildcardSelector, + }), + )).Methods(http.MethodPost).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v3/licenses", handler.New( + provider.authzMiddleware.CheckResources(provider.licensingHandler.ActivateDeprecated, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "ActivateLicenseDeprecated", + Tags: []string{"licenses"}, + Summary: "Activate a license.", + Description: "This endpoint validates the license key with the upstream server and activates the license for the organization.", + Request: new(licensetypes.PostableLicense), + RequestContentType: "application/json", + Response: nil, + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusAccepted, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict}, + Deprecated: true, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbCreate)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceLicense, + Verb: coretypes.VerbCreate, + Category: coretypes.ActionCategoryConfigurationChange, + Selector: coretypes.WildcardSelector, + }), + )).Methods(http.MethodPost).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v3/licenses", handler.New( + provider.authzMiddleware.CheckResources(provider.licensingHandler.RefreshDeprecated, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "RefreshLicenseDeprecated", + Tags: []string{"licenses"}, + Summary: "Refresh a license.", + Description: "This endpoint refreshes the active license of the organization from the upstream server.", + Request: nil, + RequestContentType: "", + Response: nil, + ResponseContentType: "", + SuccessStatusCode: http.StatusNoContent, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, + Deprecated: true, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbUpdate)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceLicense, + Verb: coretypes.VerbUpdate, + Category: coretypes.ActionCategoryConfigurationChange, + Selector: coretypes.WildcardSelector, + }), + )).Methods(http.MethodPut).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v4/licenses", handler.New( + provider.authzMiddleware.CheckResources(provider.licensingHandler.List, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "ListLicenses", + Tags: []string{"licenses"}, + Summary: "List licenses.", + Description: "This endpoint lists all the licenses of the organization.", + Request: nil, + RequestContentType: "", + Response: make([]*licensetypes.GettableLicense, 0), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{http.StatusBadRequest}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbList)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceLicense, + Verb: coretypes.VerbList, + Category: coretypes.ActionCategoryDataAccess, + Selector: coretypes.WildcardSelector, + }), + )).Methods(http.MethodGet).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v4/licenses/active", handler.New(provider.authzMiddleware.OpenAccess(provider.licensingHandler.GetActive), handler.OpenAPIDef{ + ID: "GetActiveLicense", + Tags: []string{"licenses"}, + Summary: "Get the active license.", + Description: "This endpoint gets the active license of the organization.", + Request: nil, + RequestContentType: "", + Response: new(licensetypes.GettableActiveLicense), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotImplemented}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes(nil), + })).Methods(http.MethodGet).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v4/licenses/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.licensingHandler.Get, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "GetLicense", + Tags: []string{"licenses"}, + Summary: "Get a license.", + Description: "This endpoint gets the license by id.", + Request: nil, + RequestContentType: "", + Response: new(licensetypes.GettableLicenseWithKey), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbRead)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceLicense, + Verb: coretypes.VerbRead, + Category: coretypes.ActionCategoryDataAccess, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }), + )).Methods(http.MethodGet).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v4/licenses/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.licensingHandler.Refresh, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "RefreshLicense", + Tags: []string{"licenses"}, + Summary: "Refresh a license.", + Description: "This endpoint refreshes the active license of the organization from the upstream server.", + Request: nil, + RequestContentType: "", + Response: nil, + ResponseContentType: "", + SuccessStatusCode: http.StatusNoContent, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbUpdate)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceLicense, + Verb: coretypes.VerbUpdate, + Category: coretypes.ActionCategoryConfigurationChange, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }), + )).Methods(http.MethodPut).GetError(); err != nil { + return err + } + + if err := router.Handle("/api/v4/licenses/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.licensingHandler.Delete, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "DeleteLicense", + Tags: []string{"licenses"}, + Summary: "Delete a license.", + Description: "This endpoint deletes the license by id. Licenses managed by SigNoz Cloud cannot be deleted.", + Request: nil, + RequestContentType: "", + Response: nil, + ResponseContentType: "", + SuccessStatusCode: http.StatusNoContent, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbDelete)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceLicense, + Verb: coretypes.VerbDelete, + Category: coretypes.ActionCategoryConfigurationChange, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }), + )).Methods(http.MethodDelete).GetError(); err != nil { + return err + } + + return nil +} diff --git a/pkg/apiserver/signozapiserver/provider.go b/pkg/apiserver/signozapiserver/provider.go index 9ffde5159bd..1645283ed1b 100644 --- a/pkg/apiserver/signozapiserver/provider.go +++ b/pkg/apiserver/signozapiserver/provider.go @@ -12,6 +12,7 @@ import ( "github.com/SigNoz/signoz/pkg/global" "github.com/SigNoz/signoz/pkg/http/handler" "github.com/SigNoz/signoz/pkg/http/middleware" + "github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/modules/aiobservability" "github.com/SigNoz/signoz/pkg/modules/authdomain" "github.com/SigNoz/signoz/pkg/modules/cloudintegration" @@ -68,6 +69,7 @@ type provider struct { authzHandler authz.Handler rawDataExportHandler rawdataexport.Handler zeusHandler zeus.Handler + licensingHandler licensing.Handler querierHandler querier.Handler serviceAccountHandler serviceaccount.Handler serviceAccountGetter serviceaccount.Getter @@ -107,6 +109,7 @@ func NewFactory( authzHandler authz.Handler, rawDataExportHandler rawdataexport.Handler, zeusHandler zeus.Handler, + licensingHandler licensing.Handler, querierHandler querier.Handler, serviceAccountHandler serviceaccount.Handler, serviceAccountGetter serviceaccount.Getter, @@ -149,6 +152,7 @@ func NewFactory( authzHandler, rawDataExportHandler, zeusHandler, + licensingHandler, querierHandler, serviceAccountHandler, serviceAccountGetter, @@ -193,6 +197,7 @@ func newProvider( authzHandler authz.Handler, rawDataExportHandler rawdataexport.Handler, zeusHandler zeus.Handler, + licensingHandler licensing.Handler, querierHandler querier.Handler, serviceAccountHandler serviceaccount.Handler, serviceAccountGetter serviceaccount.Getter, @@ -236,6 +241,7 @@ func newProvider( authzHandler: authzHandler, rawDataExportHandler: rawDataExportHandler, zeusHandler: zeusHandler, + licensingHandler: licensingHandler, querierHandler: querierHandler, serviceAccountHandler: serviceAccountHandler, serviceAccountGetter: serviceAccountGetter, @@ -338,6 +344,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error { return err } + if err := provider.addLicensingRoutes(router); err != nil { + return err + } + if err := provider.addZeusRoutes(router); err != nil { return err } diff --git a/pkg/licensing/handler.go b/pkg/licensing/handler.go new file mode 100644 index 00000000000..eea754f5216 --- /dev/null +++ b/pkg/licensing/handler.go @@ -0,0 +1,210 @@ +package licensing + +import ( + "net/http" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/binding" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/gorilla/mux" +) + +type handler struct { + licensing Licensing +} + +func NewHandler(licensing Licensing) Handler { + return &handler{licensing: licensing} +} + +func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + req := new(licensetypes.PostableLicense) + if err := binding.JSON.BindBody(r.Body, req); err != nil { + render.Error(rw, err) + return + } + + license, err := handler.licensing.Activate(ctx, valuer.MustNewUUID(claims.OrgID), req.Key) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusCreated, types.Identifiable{ID: license.ID}) +} + +func (handler *handler) ActivateDeprecated(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + req := new(licensetypes.PostableLicense) + if err := binding.JSON.BindBody(r.Body, req); err != nil { + render.Error(rw, err) + return + } + + if _, err := handler.licensing.Activate(ctx, valuer.MustNewUUID(claims.OrgID), req.Key); err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusAccepted, nil) +} + +func (handler *handler) RefreshDeprecated(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + if err := handler.licensing.Refresh(ctx, valuer.MustNewUUID(claims.OrgID)); err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusNoContent, nil) +} + +func (handler *handler) List(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + licenses, err := handler.licensing.List(ctx, valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, err) + return + } + + gettableLicenses := make([]*licensetypes.GettableLicense, 0, len(licenses)) + for _, license := range licenses { + gettableLicenses = append(gettableLicenses, licensetypes.NewGettableLicense(license)) + } + + render.Success(rw, http.StatusOK, gettableLicenses) +} + +func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + licenseID, err := valuer.NewUUID(mux.Vars(r)["id"]) + if err != nil { + render.Error(rw, err) + return + } + + license, err := handler.licensing.Get(ctx, valuer.MustNewUUID(claims.OrgID), licenseID) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, licensetypes.NewGettableLicenseWithKey(license)) +} + +func (handler *handler) Refresh(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + licenseID, err := valuer.NewUUID(mux.Vars(r)["id"]) + if err != nil { + render.Error(rw, err) + return + } + + orgID := valuer.MustNewUUID(claims.OrgID) + + activeLicense, err := handler.licensing.GetActive(ctx, orgID) + if err != nil { + render.Error(rw, err) + return + } + + if activeLicense.ID != licenseID { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only the active license %s can be refreshed", activeLicense.ID.StringValue())) + return + } + + if err := handler.licensing.Refresh(ctx, orgID); err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusNoContent, nil) +} + +func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + licenseID, err := valuer.NewUUID(mux.Vars(r)["id"]) + if err != nil { + render.Error(rw, err) + return + } + + if err := handler.licensing.Delete(ctx, valuer.MustNewUUID(claims.OrgID), licenseID); err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusNoContent, nil) +} + +func (handler *handler) GetActive(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + license, err := handler.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, licensetypes.NewGettableActiveLicense(license)) +} diff --git a/pkg/licensing/licensing.go b/pkg/licensing/licensing.go index 3d77d7e156d..b1834f01564 100644 --- a/pkg/licensing/licensing.go +++ b/pkg/licensing/licensing.go @@ -21,10 +21,16 @@ type Licensing interface { // Validate validates the license with the upstream server Validate(ctx context.Context) error - // Activate validates and enables the license - Activate(ctx context.Context, organizationID valuer.UUID, key string) error + // Activate validates the key with the upstream server and enables the license + Activate(ctx context.Context, organizationID valuer.UUID, key string) (*licensetypes.License, error) // GetActive fetches the current active license in org GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) + // Get fetches the license by id in org + Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.License, error) + // List fetches all the licenses in org + List(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.License, error) + // Delete deletes the license by id in org, cloud licenses cannot be deleted + Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error // Refresh refreshes the license state from upstream server Refresh(ctx context.Context, organizationID valuer.UUID) error // Checkout creates a checkout session via upstream server and returns the redirection link @@ -38,10 +44,24 @@ type Licensing interface { } type API interface { - Activate(http.ResponseWriter, *http.Request) - Refresh(http.ResponseWriter, *http.Request) - GetActive(http.ResponseWriter, *http.Request) - Checkout(http.ResponseWriter, *http.Request) Portal(http.ResponseWriter, *http.Request) } + +type Handler interface { + Create(http.ResponseWriter, *http.Request) + + ActivateDeprecated(http.ResponseWriter, *http.Request) + + RefreshDeprecated(http.ResponseWriter, *http.Request) + + List(http.ResponseWriter, *http.Request) + + Get(http.ResponseWriter, *http.Request) + + Refresh(http.ResponseWriter, *http.Request) + + Delete(http.ResponseWriter, *http.Request) + + GetActive(http.ResponseWriter, *http.Request) +} diff --git a/pkg/licensing/nooplicensing/api.go b/pkg/licensing/nooplicensing/api.go index e484376fd56..c49a073b645 100644 --- a/pkg/licensing/nooplicensing/api.go +++ b/pkg/licensing/nooplicensing/api.go @@ -14,18 +14,6 @@ func NewLicenseAPI() licensing.API { return &noopLicensingAPI{} } -func (api *noopLicensingAPI) Activate(rw http.ResponseWriter, r *http.Request) { - render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) -} - -func (api *noopLicensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) { - render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) -} - -func (api *noopLicensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) { - render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) -} - func (api *noopLicensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) { render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) } diff --git a/pkg/licensing/nooplicensing/provider.go b/pkg/licensing/nooplicensing/provider.go index be40b968858..76627255149 100644 --- a/pkg/licensing/nooplicensing/provider.go +++ b/pkg/licensing/nooplicensing/provider.go @@ -35,8 +35,20 @@ func (provider *noopLicensing) Stop(context.Context) error { return nil } -func (provider *noopLicensing) Activate(ctx context.Context, organizationID valuer.UUID, key string) error { - return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "fetching license is not supported") +func (provider *noopLicensing) Activate(ctx context.Context, organizationID valuer.UUID, key string) (*licensetypes.License, error) { + return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "fetching license is not supported") +} + +func (provider *noopLicensing) Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.License, error) { + return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "fetching license is not supported") +} + +func (provider *noopLicensing) List(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.License, error) { + return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "listing licenses is not supported") +} + +func (provider *noopLicensing) Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error { + return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "deleting license is not supported") } func (provider *noopLicensing) Validate(ctx context.Context) error { diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index e0a963d51e2..01e0e7a2869 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -458,13 +458,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost) - router.HandleFunc("/api/v3/licenses", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) { - render.Success(rw, http.StatusOK, []any{}) - })).Methods(http.MethodGet) - router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) { - aH.LicensingAPI.Activate(rw, req) - })).Methods(http.MethodGet) - router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost) // Query Filter Analyzer api used to extract metric names and grouping columns from a query diff --git a/pkg/signoz/handler.go b/pkg/signoz/handler.go index 80b471ae20e..cc96a897385 100644 --- a/pkg/signoz/handler.go +++ b/pkg/signoz/handler.go @@ -78,6 +78,7 @@ type Handlers struct { AIObservability aiobservability.Handler AuthzHandler authz.Handler ZeusHandler zeus.Handler + LicensingHandler licensing.Handler QuerierHandler querier.Handler ServiceAccountHandler serviceaccount.Handler RegistryHandler factory.Handler @@ -97,7 +98,7 @@ func NewHandlers( providerSettings factory.ProviderSettings, analytics analytics.Analytics, querierHandler querier.Handler, - licensing licensing.Licensing, + licensingService licensing.Licensing, global global.Global, flaggerService flagger.Flagger, gatewayService gateway.Gateway, @@ -128,7 +129,8 @@ func NewHandlers( Fields: implfields.NewHandler(providerSettings, telemetryMetadataStore), AIObservability: implaiobservability.NewHandler(telemetryMetadataStore), AuthzHandler: signozauthzapi.NewHandler(authz), - ZeusHandler: zeus.NewHandler(zeusService, licensing), + ZeusHandler: zeus.NewHandler(zeusService, licensingService), + LicensingHandler: licensing.NewHandler(licensingService), QuerierHandler: querierHandler, ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount, modules.ServiceAccountGetter), RegistryHandler: registryHandler, diff --git a/pkg/signoz/openapi.go b/pkg/signoz/openapi.go index 5e6b3d8fe86..acad6d2bc54 100644 --- a/pkg/signoz/openapi.go +++ b/pkg/signoz/openapi.go @@ -17,6 +17,7 @@ import ( "github.com/SigNoz/signoz/pkg/global" "github.com/SigNoz/signoz/pkg/http/handler" "github.com/SigNoz/signoz/pkg/instrumentation" + "github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/modules/aiobservability" "github.com/SigNoz/signoz/pkg/modules/authdomain" "github.com/SigNoz/signoz/pkg/modules/cloudintegration" @@ -81,6 +82,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta struct{ authz.Handler }{}, struct{ rawdataexport.Handler }{}, struct{ zeus.Handler }{}, + struct{ licensing.Handler }{}, struct{ querier.Handler }{}, struct{ serviceaccount.Handler }{}, struct{ serviceaccount.Getter }{}, diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 878d1debb40..30734e888e6 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -246,6 +246,7 @@ func NewSQLMigrationProviderFactories( sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore), sqlmigration.NewAddDeploymentHostTuplesFactory(sqlstore), sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema), + sqlmigration.NewAddLicenseTuplesFactory(sqlstore), ) } @@ -336,6 +337,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au handlers.AuthzHandler, handlers.RawDataExport, handlers.ZeusHandler, + handlers.LicensingHandler, handlers.QuerierHandler, handlers.ServiceAccountHandler, modules.ServiceAccountGetter, diff --git a/pkg/sqlmigration/120_add_license_tuples.go b/pkg/sqlmigration/120_add_license_tuples.go new file mode 100644 index 00000000000..63257c4ff0f --- /dev/null +++ b/pkg/sqlmigration/120_add_license_tuples.go @@ -0,0 +1,134 @@ +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/authtypes" + "github.com/oklog/ulid/v2" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" + "github.com/uptrace/bun/migrate" +) + +type addLicenseTuples struct { + sqlstore sqlstore.SQLStore +} + +func NewAddLicenseTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("add_license_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return &addLicenseTuples{sqlstore: sqlstore}, nil + }) +} + +func (migration *addLicenseTuples) Register(migrations *migrate.Migrations) error { + return migrations.Register(migration.Up, migration.Down) +} + +func (migration *addLicenseTuples) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var storeID string + err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID) + if err != nil { + return err + } + + var orgIDs []string + err = tx.NewSelect(). + Table("organizations"). + Column("id"). + Scan(ctx, &orgIDs) + if err != nil && err != sql.ErrNoRows { + return err + } + + isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG + + tuples := []migrationTuple{ + {authtypes.SigNozAdminRoleName, "metaresource", "license", "create"}, + {authtypes.SigNozAdminRoleName, "metaresource", "license", "read"}, + {authtypes.SigNozAdminRoleName, "metaresource", "license", "update"}, + {authtypes.SigNozAdminRoleName, "metaresource", "license", "delete"}, + {authtypes.SigNozAdminRoleName, "metaresource", "license", "list"}, + } + + for _, orgID := range orgIDs { + for _, tuple := range tuples { + entropy := ulid.DefaultEntropy() + now := time.Now().UTC() + tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String() + + objectID := "organization/" + orgID + "/" + tuple.objectName + "/*" + roleSubject := "organization/" + orgID + "/role/" + tuple.roleName + + if isPG { + user := "role:" + roleSubject + "#assignee" + result, err := tx.ExecContext(ctx, ` + INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now, + ) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + continue + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, ulid, object_type) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now, + ) + if err != nil { + return err + } + } else { + result, err := tx.ExecContext(ctx, ` + INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now, + ) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + continue + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, ulid, object_type) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now, + ) + if err != nil { + return err + } + } + } + } + + return tx.Commit() +} + +func (migration *addLicenseTuples) Down(context.Context, *bun.DB) error { + return nil +} diff --git a/pkg/types/coretypes/registry_managed_role.go b/pkg/types/coretypes/registry_managed_role.go index 9e23defb6f7..947ff99be5b 100644 --- a/pkg/types/coretypes/registry_managed_role.go +++ b/pkg/types/coretypes/registry_managed_role.go @@ -64,11 +64,10 @@ var ManagedRoleToTransactions = map[string][]Transaction{ {Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)}, {Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)}, // license — admin only. - // Uniform LCRUD shape; actual ee routes are POST /api/v3/licenses (create - // = Activate), PUT /api/v3/licenses (update = Refresh), GET - // /api/v3/licenses/active (read; currently exposed as ViewAccess on the - // route side). delete and list are placeholders for shape parity, no - // route serves them today. + // Uniform LCRUD shape served by /api/v4/licenses: create = Activate, + // update = Refresh, read = Get (includes the key), list, delete (non-cloud + // licenses only). GET /api/v4/licenses/active is OpenAccess, so the read + // grant is not enforced there. {Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)}, {Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)}, {Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)}, diff --git a/pkg/types/coretypes/registry_resource.go b/pkg/types/coretypes/registry_resource.go index fc09f125ee9..9e7f8528a72 100644 --- a/pkg/types/coretypes/registry_resource.go +++ b/pkg/types/coretypes/registry_resource.go @@ -70,7 +70,7 @@ var ( ResourceMetaResourceTraceFunnel = NewResourceMetaResource(KindTraceFunnel) ResourceMetaResourceFactorPassword = NewResourceMetaResource(KindFactorPassword) ResourceMetaResourceFactorAPIKey = NewResourceMetaResource(KindFactorAPIKey, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete) - ResourceMetaResourceLicense = NewResourceMetaResource(KindLicense) + ResourceMetaResourceLicense = NewResourceMetaResource(KindLicense, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete) ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription) ResourceMetaResourceDeploymentHost = NewResourceMetaResource(KindDeploymentHost, VerbList, VerbUpdate) ResourceTelemetryResourceLogs = NewResourceTelemetryResource(KindLogs) diff --git a/pkg/types/licensetypes/license.go b/pkg/types/licensetypes/license.go index 0271828369f..157e2cc63c8 100644 --- a/pkg/types/licensetypes/license.go +++ b/pkg/types/licensetypes/license.go @@ -3,15 +3,20 @@ package licensetypes import ( "context" "encoding/json" - "reflect" + "strings" "time" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/zeustypes" "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" ) +var ( + ErrCodeCloudLicenseOperationUnsupported = errors.MustNewCode("cloud_license_operation_unsupported") +) + type StorableLicense struct { bun.BaseModel `bun:"table:license"` @@ -28,10 +33,12 @@ type License struct { ID valuer.UUID Key string Data map[string]interface{} - PlanName valuer.String + Plan LicensePlan + EventQueue LicenseEventQueue Features []*Feature Status valuer.String - State string + State valuer.String + Platform valuer.String FreeUntil time.Time ValidFrom int64 ValidUntil int64 @@ -41,26 +48,49 @@ type License struct { OrganizationID valuer.UUID } -type GettableLicense map[string]any +type LicensePlan struct { + ID valuer.UUID `json:"id" required:"true"` + Name valuer.String `json:"name" required:"true"` + Description string `json:"description" required:"true"` + IsActive bool `json:"isActive" required:"true"` + CreatedAt time.Time `json:"createdAt" required:"true"` + UpdatedAt time.Time `json:"updatedAt" required:"true"` +} -type PostableLicense struct { - Key string `json:"key"` +type LicenseEventQueue struct { + Event valuer.String `json:"event" required:"true"` + Status valuer.String `json:"status" required:"true"` + ScheduledAt time.Time `json:"scheduledAt" required:"true"` + CreatedAt time.Time `json:"createdAt" required:"true"` + UpdatedAt time.Time `json:"updatedAt" required:"true"` } -func NewStorableLicense(ID valuer.UUID, key string, data map[string]any, createdAt, updatedAt, lastValidatedAt time.Time, organizationID valuer.UUID) *StorableLicense { - return &StorableLicense{ - Identifiable: types.Identifiable{ - ID: ID, - }, - TimeAuditable: types.TimeAuditable{ - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }, - Key: key, - Data: data, - LastValidatedAt: lastValidatedAt, - OrgID: organizationID, - } +type GettableLicense struct { + ID valuer.UUID `json:"id" required:"true"` + ValidFrom int64 `json:"validFrom" required:"true"` + ValidUntil int64 `json:"validUntil" required:"true"` + Status valuer.String `json:"status" required:"true"` + State valuer.String `json:"state" required:"true"` + Platform valuer.String `json:"platform" required:"true"` + FreeUntil time.Time `json:"freeUntil" required:"true"` + CreatedAt time.Time `json:"createdAt" required:"true"` + UpdatedAt time.Time `json:"updatedAt" required:"true"` + Plan LicensePlan `json:"plan" required:"true"` + Features []*Feature `json:"features" required:"true" nullable:"false"` + EventQueue LicenseEventQueue `json:"eventQueue" required:"true"` +} + +type GettableLicenseWithKey struct { + GettableLicense + Key string `json:"key" required:"true" format:"password"` +} + +type GettableActiveLicense struct { + GettableLicense +} + +type PostableLicense struct { + Key string `json:"key" format:"password"` } func NewStorableLicenseFromLicense(license *License) *StorableLicense { @@ -106,263 +136,109 @@ func GetActiveLicenseFromStorableLicenses(storableLicenses []*StorableLicense, o return activeLicense, nil } -func extractKeyFromMapStringInterface[T any](data map[string]interface{}, key string) (T, error) { - var zeroValue T - if val, ok := data[key]; ok { - if value, ok := val.(T); ok { - return value, nil - } - return zeroValue, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s key is not a valid %s", key, reflect.TypeOf(zeroValue)) +func NewLicense(zeusLicense *zeustypes.License, organizationID valuer.UUID) (*License, error) { + if zeusLicense.ID.IsZero() { + return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license id is missing") } - return zeroValue, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s key is missing", key) -} -func NewLicense(data []byte, organizationID valuer.UUID) (*License, error) { - licenseData := map[string]any{} - err := json.Unmarshal(data, &licenseData) - if err != nil { - return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to unmarshal license data") + if zeusLicense.Key == "" { + return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license key is missing") } - var features []*Feature - - // extract id from data - licenseIDStr, err := extractKeyFromMapStringInterface[string](licenseData, "id") + planName, status, err := newPlanNameAndStatusFromZeusLicense(zeusLicense) if err != nil { return nil, err } - licenseID, err := valuer.NewUUID(licenseIDStr) - if err != nil { - return nil, err - } - delete(licenseData, "id") - - // extract key from data - licenseKey, err := extractKeyFromMapStringInterface[string](licenseData, "key") - if err != nil { - return nil, err - } - delete(licenseData, "key") - - // extract status from data - statusStr, err := extractKeyFromMapStringInterface[string](licenseData, "status") - if err != nil { - return nil, err - } - status := valuer.NewString(statusStr) - planMap, err := extractKeyFromMapStringInterface[map[string]any](licenseData, "plan") - if err != nil { - return nil, err - } + features := newMergedFeatures(planName, zeusLicense.Features) - planNameStr, err := extractKeyFromMapStringInterface[string](planMap, "name") + data, err := newDataFromZeusLicense(zeusLicense, features) if err != nil { return nil, err } - planName := valuer.NewString(planNameStr) - // if license status is invalid then default it to basic - if status == LicenseStatusInvalid { - planName = PlanNameBasic - } - - state, err := extractKeyFromMapStringInterface[string](licenseData, "state") - if err != nil { - state = "" - } - - freeUntilStr, err := extractKeyFromMapStringInterface[string](licenseData, "free_until") - if err != nil { - freeUntilStr = "" - } - - freeUntil, err := time.Parse(time.RFC3339, freeUntilStr) - if err != nil { - freeUntil = time.Time{} - } - - featuresFromZeus := make([]*Feature, 0) - if _features, ok := licenseData["features"]; ok { - featuresData, err := json.Marshal(_features) - if err != nil { - return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal features data") - } - - if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil { - return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to unmarshal features data") - } - } - - switch planName { - case PlanNameEnterprise: - features = append(features, EnterprisePlan...) - case PlanNameBasic: - features = append(features, BasicPlan...) - default: - features = append(features, BasicPlan...) - } - - if len(featuresFromZeus) > 0 { - for _, feature := range featuresFromZeus { - exists := false - for i, existingFeature := range features { - if existingFeature.Name == feature.Name { - features[i] = feature // Replace existing feature - exists = true - break - } - } - if !exists { - features = append(features, feature) // Append if it doesn't exist - } - } - } - licenseData["features"] = features - - _validFrom, err := extractKeyFromMapStringInterface[float64](licenseData, "valid_from") - if err != nil { - _validFrom = 0 - } - validFrom := int64(_validFrom) - - _validUntil, err := extractKeyFromMapStringInterface[float64](licenseData, "valid_until") - if err != nil { - _validUntil = 0 - } - validUntil := int64(_validUntil) return &License{ - ID: licenseID, - Key: licenseKey, - Data: licenseData, - PlanName: planName, + ID: zeusLicense.ID, + Key: zeusLicense.Key, + Data: data, + Plan: newLicensePlanFromZeusLicense(zeusLicense, planName), + EventQueue: newLicenseEventQueueFromZeusLicense(zeusLicense), Features: features, - ValidFrom: validFrom, - ValidUntil: validUntil, + ValidFrom: zeusLicense.ValidFrom, + ValidUntil: zeusLicense.ValidUntil, Status: status, - State: state, - FreeUntil: freeUntil, + State: valuer.NewString(zeusLicense.State), + Platform: valuer.NewString(zeusLicense.Platform), + FreeUntil: zeusLicense.FreeUntil, CreatedAt: time.Now(), UpdatedAt: time.Now(), LastValidatedAt: time.Now(), OrganizationID: organizationID, }, nil - } func NewLicenseFromStorableLicense(storableLicense *StorableLicense) (*License, error) { - var features []*Feature - // extract status from data - statusStr, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "status") - if err != nil { - return nil, err - } - status := valuer.NewString(statusStr) - - planMap, err := extractKeyFromMapStringInterface[map[string]any](storableLicense.Data, "plan") + zeusLicense, err := NewZeusLicenseFromData(storableLicense.Data) if err != nil { return nil, err } - planNameStr, err := extractKeyFromMapStringInterface[string](planMap, "name") + planName, status, err := newPlanNameAndStatusFromZeusLicense(zeusLicense) if err != nil { return nil, err } - planName := valuer.NewString(planNameStr) - // if license status is invalid then default it to basic - if status == LicenseStatusInvalid { - planName = PlanNameBasic - } - - featuresFromZeus := make([]*Feature, 0) - if _features, ok := storableLicense.Data["features"]; ok { - featuresData, err := json.Marshal(_features) - if err != nil { - return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal features data") - } - - if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil { - return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to unmarshal features data") - } - } - - switch planName { - case PlanNameEnterprise: - features = append(features, EnterprisePlan...) - case PlanNameBasic: - features = append(features, BasicPlan...) - default: - features = append(features, BasicPlan...) - } - if len(featuresFromZeus) > 0 { - for _, feature := range featuresFromZeus { - exists := false - for i, existingFeature := range features { - if existingFeature.Name == feature.Name { - features[i] = feature // Replace existing feature - exists = true - break - } - } - if !exists { - features = append(features, feature) // Append if it doesn't exist - } - } - } + features := newMergedFeatures(planName, zeusLicense.Features) storableLicense.Data["features"] = features - _validFrom, err := extractKeyFromMapStringInterface[float64](storableLicense.Data, "valid_from") - if err != nil { - _validFrom = 0 - } - validFrom := int64(_validFrom) - - _validUntil, err := extractKeyFromMapStringInterface[float64](storableLicense.Data, "valid_until") - if err != nil { - _validUntil = 0 - } - validUntil := int64(_validUntil) - - state, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "state") - if err != nil { - state = "" - } - - freeUntilStr, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "free_until") - if err != nil { - freeUntilStr = "" - } - - freeUntil, err := time.Parse(time.RFC3339, freeUntilStr) - if err != nil { - freeUntil = time.Time{} - } - return &License{ ID: storableLicense.ID, Key: storableLicense.Key, Data: storableLicense.Data, - PlanName: planName, + Plan: newLicensePlanFromZeusLicense(zeusLicense, planName), + EventQueue: newLicenseEventQueueFromZeusLicense(zeusLicense), Features: features, - ValidFrom: validFrom, - ValidUntil: validUntil, + ValidFrom: zeusLicense.ValidFrom, + ValidUntil: zeusLicense.ValidUntil, Status: status, - State: state, - FreeUntil: freeUntil, + State: valuer.NewString(zeusLicense.State), + Platform: valuer.NewString(zeusLicense.Platform), + FreeUntil: zeusLicense.FreeUntil, CreatedAt: storableLicense.CreatedAt, UpdatedAt: storableLicense.UpdatedAt, LastValidatedAt: storableLicense.LastValidatedAt, OrganizationID: storableLicense.OrgID, }, nil +} + +func NewZeusLicenseFromData(data map[string]any) (*zeustypes.License, error) { + dataBytes, err := json.Marshal(data) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal license data") + } + + zeusLicense := new(zeustypes.License) + if err := json.Unmarshal(dataBytes, zeusLicense); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to unmarshal license data") + } + + return zeusLicense, nil +} +// ErrIfCloud returns an error if the license is managed by SigNoz Cloud. The +// caller should enrich the error with the specific operation using errors.WithAdditionalf. +func (license *License) ErrIfCloud() error { + if license.Platform == LicensePlatformCloud { + return errors.New(errors.TypeInvalidInput, ErrCodeCloudLicenseOperationUnsupported, "this operation is not supported for licenses managed by SigNoz Cloud") + } + return nil } func NewStatsFromLicense(license *License) map[string]any { return map[string]any{ "license.id": license.ID.StringValue(), - "license.plan.name": license.PlanName.StringValue(), - "license.state.name": license.State, + "license.plan.name": license.Plan.Name.StringValue(), + "license.state.name": strings.ToUpper(license.State.StringValue()), "license.free_until.time": license.FreeUntil.UTC(), } } @@ -371,8 +247,8 @@ func (license *License) UpdateFeatures(features []*Feature) { license.Features = features } -func (license *License) Update(data []byte) error { - updatedLicense, err := NewLicense(data, license.OrganizationID) +func (license *License) Update(zeusLicense *zeustypes.License) error { + updatedLicense, err := NewLicense(zeusLicense, license.OrganizationID) if err != nil { return err } @@ -382,8 +258,11 @@ func (license *License) Update(data []byte) error { license.Features = updatedLicense.Features license.ID = updatedLicense.ID license.Key = updatedLicense.Key - license.PlanName = updatedLicense.PlanName + license.Plan = updatedLicense.Plan + license.EventQueue = updatedLicense.EventQueue license.Status = updatedLicense.Status + license.State = updatedLicense.State + license.Platform = updatedLicense.Platform license.ValidFrom = updatedLicense.ValidFrom license.ValidUntil = updatedLicense.ValidUntil license.UpdatedAt = currentTime @@ -392,13 +271,34 @@ func (license *License) Update(data []byte) error { return nil } -func NewGettableLicense(data map[string]any, key string) *GettableLicense { - gettableLicense := make(GettableLicense) - for k, v := range data { - gettableLicense[k] = v +func NewGettableLicense(license *License) *GettableLicense { + return &GettableLicense{ + ID: license.ID, + ValidFrom: license.ValidFrom, + ValidUntil: license.ValidUntil, + Status: license.Status, + State: license.State, + Platform: license.Platform, + FreeUntil: license.FreeUntil, + CreatedAt: license.CreatedAt, + UpdatedAt: license.UpdatedAt, + Plan: license.Plan, + Features: license.Features, + EventQueue: license.EventQueue, + } +} + +func NewGettableLicenseWithKey(license *License) *GettableLicenseWithKey { + return &GettableLicenseWithKey{ + GettableLicense: *NewGettableLicense(license), + Key: license.Key, + } +} + +func NewGettableActiveLicense(license *License) *GettableActiveLicense { + return &GettableActiveLicense{ + GettableLicense: *NewGettableLicense(license), } - gettableLicense["key"] = key - return &gettableLicense } func (p *PostableLicense) UnmarshalJSON(data []byte) error { @@ -419,9 +319,102 @@ func (p *PostableLicense) UnmarshalJSON(data []byte) error { return nil } +func newPlanNameAndStatusFromZeusLicense(zeusLicense *zeustypes.License) (valuer.String, valuer.String, error) { + if zeusLicense.Status == "" { + return valuer.String{}, valuer.String{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license status is missing") + } + + if zeusLicense.Plan.Name == "" { + return valuer.String{}, valuer.String{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license plan name is missing") + } + + status := valuer.NewString(zeusLicense.Status) + planName := valuer.NewString(zeusLicense.Plan.Name) + // if license status is invalid then default it to basic + if status == LicenseStatusInvalid { + planName = PlanNameBasic + } + + return planName, status, nil +} + +func newLicensePlanFromZeusLicense(zeusLicense *zeustypes.License, planName valuer.String) LicensePlan { + return LicensePlan{ + ID: zeusLicense.Plan.ID, + Name: planName, + Description: zeusLicense.Plan.Description, + IsActive: zeusLicense.Plan.IsActive, + CreatedAt: zeusLicense.Plan.CreatedAt, + UpdatedAt: zeusLicense.Plan.UpdatedAt, + } +} + +func newLicenseEventQueueFromZeusLicense(zeusLicense *zeustypes.License) LicenseEventQueue { + return LicenseEventQueue{ + Event: valuer.NewString(zeusLicense.EventQueue.Event), + Status: valuer.NewString(zeusLicense.EventQueue.Status), + ScheduledAt: zeusLicense.EventQueue.ScheduledAt, + CreatedAt: zeusLicense.EventQueue.CreatedAt, + UpdatedAt: zeusLicense.EventQueue.UpdatedAt, + } +} + +func newMergedFeatures(planName valuer.String, zeusFeatures []zeustypes.LicenseFeature) []*Feature { + features := make([]*Feature, 0) + switch planName { + case PlanNameEnterprise: + features = append(features, EnterprisePlan...) + default: + features = append(features, BasicPlan...) + } + + for _, zeusFeature := range zeusFeatures { + feature := &Feature{ + Name: valuer.NewString(zeusFeature.Name), + Active: zeusFeature.Active, + Usage: zeusFeature.Usage, + UsageLimit: zeusFeature.UsageLimit, + Route: zeusFeature.Route, + } + + exists := false + for i, existingFeature := range features { + if existingFeature.Name == feature.Name { + features[i] = feature + exists = true + break + } + } + if !exists { + features = append(features, feature) + } + } + + return features +} + +func newDataFromZeusLicense(zeusLicense *zeustypes.License, features []*Feature) (map[string]any, error) { + dataBytes, err := json.Marshal(zeusLicense) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal license data") + } + + data := map[string]any{} + if err := json.Unmarshal(dataBytes, &data); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to unmarshal license data") + } + + delete(data, "id") + delete(data, "key") + data["features"] = features + + return data, nil +} + type Store interface { Create(context.Context, *StorableLicense) error Get(context.Context, valuer.UUID, valuer.UUID) (*StorableLicense, error) GetAll(context.Context, valuer.UUID) ([]*StorableLicense, error) Update(context.Context, valuer.UUID, *StorableLicense) error + Delete(context.Context, valuer.UUID, valuer.UUID) error } diff --git a/pkg/types/licensetypes/license_test.go b/pkg/types/licensetypes/license_test.go index e6589643f47..698f145eb18 100644 --- a/pkg/types/licensetypes/license_test.go +++ b/pkg/types/licensetypes/license_test.go @@ -1,178 +1,135 @@ package licensetypes import ( + "encoding/json" "testing" "time" + "github.com/SigNoz/signoz/pkg/types/zeustypes" "github.com/SigNoz/signoz/pkg/valuer" - "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestNewLicenseV3(t *testing.T) { +func TestNewLicenseValidation(t *testing.T) { + organizationID := valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e") + testCases := []struct { - name string - data []byte - pass bool - expected *License - error error + name string + data string + errorContains string }{ { - name: "Error for missing license id", - data: []byte(`{}`), - pass: false, - error: errors.New("id key is missing"), - }, - { - name: "Error for license id not being a valid string", - data: []byte(`{"id": 10}`), - pass: false, - error: errors.New("id key is not a valid string"), - }, - { - name: "Error for missing license key", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e"}`), - pass: false, - error: errors.New("key key is missing"), - }, - { - name: "Error for invalid string license key", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":10}`), - pass: false, - error: errors.New("key key is not a valid string"), - }, - { - name: "Error for missing license status", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e", "key": "does-not-matter","category":"FREE"}`), - pass: false, - error: errors.New("status key is missing"), - }, - { - name: "Error for invalid string license status", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key": "does-not-matter", "category":"FREE", "status":10}`), - pass: false, - error: errors.New("status key is not a valid string"), + name: "missing license id", + data: `{}`, + errorContains: "license id is missing", }, { - name: "Error for missing license plan", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE"}`), - pass: false, - error: errors.New("plan key is missing"), + name: "missing license key", + data: `{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e"}`, + errorContains: "license key is missing", }, { - name: "Error for invalid json license plan", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":10}`), - pass: false, - error: errors.New("plan key is not a valid map[string]interface {}"), + name: "missing license status", + data: `{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter"}`, + errorContains: "license status is missing", }, { - name: "Error for invalid license plan", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{}}`), - pass: false, - error: errors.New("name key is missing"), - }, - { - name: "Parse the entire license properly", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1,"state":"test","free_until":"2025-05-16T11:17:48.124202Z"}`), - pass: true, - expected: &License{ - ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), - Key: "does-not-matter-key", - Data: map[string]interface{}{ - "plan": map[string]interface{}{ - "name": "ENTERPRISE", - }, - "category": "FREE", - "status": "ACTIVE", - "valid_from": float64(1730899309), - "valid_until": float64(-1), - "state": "test", - "free_until": "2025-05-16T11:17:48.124202Z", - }, - PlanName: PlanNameEnterprise, - ValidFrom: 1730899309, - ValidUntil: -1, - Status: valuer.NewString("ACTIVE"), - State: "test", - FreeUntil: time.Date(2025, 5, 16, 11, 17, 48, 124202000, time.UTC), - Features: make([]*Feature, 0), - OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), - }, - }, - { - name: "Fallback to basic plan if license status is invalid", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"INVALID","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`), - pass: true, - expected: &License{ - ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), - Key: "does-not-matter-key", - Data: map[string]interface{}{ - "plan": map[string]interface{}{ - "name": "ENTERPRISE", - }, - "category": "FREE", - "status": "INVALID", - "valid_from": float64(1730899309), - "valid_until": float64(-1), - }, - PlanName: PlanNameBasic, - ValidFrom: 1730899309, - ValidUntil: -1, - Status: valuer.NewString("INVALID"), - Features: make([]*Feature, 0), - OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), - }, - }, - { - name: "fallback states for validFrom and validUntil", - data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from":1234.456,"valid_until":5678.567}`), - pass: true, - expected: &License{ - ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), - Key: "does-not-matter-key", - Data: map[string]interface{}{ - "plan": map[string]interface{}{ - "name": "ENTERPRISE", - }, - "valid_from": 1234.456, - "valid_until": 5678.567, - "category": "FREE", - "status": "ACTIVE", - }, - PlanName: PlanNameEnterprise, - ValidFrom: 1234, - ValidUntil: 5678, - Status: valuer.NewString("ACTIVE"), - Features: make([]*Feature, 0), - CreatedAt: time.Time{}, - UpdatedAt: time.Time{}, - LastValidatedAt: time.Time{}, - OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), - }, + name: "missing license plan name", + data: `{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter","status":"ACTIVE","plan":{}}`, + errorContains: "license plan name is missing", }, } for _, tc := range testCases { - license, err := NewLicense(tc.data, valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e")) - if license != nil { - license.Features = make([]*Feature, 0) - delete(license.Data, "features") - } + zeusLicense := new(zeustypes.License) + require.NoError(t, json.Unmarshal([]byte(tc.data), zeusLicense), tc.name) - if tc.pass { - require.NoError(t, err) - require.NotNil(t, license) - // as the new license will pick the time.Now() value. doesn't make sense to compare them - license.CreatedAt = time.Time{} - license.UpdatedAt = time.Time{} - license.LastValidatedAt = time.Time{} - assert.Equal(t, tc.expected, license) - } else { - require.Error(t, err) - assert.EqualError(t, err, tc.error.Error()) - require.Nil(t, license) - } + license, err := NewLicense(zeusLicense, organizationID) + require.Error(t, err, tc.name) + assert.ErrorContains(t, err, tc.errorContains, tc.name) + require.Nil(t, license, tc.name) + } +} +func TestNewLicense(t *testing.T) { + organizationID := valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e") + + zeusLicense := new(zeustypes.License) + require.NoError(t, json.Unmarshal([]byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","status":"ACTIVE","state":"EVALUATING","platform":"SELF_HOSTED","plan":{"name":"ENTERPRISE"},"valid_from":1730899309,"valid_until":-1,"free_until":"2025-05-16T11:17:48.124202Z","features":[{"name":"sso","active":true,"usage":0,"usage_limit":-1,"route":""}],"event_queue":{"event":"DEFAULT","status":"SCHEDULED"}}`), zeusLicense)) + + license, err := NewLicense(zeusLicense, organizationID) + require.NoError(t, err) + + assert.Equal(t, valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), license.ID) + assert.Equal(t, "does-not-matter-key", license.Key) + assert.Equal(t, PlanNameEnterprise, license.Plan.Name) + assert.Equal(t, valuer.NewString("active"), license.Status) + assert.Equal(t, valuer.NewString("evaluating"), license.State) + assert.Equal(t, LicensePlatformSelfHosted, license.Platform) + assert.Equal(t, valuer.NewString("default"), license.EventQueue.Event) + assert.Equal(t, valuer.NewString("scheduled"), license.EventQueue.Status) + assert.Equal(t, int64(1730899309), license.ValidFrom) + assert.Equal(t, int64(-1), license.ValidUntil) + assert.Equal(t, time.Date(2025, 5, 16, 11, 17, 48, 124202000, time.UTC), license.FreeUntil) + assert.Equal(t, organizationID, license.OrganizationID) + + ssoFeature := false + for _, feature := range license.Features { + if feature.Name == SSO { + ssoFeature = feature.Active + } } + assert.True(t, ssoFeature) + + assert.NotContains(t, license.Data, "id") + assert.NotContains(t, license.Data, "key") + assert.Equal(t, "ACTIVE", license.Data["status"]) + + gettableLicense := NewGettableLicense(license) + assert.Equal(t, license.ID, gettableLicense.ID) + assert.Equal(t, valuer.NewString("active"), gettableLicense.Status) + assert.Equal(t, LicensePlatformSelfHosted, gettableLicense.Platform) + assert.Equal(t, PlanNameEnterprise, gettableLicense.Plan.Name) + + gettableLicenseWithKey := NewGettableLicenseWithKey(license) + assert.Equal(t, "does-not-matter-key", gettableLicenseWithKey.Key) +} + +func TestNewLicenseFallsBackToBasicPlanOnInvalidStatus(t *testing.T) { + organizationID := valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e") + + zeusLicense := new(zeustypes.License) + require.NoError(t, json.Unmarshal([]byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","status":"INVALID","plan":{"name":"ENTERPRISE"},"valid_from":1730899309,"valid_until":-1}`), zeusLicense)) + + license, err := NewLicense(zeusLicense, organizationID) + require.NoError(t, err) + + assert.Equal(t, PlanNameBasic, license.Plan.Name) +} + +func TestNewLicenseFromStorableLicenseRoundTrip(t *testing.T) { + organizationID := valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e") + + zeusLicense := new(zeustypes.License) + require.NoError(t, json.Unmarshal([]byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","status":"ACTIVE","state":"EVALUATING","platform":"CLOUD","plan":{"name":"ENTERPRISE"},"valid_from":1730899309,"valid_until":-1}`), zeusLicense)) + + license, err := NewLicense(zeusLicense, organizationID) + require.NoError(t, err) + + storableLicense := NewStorableLicenseFromLicense(license) + + roundTrippedLicense, err := NewLicenseFromStorableLicense(storableLicense) + require.NoError(t, err) + + assert.Equal(t, license.ID, roundTrippedLicense.ID) + assert.Equal(t, license.Key, roundTrippedLicense.Key) + assert.Equal(t, license.Plan.Name, roundTrippedLicense.Plan.Name) + assert.Equal(t, license.Status, roundTrippedLicense.Status) + assert.Equal(t, license.State, roundTrippedLicense.State) + assert.Equal(t, LicensePlatformCloud, roundTrippedLicense.Platform) + assert.Equal(t, license.ValidFrom, roundTrippedLicense.ValidFrom) + assert.Equal(t, license.ValidUntil, roundTrippedLicense.ValidUntil) + + assert.ErrorContains(t, roundTrippedLicense.ErrIfCloud(), "not supported for licenses managed by SigNoz Cloud") } diff --git a/pkg/types/licensetypes/plan.go b/pkg/types/licensetypes/plan.go index 9a53d92012d..0332133ffbc 100644 --- a/pkg/types/licensetypes/plan.go +++ b/pkg/types/licensetypes/plan.go @@ -17,6 +17,10 @@ var ( // License State. LicenseStatusInvalid = valuer.NewString("invalid") + // License Platform. + LicensePlatformCloud = valuer.NewString("cloud") + LicensePlatformSelfHosted = valuer.NewString("self_hosted") + // Plan. PlanNameEnterprise = valuer.NewString("enterprise") PlanNameBasic = valuer.NewString("basic") diff --git a/pkg/types/zeustypes/license.go b/pkg/types/zeustypes/license.go new file mode 100644 index 00000000000..3d909115bf1 --- /dev/null +++ b/pkg/types/zeustypes/license.go @@ -0,0 +1,49 @@ +package zeustypes + +import ( + "time" + + "github.com/SigNoz/signoz/pkg/valuer" +) + +type LicenseFeature struct { + Name string `json:"name"` + Active bool `json:"active"` + Usage int64 `json:"usage"` + UsageLimit int64 `json:"usage_limit"` + Route string `json:"route"` +} + +type LicensePlan struct { + ID valuer.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type LicenseEventQueue struct { + Event string `json:"event"` + Status string `json:"status"` + ScheduledAt time.Time `json:"scheduled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type License struct { + ID valuer.UUID `json:"id"` + Key string `json:"key"` + ValidFrom int64 `json:"valid_from"` + ValidUntil int64 `json:"valid_until"` + Status string `json:"status"` + State string `json:"state"` + Platform string `json:"platform"` + FreeUntil time.Time `json:"free_until"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PlanID valuer.UUID `json:"plan_id"` + Plan LicensePlan `json:"plan"` + Features []LicenseFeature `json:"features"` + EventQueue LicenseEventQueue `json:"event_queue"` +} diff --git a/pkg/zeus/noopzeus/provider.go b/pkg/zeus/noopzeus/provider.go index 268060f308b..ee9e5962e4f 100644 --- a/pkg/zeus/noopzeus/provider.go +++ b/pkg/zeus/noopzeus/provider.go @@ -21,7 +21,7 @@ func New(_ context.Context, _ factory.ProviderSettings, _ zeus.Config) (zeus.Zeu return &provider{}, nil } -func (provider *provider) GetLicense(_ context.Context, _ string) ([]byte, error) { +func (provider *provider) GetLicense(_ context.Context, _ string) (*zeustypes.License, error) { return nil, errors.New(errors.TypeUnsupported, zeus.ErrCodeUnsupported, "fetching license is not supported") } diff --git a/pkg/zeus/zeus.go b/pkg/zeus/zeus.go index 693a85f06f8..37078859326 100644 --- a/pkg/zeus/zeus.go +++ b/pkg/zeus/zeus.go @@ -15,7 +15,7 @@ var ( type Zeus interface { // Returns the license for the given key. - GetLicense(context.Context, string) ([]byte, error) + GetLicense(context.Context, string) (*zeustypes.License, error) // Returns the checkout URL for the given license key. GetCheckoutURL(context.Context, string, []byte) ([]byte, error) diff --git a/tests/fixtures/auth.py b/tests/fixtures/auth.py index 1ca737b583c..6e596eb3835 100644 --- a/tests/fixtures/auth.py +++ b/tests/fixtures/auth.py @@ -189,7 +189,7 @@ def apply_license( request: pytest.FixtureRequest, pytestconfig: pytest.Config, ) -> types.Operation: - """Stub Zeus license-lookup, then POST /api/v3/licenses so the BE flips + """Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and every spec inherits the licensed state.""" @@ -226,10 +226,10 @@ def create() -> types.Operation: access_token = _login(signoz, USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) - # 202 = applied, 409 = already applied. Retry transient failures — + # 201 = applied, 409 = already applied. Retry transient failures — # the BE occasionally 5xxs right after startup before the license # sync goroutine is ready. - license_url = signoz.self.host_configs["8080"].get("/api/v3/licenses") + license_url = signoz.self.host_configs["8080"].get("/api/v4/licenses") auth_header = {"Authorization": f"Bearer {access_token}"} for attempt in range(10): resp = requests.post( @@ -238,7 +238,7 @@ def create() -> types.Operation: headers=auth_header, timeout=5, ) - if resp.status_code in (HTTPStatus.ACCEPTED, HTTPStatus.CONFLICT): + if resp.status_code in (HTTPStatus.CREATED, HTTPStatus.CONFLICT): break if attempt == 9: resp.raise_for_status() @@ -318,7 +318,7 @@ def add_license( access_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) response = requests.post( - url=signoz.self.host_configs["8080"].get(f"{base_path}/api/v3/licenses"), + url=signoz.self.host_configs["8080"].get(f"{base_path}/api/v4/licenses"), json={"key": "secret-key"}, headers={"Authorization": "Bearer " + access_token}, timeout=5, @@ -327,7 +327,7 @@ def add_license( if response.status_code == HTTPStatus.CONFLICT: return - assert response.status_code == HTTPStatus.ACCEPTED + assert response.status_code == HTTPStatus.CREATED response = requests.post( url=signoz.zeus.host_configs["8080"].get("/__admin/requests/count"), diff --git a/tests/integration/tests/passwordauthn/02_license.py b/tests/integration/tests/passwordauthn/02_license.py index 66ba9b9c125..8617bbd523f 100644 --- a/tests/integration/tests/passwordauthn/02_license.py +++ b/tests/integration/tests/passwordauthn/02_license.py @@ -54,6 +54,16 @@ def test_apply_license( access_token = get_token("admin@integration.test", "password123Z$") + response = requests.post( + url=signoz.self.host_configs["8080"].get("/api/v4/licenses"), + json={"key": "secret-key"}, + headers={"Authorization": "Bearer " + access_token}, + timeout=5, + ) + + assert response.status_code == http.HTTPStatus.CREATED + assert response.json()["data"]["id"] == "0196360e-90cd-7a74-8313-1aa815ce2a67" + response = requests.post( url=signoz.self.host_configs["8080"].get("/api/v3/licenses"), json={"key": "secret-key"}, @@ -61,7 +71,7 @@ def test_apply_license( timeout=5, ) - assert response.status_code == http.HTTPStatus.ACCEPTED + assert response.status_code == http.HTTPStatus.CONFLICT response = requests.post( url=signoz.zeus.host_configs["8080"].get("/__admin/requests/count"), @@ -69,7 +79,7 @@ def test_apply_license( timeout=5, ) - assert response.json()["count"] == 1 + assert response.json()["count"] == 2 def test_refresh_license( @@ -113,6 +123,14 @@ def test_refresh_license( access_token = get_token("admin@integration.test", "password123Z$") + response = requests.put( + url=signoz.self.host_configs["8080"].get("/api/v4/licenses/0196360e-90cd-7a74-8313-1aa815ce2a67"), + headers={"Authorization": "Bearer " + access_token}, + timeout=5, + ) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + response = requests.put( url=signoz.self.host_configs["8080"].get("/api/v3/licenses"), headers={"Authorization": "Bearer " + access_token}, @@ -122,12 +140,12 @@ def test_refresh_license( assert response.status_code == http.HTTPStatus.NO_CONTENT response = requests.get( - url=signoz.self.host_configs["8080"].get("/api/v3/licenses/active"), + url=signoz.self.host_configs["8080"].get("/api/v4/licenses/active"), headers={"Authorization": "Bearer " + access_token}, timeout=5, ) assert response.status_code == http.HTTPStatus.OK - assert response.json()["data"]["valid_from"] == 1732146922 + assert response.json()["data"]["validFrom"] == 1732146922 response = requests.post( url=signoz.zeus.host_configs["8080"].get("/__admin/requests/count"), @@ -135,7 +153,7 @@ def test_refresh_license( timeout=5, ) - assert response.json()["count"] == 1 + assert response.json()["count"] == 2 def test_license_checkout(