From 2d33d2f6d697881c623cf1d11cb4689a0ecba94e Mon Sep 17 00:00:00 2001 From: Niklas van Schrick Date: Tue, 28 Apr 2026 22:39:48 +0200 Subject: [PATCH 1/4] Refactor license to global entity --- .gitlab-ci.yml | 4 +- .rubocop.yml | 3 + app/models/audit_event.rb | 4 +- app/models/namespace_role_ability.rb | 6 +- app/services/error_code.rb | 2 - .../namespaces/members/invite_service.rb | 2 +- app/services/users/create_service.rb | 12 ++- .../users/identity/register_service.rb | 10 ++ app/services/users/register_service.rb | 10 ++ config/initializers/inflections.rb | 1 + ...7172623_change_license_to_global_object.rb | 8 ++ db/schema_migrations/20260427172623 | 1 + db/structure.sql | 18 ++-- docs/graphql/enum/errorcodeenum.md | 4 +- docs/graphql/enum/namespaceroleability.md | 6 +- .../mutation/namespaceslicensescreate.md | 4 +- .../mutation/namespaceslicensesdelete.md | 6 +- docs/graphql/object/license.md | 18 ++++ ...enseconnection.md => licenseconnection.md} | 8 +- ...namespacelicenseedge.md => licenseedge.md} | 4 +- docs/graphql/object/licenseuserabilities.md | 11 +++ docs/graphql/object/namespace.md | 4 +- docs/graphql/object/namespacelicense.md | 18 ---- .../object/namespacelicenseuserabilities.md | 11 --- docs/graphql/object/namespaceuserabilities.md | 2 +- docs/graphql/scalar/licenseid.md | 5 + docs/graphql/scalar/namespacelicenseid.md | 5 - .../app/graphql/cloud/types/license_type.rb | 15 +++ .../app/graphql/cloud}/types/mutation_type.rb | 2 +- .../app/graphql/cloud/types/namespace_type.rb | 23 +++++ .../mutations/namespaces/licenses/create.rb | 8 +- .../mutations/namespaces/licenses/delete.rb | 33 +++++++ extensions/cloud/app/models/cloud/license.rb | 29 ++++++ .../app/models/cloud}/namespace.rb | 6 +- .../app/policies/cloud/license_policy.rb | 11 +++ .../app/policies/cloud/namespace_policy.rb | 13 +++ .../namespaces/members/invite_service.rb | 2 +- .../namespaces/licenses/create_service.rb | 8 +- .../namespaces/licenses/delete_service.rb | 24 ++--- extensions/cloud/spec/factories/licenses.rb | 7 ++ .../namespaces/licenses/create_spec.rb | 0 .../types/cloud/types/license_type_spec.rb} | 8 +- .../types/cloud}/types/mutation_type_spec.rb | 2 +- .../types/cloud}/types/namespace_type_spec.rb | 6 +- .../cloud/spec/models/cloud/license_spec.rb | 58 ++++++++++++ .../cloud/spec/models/cloud/namespace_spec.rb | 11 +++ .../policies/cloud/license_policy_spec.rb | 7 ++ .../policies/cloud}/namespace_policy_spec.rb | 2 +- .../licenses/create_mutation_spec.rb | 20 ++-- .../licenses/delete_mutation_spec.rb | 20 ++-- .../namespaces/members/invite_service_spec.rb | 4 +- .../licenses/create_service_spec.rb | 16 ++-- .../licenses/delete_service_spec.rb | 16 ++-- .../ee/app/graphql/ee/types/namespace_type.rb | 24 ----- .../mutations/namespaces/licenses/delete.rb | 32 ------- ...espace_license_type.rb => license_type.rb} | 14 +-- .../{namespace_license.rb => license.rb} | 19 ++-- .../ee/app/policies/ee/namespace_policy.rb | 13 --- extensions/ee/app/policies/license_policy.rb | 6 ++ .../app/policies/namespace_license_policy.rb | 5 - extensions/ee/app/services/ee/error_code.rb | 2 + .../app/services/ee/users/create_service.rb | 12 +++ .../ee/users/identity/register_service.rb | 14 +++ .../app/services/ee/users/register_service.rb | 12 +++ .../services/ee/users/validate_user_limit.rb | 20 ++++ .../{namespace_licenses.rb => licenses.rb} | 4 +- .../spec/graphql/types/license_type_spec.rb | 21 +++++ .../ee/spec/models/ee/namespace_spec.rb | 11 --- extensions/ee/spec/models/license_spec.rb | 40 ++++++++ .../ee/spec/models/namespace_license_spec.rb | 91 ------------------- .../services/ee/users/create_service_spec.rb | 29 ++++++ .../users/identity/register_service_spec.rb | 50 ++++++++++ .../ee/users/register_service_spec.rb | 21 +++++ lib/sagittarius/extensions.rb | 2 +- spec/lib/sagittarius/extensions_spec.rb | 32 +++++-- spec/rails_helper.rb | 6 ++ .../graphql/mutation/users/create_spec.rb | 2 + 77 files changed, 668 insertions(+), 352 deletions(-) create mode 100644 db/migrate/20260427172623_change_license_to_global_object.rb create mode 100644 db/schema_migrations/20260427172623 create mode 100644 docs/graphql/object/license.md rename docs/graphql/object/{namespacelicenseconnection.md => licenseconnection.md} (50%) rename docs/graphql/object/{namespacelicenseedge.md => licenseedge.md} (61%) create mode 100644 docs/graphql/object/licenseuserabilities.md delete mode 100644 docs/graphql/object/namespacelicense.md delete mode 100644 docs/graphql/object/namespacelicenseuserabilities.md create mode 100644 docs/graphql/scalar/licenseid.md delete mode 100644 docs/graphql/scalar/namespacelicenseid.md create mode 100644 extensions/cloud/app/graphql/cloud/types/license_type.rb rename extensions/{ee/app/graphql/ee => cloud/app/graphql/cloud}/types/mutation_type.rb (95%) create mode 100644 extensions/cloud/app/graphql/cloud/types/namespace_type.rb rename extensions/{ee => cloud}/app/graphql/mutations/namespaces/licenses/create.rb (73%) create mode 100644 extensions/cloud/app/graphql/mutations/namespaces/licenses/delete.rb create mode 100644 extensions/cloud/app/models/cloud/license.rb rename extensions/{ee/app/models/ee => cloud/app/models/cloud}/namespace.rb (59%) create mode 100644 extensions/cloud/app/policies/cloud/license_policy.rb create mode 100644 extensions/cloud/app/policies/cloud/namespace_policy.rb rename extensions/{ee/app/services/ee => cloud/app/services/cloud}/namespaces/members/invite_service.rb (98%) rename extensions/{ee => cloud}/app/services/namespaces/licenses/create_service.rb (87%) rename extensions/{ee => cloud}/app/services/namespaces/licenses/delete_service.rb (54%) create mode 100644 extensions/cloud/spec/factories/licenses.rb rename extensions/{ee => cloud}/spec/graphql/mutations/namespaces/licenses/create_spec.rb (100%) rename extensions/{ee/spec/graphql/types/namespace_license_type_spec.rb => cloud/spec/graphql/types/cloud/types/license_type_spec.rb} (68%) rename extensions/{ee/spec/graphql/types/ee => cloud/spec/graphql/types/cloud}/types/mutation_type_spec.rb (58%) rename extensions/{ee/spec/graphql/types/ee => cloud/spec/graphql/types/cloud}/types/namespace_type_spec.rb (73%) create mode 100644 extensions/cloud/spec/models/cloud/license_spec.rb create mode 100644 extensions/cloud/spec/models/cloud/namespace_spec.rb create mode 100644 extensions/cloud/spec/policies/cloud/license_policy_spec.rb rename extensions/{ee/spec/policies/ee => cloud/spec/policies/cloud}/namespace_policy_spec.rb (55%) rename extensions/{ee => cloud}/spec/requests/graphql/mutations/namespaces/licenses/create_mutation_spec.rb (72%) rename extensions/{ee => cloud}/spec/requests/graphql/mutations/namespaces/licenses/delete_mutation_spec.rb (66%) rename extensions/{ee/spec/services/ee => cloud/spec/services/cloud}/namespaces/members/invite_service_spec.rb (81%) rename extensions/{ee => cloud}/spec/services/namespaces/licenses/create_service_spec.rb (75%) rename extensions/{ee => cloud}/spec/services/namespaces/licenses/delete_service_spec.rb (66%) delete mode 100644 extensions/ee/app/graphql/ee/types/namespace_type.rb delete mode 100644 extensions/ee/app/graphql/mutations/namespaces/licenses/delete.rb rename extensions/ee/app/graphql/types/{namespace_license_type.rb => license_type.rb} (55%) rename extensions/ee/app/models/{namespace_license.rb => license.rb} (66%) delete mode 100644 extensions/ee/app/policies/ee/namespace_policy.rb create mode 100644 extensions/ee/app/policies/license_policy.rb delete mode 100644 extensions/ee/app/policies/namespace_license_policy.rb create mode 100644 extensions/ee/app/services/ee/users/create_service.rb create mode 100644 extensions/ee/app/services/ee/users/identity/register_service.rb create mode 100644 extensions/ee/app/services/ee/users/register_service.rb create mode 100644 extensions/ee/app/services/ee/users/validate_user_limit.rb rename extensions/ee/spec/factories/{namespace_licenses.rb => licenses.rb} (91%) create mode 100644 extensions/ee/spec/graphql/types/license_type_spec.rb delete mode 100644 extensions/ee/spec/models/ee/namespace_spec.rb create mode 100644 extensions/ee/spec/models/license_spec.rb delete mode 100644 extensions/ee/spec/models/namespace_license_spec.rb create mode 100644 extensions/ee/spec/services/ee/users/create_service_spec.rb create mode 100644 extensions/ee/spec/services/ee/users/identity/register_service_spec.rb create mode 100644 extensions/ee/spec/services/ee/users/register_service_spec.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3f0fecf0..769e71b9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -59,10 +59,12 @@ rspec: - EDITION: - ce - ee + - cloud variables: FPROF: '1' script: - - '[[ "$EDITION" = "ee" ]] || rm -rf extensions/ee/' + - '[[ "$EDITION" = "ce" ]] && rm -rf extensions/{ee,cloud}/' + - '[[ "$EDITION" = "ee" ]] && rm -rf extensions/cloud' - bundle exec rspec --format doc --format RspecJunitFormatter --out rspec.xml | tee output || exit_code=$? - | echo -e "\e[0Ksection_start:`date +%s`:glpa_summary\r\e[0KHeader of the summary" diff --git a/.rubocop.yml b/.rubocop.yml index e3cc3071..d70e3287 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -66,6 +66,9 @@ RSpec/DescribeClass: - extensions/ee/spec/graphql/**/* - extensions/ee/spec/requests/graphql/**/* - extensions/ee/spec/requests/grpc/**/* + - extensions/cloud/spec/graphql/**/* + - extensions/cloud/spec/requests/graphql/**/* + - extensions/cloud/spec/requests/grpc/**/* RSpec/ExampleLength: Enabled: false diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 72a98628..332d468a 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -15,9 +15,9 @@ class AuditEvent < ApplicationRecord namespace_member_deleted: 11, organization_deleted: 12, namespace_role_deleted: 13, - namespace_license_created: 14, # EE-specific + license_created: 14, # EE-specific namespace_project_created: 15, - namespace_license_deleted: 16, # EE-specific + license_deleted: 16, # EE-specific namespace_project_deleted: 17, namespace_project_updated: 18, runtime_created: 19, diff --git a/app/models/namespace_role_ability.rb b/app/models/namespace_role_ability.rb index 49a6fef2..a32937bd 100644 --- a/app/models/namespace_role_ability.rb +++ b/app/models/namespace_role_ability.rb @@ -12,11 +12,11 @@ class NamespaceRoleAbility < ApplicationRecord delete_organization: { db: 8, description: 'Allows to delete the organization' }, delete_namespace_role: { db: 9, description: 'Allows the deletion of roles in a namespace' }, namespace_administrator: { db: 10, description: 'Allows to perform any action in the namespace' }, - create_namespace_license: { db: 11, description: 'Allows to create a license for the namespace' }, # EE-specific - read_namespace_license: { db: 12, description: 'Allows to read the license of the namespace' }, # EE-specific + create_license: { db: 11, description: 'Allows to create a license for the namespace' }, # Cloud-specific + read_license: { db: 12, description: 'Allows to read the license of the namespace' }, # Cloud-specific create_namespace_project: { db: 13, description: 'Allows to create a project in the namespace' }, read_namespace_project: { db: 14, description: 'Allows to read the project of the namespace' }, - delete_namespace_license: { db: 15, description: 'Allows to delete the license of the namespace' }, # EE-specific + delete_license: { db: 15, description: 'Allows to delete the license of the namespace' }, # Cloud-specific update_namespace_project: { db: 16, description: 'Allows to update the project of the namespace' }, delete_namespace_project: { db: 17, description: 'Allows to delete the project of the namespace' }, create_runtime: { db: 18, description: 'Allows to create a runtime globally or for the namespace' }, diff --git a/app/services/error_code.rb b/app/services/error_code.rb index 48db2f63..ca5af764 100644 --- a/app/services/error_code.rb +++ b/app/services/error_code.rb @@ -56,7 +56,6 @@ def self.error_codes invalid_flow_setting: { description: 'The flow setting is invalid because of active model errors' }, invalid_namespace_member: { description: 'The namespace member is invalid because of active model errors' }, invalid_attachment: { description: 'The attachment is invalid because of active model errors' }, - invalid_namespace_license: { description: 'The namespace license is invalid because of active model errors' }, invalid_flow: { description: 'The flow is invalid because of active model errors' }, project_not_found: { description: 'The namespace project with the given identifier was not found' }, runtime_not_found: { description: 'The runtime with the given identifier was not found' }, @@ -69,7 +68,6 @@ def self.error_codes user_session_not_found: { description: 'The user session with the given identifier was not found' }, namespace_project_not_found: { description: 'The namespace project with the given identifier was not found' }, namespace_member_not_found: { description: 'The namespace member with the given identifier was not found' }, - license_not_found: { description: 'The namespace license with the given identifier was not found' }, flow_type_not_found: { description: 'The flow type with the given identifier was not found' }, organization_not_found: { description: 'The organization with the given identifier was not found' }, invalid_function_id: { description: 'The function ID is invalid' }, diff --git a/app/services/namespaces/members/invite_service.rb b/app/services/namespaces/members/invite_service.rb index af6be59d..ca83c55c 100644 --- a/app/services/namespaces/members/invite_service.rb +++ b/app/services/namespaces/members/invite_service.rb @@ -44,7 +44,7 @@ def execute protected def validate_user_limit!(*) - # overridden in EE + # overridden in CLOUD end end end diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index fc1b38b5..cb4b5def 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -16,13 +16,15 @@ def execute return ServiceResponse.error(message: 'Missing permissions', error_code: :missing_permission) end - transactional do + transactional do |t| user = User.create(**params) unless user.persisted? return ServiceResponse.error(message: 'User is invalid', error_code: :invalid_user, details: user.errors) end + validate_user_limit!(t) + AuditService.audit( :user_created, author_id: current_authentication.user.id, @@ -34,5 +36,13 @@ def execute ServiceResponse.success(payload: user) end end + + protected + + def validate_user_limit!(*) + # overridden in EE + end end end + +Users::CreateService.prepend_extensions diff --git a/app/services/users/identity/register_service.rb b/app/services/users/identity/register_service.rb index c6b592cb..271cf80a 100644 --- a/app/services/users/identity/register_service.rb +++ b/app/services/users/identity/register_service.rb @@ -50,6 +50,8 @@ def execute user.ensure_namespace return ServiceResponse.error(error_code: :invalid_user, details: user.errors) unless user.persisted? + validate_user_limit!(t) + user_identity = UserIdentity.create(user: user, provider_id: provider_id, identifier: identifier) unless user_identity.persisted? t.rollback_and_return! ServiceResponse.error(error_code: :invalid_user_identity, @@ -75,6 +77,14 @@ def execute ServiceResponse.success(payload: user_session) end end + + protected + + def validate_user_limit!(*) + # overridden in EE + end end end end + +Users::Identity::RegisterService.prepend_extensions diff --git a/app/services/users/register_service.rb b/app/services/users/register_service.rb index 8f029623..617bca16 100644 --- a/app/services/users/register_service.rb +++ b/app/services/users/register_service.rb @@ -25,6 +25,8 @@ def execute return ServiceResponse.error(message: 'User is invalid', error_code: :invalid_user, details: user.errors) end + validate_user_limit!(t) + user_session = UserSession.create(user: user) unless user_session.persisted? t.rollback_and_return! ServiceResponse.error(message: 'UserSession is invalid', @@ -55,5 +57,13 @@ def execute ServiceResponse.success(payload: user_session) end end + + protected + + def validate_user_limit!(*) + # overridden in EE + end end end + +Users::RegisterService.prepend_extensions diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 69c965a5..481ab9ab 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -19,4 +19,5 @@ ActiveSupport::Inflector.inflections do |inflect| inflect.acronym 'EE' + inflect.acronym 'CLOUD' end diff --git a/db/migrate/20260427172623_change_license_to_global_object.rb b/db/migrate/20260427172623_change_license_to_global_object.rb new file mode 100644 index 00000000..853f4382 --- /dev/null +++ b/db/migrate/20260427172623_change_license_to_global_object.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ChangeLicenseToGlobalObject < Code0::ZeroTrack::Database::Migration[1.0] + def change + rename_table :namespace_licenses, :licenses + change_column_null :licenses, :namespace_id, true + end +end diff --git a/db/schema_migrations/20260427172623 b/db/schema_migrations/20260427172623 new file mode 100644 index 00000000..20db221f --- /dev/null +++ b/db/schema_migrations/20260427172623 @@ -0,0 +1 @@ +d331bc990e7fbe15476ae12a6be85dd89d0b1a8495ca49cf6ad817b06704a18f \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 40475aed..19ffd36f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -394,22 +394,22 @@ CREATE TABLE good_jobs ( locked_at timestamp with time zone ); -CREATE TABLE namespace_licenses ( +CREATE TABLE licenses ( id bigint NOT NULL, data text NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - namespace_id bigint NOT NULL + namespace_id bigint ); -CREATE SEQUENCE namespace_licenses_id_seq +CREATE SEQUENCE licenses_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; -ALTER SEQUENCE namespace_licenses_id_seq OWNED BY namespace_licenses.id; +ALTER SEQUENCE licenses_id_seq OWNED BY licenses.id; CREATE TABLE namespace_member_roles ( id bigint NOT NULL, @@ -923,7 +923,7 @@ ALTER TABLE ONLY flows ALTER COLUMN id SET DEFAULT nextval('flows_id_seq'::regcl ALTER TABLE ONLY function_definitions ALTER COLUMN id SET DEFAULT nextval('function_definitions_id_seq'::regclass); -ALTER TABLE ONLY namespace_licenses ALTER COLUMN id SET DEFAULT nextval('namespace_licenses_id_seq'::regclass); +ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass); ALTER TABLE ONLY namespace_member_roles ALTER COLUMN id SET DEFAULT nextval('namespace_member_roles_id_seq'::regclass); @@ -1041,8 +1041,8 @@ ALTER TABLE ONLY good_job_settings ALTER TABLE ONLY good_jobs ADD CONSTRAINT good_jobs_pkey PRIMARY KEY (id); -ALTER TABLE ONLY namespace_licenses - ADD CONSTRAINT namespace_licenses_pkey PRIMARY KEY (id); +ALTER TABLE ONLY licenses + ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); ALTER TABLE ONLY namespace_member_roles ADD CONSTRAINT namespace_member_roles_pkey PRIMARY KEY (id); @@ -1212,7 +1212,7 @@ CREATE INDEX index_good_jobs_on_queue_name_and_scheduled_at ON good_jobs USING b CREATE INDEX index_good_jobs_on_scheduled_at ON good_jobs USING btree (scheduled_at) WHERE (finished_at IS NULL); -CREATE INDEX index_namespace_licenses_on_namespace_id ON namespace_licenses USING btree (namespace_id); +CREATE INDEX index_licenses_on_namespace_id ON licenses USING btree (namespace_id); CREATE INDEX index_namespace_member_roles_on_member_id ON namespace_member_roles USING btree (member_id); @@ -1307,7 +1307,7 @@ ALTER TABLE ONLY node_parameters ALTER TABLE ONLY flow_type_data_type_links ADD CONSTRAINT fk_rails_38698de52d FOREIGN KEY (referenced_data_type_id) REFERENCES data_types(id) ON DELETE RESTRICT; -ALTER TABLE ONLY namespace_licenses +ALTER TABLE ONLY licenses ADD CONSTRAINT fk_rails_38f693332d FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; ALTER TABLE ONLY runtime_features diff --git a/docs/graphql/enum/errorcodeenum.md b/docs/graphql/enum/errorcodeenum.md index 675b3578..7f1f8c58 100644 --- a/docs/graphql/enum/errorcodeenum.md +++ b/docs/graphql/enum/errorcodeenum.md @@ -32,8 +32,8 @@ Represents the available error responses | `INVALID_FLOW_SETTING` | The flow setting is invalid because of active model errors | | `INVALID_FLOW_TYPE` | The flow type is invalid because of active model errors | | `INVALID_FUNCTION_ID` | The function ID is invalid | +| `INVALID_LICENSE` | The license is invalid because of active model errors | | `INVALID_LOGIN_DATA` | Invalid login data provided | -| `INVALID_NAMESPACE_LICENSE` | The namespace license is invalid because of active model errors | | `INVALID_NAMESPACE_MEMBER` | The namespace member is invalid because of active model errors | | `INVALID_NAMESPACE_PROJECT` | The namespace project is invalid because of active model errors | | `INVALID_NAMESPACE_ROLE` | The namespace role is invalid because of active model errors | @@ -56,7 +56,7 @@ Represents the available error responses | `INVALID_USER_SESSION` | The user session is invalid because of active model errors | | `INVALID_VERIFICATION_CODE` | Invalid verification code provided | | `IS_PRIMARY_RUNTIME` | This runtime is the primary runtime of a project | -| `LICENSE_NOT_FOUND` | The namespace license with the given identifier was not found | +| `LICENSE_NOT_FOUND` | The license with the given identifier was not found | | `LOADING_IDENTITY_FAILED` | Failed to load user identity from external provider | | `MFA_FAILED` | Invalid MFA data provided | | `MFA_REQUIRED` | MFA is required | diff --git a/docs/graphql/enum/namespaceroleability.md b/docs/graphql/enum/namespaceroleability.md index 00dc015f..0cd23ff4 100644 --- a/docs/graphql/enum/namespaceroleability.md +++ b/docs/graphql/enum/namespaceroleability.md @@ -11,20 +11,20 @@ Represents abilities that can be granted to roles in namespaces. | `ASSIGN_ROLE_ABILITIES` | Allows to change the abilities of a namespace role | | `ASSIGN_ROLE_PROJECTS` | Allows to change the assigned projects of a namespace role | | `CREATE_FLOW` | Allows to create flows in a namespace project | -| `CREATE_NAMESPACE_LICENSE` | Allows to create a license for the namespace | +| `CREATE_LICENSE` | Allows to create a license for the namespace | | `CREATE_NAMESPACE_PROJECT` | Allows to create a project in the namespace | | `CREATE_NAMESPACE_ROLE` | Allows the creation of roles in a namespace | | `CREATE_RUNTIME` | Allows to create a runtime globally or for the namespace | | `DELETE_FLOW` | Allows to delete flows in a namespace project | +| `DELETE_LICENSE` | Allows to delete the license of the namespace | | `DELETE_MEMBER` | Allows to remove members of a namespace | -| `DELETE_NAMESPACE_LICENSE` | Allows to delete the license of the namespace | | `DELETE_NAMESPACE_PROJECT` | Allows to delete the project of the namespace | | `DELETE_NAMESPACE_ROLE` | Allows the deletion of roles in a namespace | | `DELETE_ORGANIZATION` | Allows to delete the organization | | `DELETE_RUNTIME` | Allows to delete a runtime | | `INVITE_MEMBER` | Allows to invite new members to a namespace | | `NAMESPACE_ADMINISTRATOR` | Allows to perform any action in the namespace | -| `READ_NAMESPACE_LICENSE` | Allows to read the license of the namespace | +| `READ_LICENSE` | Allows to read the license of the namespace | | `READ_NAMESPACE_PROJECT` | Allows to read the project of the namespace | | `ROTATE_RUNTIME_TOKEN` | Allows to regenerate a runtime token | | `UPDATE_FLOW` | Allows to update flows in the project | diff --git a/docs/graphql/mutation/namespaceslicensescreate.md b/docs/graphql/mutation/namespaceslicensescreate.md index 980faf89..81a6be5d 100644 --- a/docs/graphql/mutation/namespaceslicensescreate.md +++ b/docs/graphql/mutation/namespaceslicensescreate.md @@ -2,7 +2,7 @@ title: namespacesLicensesCreate --- -(EE only) Create a new namespace license. +(Cloud only) Create a new namespace license. ## Arguments @@ -18,4 +18,4 @@ title: namespacesLicensesCreate |------|------|-------------| | `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | | `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. | -| `namespaceLicense` | [`NamespaceLicense`](../object/namespacelicense.md) | The newly created license. | +| `license` | [`License`](../object/license.md) | The newly created license. | diff --git a/docs/graphql/mutation/namespaceslicensesdelete.md b/docs/graphql/mutation/namespaceslicensesdelete.md index dfd85733..afc7a67c 100644 --- a/docs/graphql/mutation/namespaceslicensesdelete.md +++ b/docs/graphql/mutation/namespaceslicensesdelete.md @@ -2,14 +2,14 @@ title: namespacesLicensesDelete --- -(EE only) Deletes an namespace license. +(Cloud only) Deletes an namespace license. ## Arguments | Name | Type | Description | |------|------|-------------| | `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | -| `namespaceLicenseId` | [`NamespaceLicenseID!`](../scalar/namespacelicenseid.md) | The license id to delete. | +| `licenseId` | [`LicenseID!`](../scalar/licenseid.md) | The license id to delete. | ## Fields @@ -17,4 +17,4 @@ title: namespacesLicensesDelete |------|------|-------------| | `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | | `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. | -| `namespaceLicense` | [`NamespaceLicense`](../object/namespacelicense.md) | The deleted namespace license. | +| `license` | [`License`](../object/license.md) | The deleted license. | diff --git a/docs/graphql/object/license.md b/docs/graphql/object/license.md new file mode 100644 index 00000000..4f6e4bbc --- /dev/null +++ b/docs/graphql/object/license.md @@ -0,0 +1,18 @@ +--- +title: License +--- + +(EE only) Represents a License + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `createdAt` | [`Time!`](../scalar/time.md) | Time when this License was created | +| `endDate` | [`Time`](../scalar/time.md) | The end date of the license | +| `id` | [`LicenseID!`](../scalar/licenseid.md) | Global ID of this License | +| `licensee` | [`JSON!`](../scalar/json.md) | The licensee information | +| `namespace` | [`Namespace!`](../object/namespace.md) | (Cloud only) The namespace the license belongs to | +| `startDate` | [`Time!`](../scalar/time.md) | The start date of the license | +| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this License was last updated | +| `userAbilities` | [`LicenseUserAbilities!`](../object/licenseuserabilities.md) | Abilities for the current user on this License | diff --git a/docs/graphql/object/namespacelicenseconnection.md b/docs/graphql/object/licenseconnection.md similarity index 50% rename from docs/graphql/object/namespacelicenseconnection.md rename to docs/graphql/object/licenseconnection.md index 28a2e258..099d3693 100644 --- a/docs/graphql/object/namespacelicenseconnection.md +++ b/docs/graphql/object/licenseconnection.md @@ -1,14 +1,14 @@ --- -title: NamespaceLicenseConnection +title: LicenseConnection --- -The connection type for NamespaceLicense. +The connection type for License. ## Fields without arguments | Name | Type | Description | |------|------|-------------| | `count` | [`Int!`](../scalar/int.md) | Total count of collection. | -| `edges` | [`[NamespaceLicenseEdge]`](../object/namespacelicenseedge.md) | A list of edges. | -| `nodes` | [`[NamespaceLicense]`](../object/namespacelicense.md) | A list of nodes. | +| `edges` | [`[LicenseEdge]`](../object/licenseedge.md) | A list of edges. | +| `nodes` | [`[License]`](../object/license.md) | A list of nodes. | | `pageInfo` | [`PageInfo!`](../object/pageinfo.md) | Information to aid in pagination. | diff --git a/docs/graphql/object/namespacelicenseedge.md b/docs/graphql/object/licenseedge.md similarity index 61% rename from docs/graphql/object/namespacelicenseedge.md rename to docs/graphql/object/licenseedge.md index 89596331..589d76f8 100644 --- a/docs/graphql/object/namespacelicenseedge.md +++ b/docs/graphql/object/licenseedge.md @@ -1,5 +1,5 @@ --- -title: NamespaceLicenseEdge +title: LicenseEdge --- An edge in a connection. @@ -9,4 +9,4 @@ An edge in a connection. | Name | Type | Description | |------|------|-------------| | `cursor` | [`String!`](../scalar/string.md) | A cursor for use in pagination. | -| `node` | [`NamespaceLicense`](../object/namespacelicense.md) | The item at the end of the edge. | +| `node` | [`License`](../object/license.md) | The item at the end of the edge. | diff --git a/docs/graphql/object/licenseuserabilities.md b/docs/graphql/object/licenseuserabilities.md new file mode 100644 index 00000000..bb001be9 --- /dev/null +++ b/docs/graphql/object/licenseuserabilities.md @@ -0,0 +1,11 @@ +--- +title: LicenseUserAbilities +--- + +Abilities for the current user on this License + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `deleteLicense` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `delete_license` ability on this License | diff --git a/docs/graphql/object/namespace.md b/docs/graphql/object/namespace.md index 4a6c8f51..be4540f1 100644 --- a/docs/graphql/object/namespace.md +++ b/docs/graphql/object/namespace.md @@ -9,10 +9,10 @@ Represents a Namespace | Name | Type | Description | |------|------|-------------| | `createdAt` | [`Time!`](../scalar/time.md) | Time when this Namespace was created | -| `currentNamespaceLicense` | [`NamespaceLicense`](../object/namespacelicense.md) | (EE only) Currently active license of the namespace | +| `currentLicense` | [`License`](../object/license.md) | (Cloud only) Currently active license of the namespace | | `id` | [`NamespaceID!`](../scalar/namespaceid.md) | Global ID of this Namespace | +| `licenses` | [`LicenseConnection!`](../object/licenseconnection.md) | (Cloud only) Licenses of the namespace | | `members` | [`NamespaceMemberConnection!`](../object/namespacememberconnection.md) | Members of the namespace | -| `namespaceLicenses` | [`NamespaceLicenseConnection!`](../object/namespacelicenseconnection.md) | (EE only) Licenses of the namespace | | `parent` | [`NamespaceParent!`](../union/namespaceparent.md) | Parent of this namespace | | `projects` | [`NamespaceProjectConnection!`](../object/namespaceprojectconnection.md) | Projects of the namespace | | `roles` | [`NamespaceRoleConnection!`](../object/namespaceroleconnection.md) | Roles of the namespace | diff --git a/docs/graphql/object/namespacelicense.md b/docs/graphql/object/namespacelicense.md deleted file mode 100644 index c58f753a..00000000 --- a/docs/graphql/object/namespacelicense.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: NamespaceLicense ---- - -(EE only) Represents a Namespace License - -## Fields without arguments - -| Name | Type | Description | -|------|------|-------------| -| `createdAt` | [`Time!`](../scalar/time.md) | Time when this NamespaceLicense was created | -| `endDate` | [`Time`](../scalar/time.md) | The end date of the license | -| `id` | [`NamespaceLicenseID!`](../scalar/namespacelicenseid.md) | Global ID of this NamespaceLicense | -| `licensee` | [`JSON!`](../scalar/json.md) | The licensee information | -| `namespace` | [`Namespace!`](../object/namespace.md) | The namespace the license belongs to | -| `startDate` | [`Time!`](../scalar/time.md) | The start date of the license | -| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this NamespaceLicense was last updated | -| `userAbilities` | [`NamespaceLicenseUserAbilities!`](../object/namespacelicenseuserabilities.md) | Abilities for the current user on this NamespaceLicense | diff --git a/docs/graphql/object/namespacelicenseuserabilities.md b/docs/graphql/object/namespacelicenseuserabilities.md deleted file mode 100644 index 020c7061..00000000 --- a/docs/graphql/object/namespacelicenseuserabilities.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: NamespaceLicenseUserAbilities ---- - -Abilities for the current user on this NamespaceLicense - -## Fields without arguments - -| Name | Type | Description | -|------|------|-------------| -| `deleteNamespaceLicense` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `delete_namespace_license` ability on this NamespaceLicense | diff --git a/docs/graphql/object/namespaceuserabilities.md b/docs/graphql/object/namespaceuserabilities.md index 6fe4db53..720765e2 100644 --- a/docs/graphql/object/namespaceuserabilities.md +++ b/docs/graphql/object/namespaceuserabilities.md @@ -8,7 +8,7 @@ Abilities for the current user on this Namespace | Name | Type | Description | |------|------|-------------| -| `createNamespaceLicense` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `create_namespace_license` ability on this Namespace | +| `createLicense` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `create_license` ability on this Namespace | | `createNamespaceProject` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `create_namespace_project` ability on this Namespace | | `createNamespaceRole` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `create_namespace_role` ability on this Namespace | | `createRuntime` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `create_runtime` ability on this Namespace | diff --git a/docs/graphql/scalar/licenseid.md b/docs/graphql/scalar/licenseid.md new file mode 100644 index 00000000..b93c4365 --- /dev/null +++ b/docs/graphql/scalar/licenseid.md @@ -0,0 +1,5 @@ +--- +title: LicenseID +--- + +A unique identifier for all License entities of the application diff --git a/docs/graphql/scalar/namespacelicenseid.md b/docs/graphql/scalar/namespacelicenseid.md deleted file mode 100644 index 51c9ff6f..00000000 --- a/docs/graphql/scalar/namespacelicenseid.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: NamespaceLicenseID ---- - -A unique identifier for all NamespaceLicense entities of the application diff --git a/extensions/cloud/app/graphql/cloud/types/license_type.rb b/extensions/cloud/app/graphql/cloud/types/license_type.rb new file mode 100644 index 00000000..bce77f82 --- /dev/null +++ b/extensions/cloud/app/graphql/cloud/types/license_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module CLOUD + module Types + module LicenseType + extend ActiveSupport::Concern + + prepended do + field :namespace, ::Types::NamespaceType, + null: false, + description: '(Cloud only) The namespace the license belongs to' + end + end + end +end diff --git a/extensions/ee/app/graphql/ee/types/mutation_type.rb b/extensions/cloud/app/graphql/cloud/types/mutation_type.rb similarity index 95% rename from extensions/ee/app/graphql/ee/types/mutation_type.rb rename to extensions/cloud/app/graphql/cloud/types/mutation_type.rb index f5c72b7c..6342918e 100644 --- a/extensions/ee/app/graphql/ee/types/mutation_type.rb +++ b/extensions/cloud/app/graphql/cloud/types/mutation_type.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module EE +module CLOUD module Types module MutationType extend ActiveSupport::Concern diff --git a/extensions/cloud/app/graphql/cloud/types/namespace_type.rb b/extensions/cloud/app/graphql/cloud/types/namespace_type.rb new file mode 100644 index 00000000..ce4dca40 --- /dev/null +++ b/extensions/cloud/app/graphql/cloud/types/namespace_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CLOUD + module Types + module NamespaceType + extend ActiveSupport::Concern + + prepended do + field :licenses, ::Types::LicenseType.connection_type, + null: false, + description: '(Cloud only) Licenses of the namespace' + + field :current_license, ::Types::LicenseType, + null: true, + description: '(Cloud only) Currently active license of the namespace' + + expose_abilities %i[ + create_license + ] + end + end + end +end diff --git a/extensions/ee/app/graphql/mutations/namespaces/licenses/create.rb b/extensions/cloud/app/graphql/mutations/namespaces/licenses/create.rb similarity index 73% rename from extensions/ee/app/graphql/mutations/namespaces/licenses/create.rb rename to extensions/cloud/app/graphql/mutations/namespaces/licenses/create.rb index f973130b..cd1e00b1 100644 --- a/extensions/ee/app/graphql/mutations/namespaces/licenses/create.rb +++ b/extensions/cloud/app/graphql/mutations/namespaces/licenses/create.rb @@ -4,9 +4,9 @@ module Mutations module Namespaces module Licenses class Create < BaseMutation - description '(EE only) Create a new namespace license.' + description '(Cloud only) Create a new namespace license.' - field :namespace_license, Types::NamespaceLicenseType, null: true, description: 'The newly created license.' + field :license, Types::LicenseType, null: true, description: 'The newly created license.' argument :data, String, required: true, description: 'The license data.' argument :namespace_id, ::Types::GlobalIdType[::Namespace], required: true, @@ -16,7 +16,7 @@ def resolve(namespace_id:, data:) namespace = SagittariusSchema.object_from_id(namespace_id) if namespace.nil? - return { namespace_license: nil, + return { license: nil, errors: [create_error(:namespace_not_found, 'Invalid namespace')] } end @@ -24,7 +24,7 @@ def resolve(namespace_id:, data:) current_authentication, namespace: namespace, data: data - ).execute.to_mutation_response(success_key: :namespace_license) + ).execute.to_mutation_response(success_key: :license) end end end diff --git a/extensions/cloud/app/graphql/mutations/namespaces/licenses/delete.rb b/extensions/cloud/app/graphql/mutations/namespaces/licenses/delete.rb new file mode 100644 index 00000000..574f8a3c --- /dev/null +++ b/extensions/cloud/app/graphql/mutations/namespaces/licenses/delete.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Namespaces + module Licenses + class Delete < BaseMutation + description '(Cloud only) Deletes an namespace license.' + + field :license, Types::LicenseType, + null: true, + description: 'The deleted license.' + + argument :license_id, ::Types::GlobalIdType[::License], + required: true, + description: 'The license id to delete.' + + def resolve(license_id:) + license = SagittariusSchema.object_from_id(license_id) + + if license.nil? + return { license: nil, + errors: [create_error(:license_not_found, 'Invalid license')] } + end + + ::Namespaces::Licenses::DeleteService.new( + current_authentication, + license: license + ).execute.to_mutation_response(success_key: :license) + end + end + end + end +end diff --git a/extensions/cloud/app/models/cloud/license.rb b/extensions/cloud/app/models/cloud/license.rb new file mode 100644 index 00000000..a98303e3 --- /dev/null +++ b/extensions/cloud/app/models/cloud/license.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module CLOUD + module License + extend ActiveSupport::Concern + + prepended do + belongs_to :namespace, inverse_of: :licenses + + scope :for_namespace, ->(namespace) { where(namespace: namespace) } + + class << self + include Code0::ZeroTrack::Memoize + + def current_for_namespace(namespace) + memoize(:current_for_namespace, reset_on_change: -> { namespace.id }) do + load_license_for_namespace(namespace) + end + end + + def load_license_for_namespace(namespace) + for_namespace(namespace).last_fifty.find do |namespace_license| + namespace_license.license.in_active_time? + end + end + end + end + end +end diff --git a/extensions/ee/app/models/ee/namespace.rb b/extensions/cloud/app/models/cloud/namespace.rb similarity index 59% rename from extensions/ee/app/models/ee/namespace.rb rename to extensions/cloud/app/models/cloud/namespace.rb index a092b5cf..1249841d 100644 --- a/extensions/ee/app/models/ee/namespace.rb +++ b/extensions/cloud/app/models/cloud/namespace.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -module EE +module CLOUD module Namespace extend ActiveSupport::Concern prepended do - has_many :namespace_licenses, inverse_of: :namespace + has_many :licenses, inverse_of: :namespace end def current_license - NamespaceLicense.current(self) + ::License.current_for_namespace(self) end end end diff --git a/extensions/cloud/app/policies/cloud/license_policy.rb b/extensions/cloud/app/policies/cloud/license_policy.rb new file mode 100644 index 00000000..2e9c0ca9 --- /dev/null +++ b/extensions/cloud/app/policies/cloud/license_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module CLOUD + module LicensePolicy + extend ActiveSupport::Concern + + prepended do + delegate { subject.namespace } + end + end +end diff --git a/extensions/cloud/app/policies/cloud/namespace_policy.rb b/extensions/cloud/app/policies/cloud/namespace_policy.rb new file mode 100644 index 00000000..881c30c2 --- /dev/null +++ b/extensions/cloud/app/policies/cloud/namespace_policy.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module CLOUD + module NamespacePolicy + extend ActiveSupport::Concern + + prepended do + customizable_permission :read_license + customizable_permission :create_license + customizable_permission :delete_license + end + end +end diff --git a/extensions/ee/app/services/ee/namespaces/members/invite_service.rb b/extensions/cloud/app/services/cloud/namespaces/members/invite_service.rb similarity index 98% rename from extensions/ee/app/services/ee/namespaces/members/invite_service.rb rename to extensions/cloud/app/services/cloud/namespaces/members/invite_service.rb index 4301564a..73e8eda5 100644 --- a/extensions/ee/app/services/ee/namespaces/members/invite_service.rb +++ b/extensions/cloud/app/services/cloud/namespaces/members/invite_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module EE +module CLOUD module Namespaces module Members module InviteService diff --git a/extensions/ee/app/services/namespaces/licenses/create_service.rb b/extensions/cloud/app/services/namespaces/licenses/create_service.rb similarity index 87% rename from extensions/ee/app/services/namespaces/licenses/create_service.rb rename to extensions/cloud/app/services/namespaces/licenses/create_service.rb index 9d792f89..a4ee38fd 100644 --- a/extensions/ee/app/services/namespaces/licenses/create_service.rb +++ b/extensions/cloud/app/services/namespaces/licenses/create_service.rb @@ -14,16 +14,16 @@ def initialize(current_authentication, namespace:, data:) end def execute - unless Ability.allowed?(current_authentication, :create_namespace_license, namespace) + unless Ability.allowed?(current_authentication, :create_license, namespace) return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) end transactional do |t| - namespace_license = NamespaceLicense.create(data: data, namespace: namespace) + namespace_license = License.create(data: data, namespace: namespace) unless namespace_license.persisted? t.rollback_and_return! ServiceResponse.error( message: 'Failed to create namespace license', - error_code: :invalid_namespace_license, + error_code: :invalid_license, details: namespace_license.errors ) end @@ -31,7 +31,7 @@ def execute license_data = namespace_license.license AuditService.audit( - :namespace_license_created, + :license_created, author_id: current_authentication.user.id, entity: namespace_license, target: namespace, diff --git a/extensions/ee/app/services/namespaces/licenses/delete_service.rb b/extensions/cloud/app/services/namespaces/licenses/delete_service.rb similarity index 54% rename from extensions/ee/app/services/namespaces/licenses/delete_service.rb rename to extensions/cloud/app/services/namespaces/licenses/delete_service.rb index f5e8a395..db6d357f 100644 --- a/extensions/ee/app/services/namespaces/licenses/delete_service.rb +++ b/extensions/cloud/app/services/namespaces/licenses/delete_service.rb @@ -5,37 +5,37 @@ module Licenses class DeleteService include Sagittarius::Database::Transactional - attr_reader :current_authentication, :namespace_license + attr_reader :current_authentication, :license - def initialize(current_authentication, namespace_license:) + def initialize(current_authentication, license:) @current_authentication = current_authentication - @namespace_license = namespace_license + @license = license end def execute - unless Ability.allowed?(current_authentication, :delete_namespace_license, namespace_license) + unless Ability.allowed?(current_authentication, :delete_license, license) return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) end transactional do |t| - namespace_license.delete - if namespace_license.persisted? + license.delete + if license.persisted? t.rollback_and_return! ServiceResponse.error( message: 'Failed to delete namespace license', - error_code: :invalid_namespace_license, - details: namespace_license.errors + error_code: :invalid_license, + details: license.errors ) end AuditService.audit( - :namespace_license_deleted, + :license_deleted, author_id: current_authentication.user.id, - entity: namespace_license, + entity: license, details: {}, - target: namespace_license.namespace + target: license.namespace ) - ServiceResponse.success(message: 'Deleted namespace license', payload: namespace_license) + ServiceResponse.success(message: 'Deleted license', payload: license) end end end diff --git a/extensions/cloud/spec/factories/licenses.rb b/extensions/cloud/spec/factories/licenses.rb new file mode 100644 index 00000000..465a1a70 --- /dev/null +++ b/extensions/cloud/spec/factories/licenses.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.modify do + factory :license do + namespace + end +end diff --git a/extensions/ee/spec/graphql/mutations/namespaces/licenses/create_spec.rb b/extensions/cloud/spec/graphql/mutations/namespaces/licenses/create_spec.rb similarity index 100% rename from extensions/ee/spec/graphql/mutations/namespaces/licenses/create_spec.rb rename to extensions/cloud/spec/graphql/mutations/namespaces/licenses/create_spec.rb diff --git a/extensions/ee/spec/graphql/types/namespace_license_type_spec.rb b/extensions/cloud/spec/graphql/types/cloud/types/license_type_spec.rb similarity index 68% rename from extensions/ee/spec/graphql/types/namespace_license_type_spec.rb rename to extensions/cloud/spec/graphql/types/cloud/types/license_type_spec.rb index d466adbb..78385062 100644 --- a/extensions/ee/spec/graphql/types/namespace_license_type_spec.rb +++ b/extensions/cloud/spec/graphql/types/cloud/types/license_type_spec.rb @@ -2,11 +2,11 @@ require 'rails_helper' -RSpec.describe SagittariusSchema.types['NamespaceLicense'] do +RSpec.describe SagittariusSchema.types['License'] do let(:fields) do %w[ - namespace id + namespace startDate endDate licensee @@ -16,7 +16,7 @@ ] end - it { expect(described_class.graphql_name).to eq('NamespaceLicense') } + it { expect(described_class.graphql_name).to eq('License') } it { expect(described_class).to have_graphql_fields(fields) } - it { expect(described_class).to require_graphql_authorizations(:read_namespace_license) } + it { expect(described_class).to require_graphql_authorizations(:read_license) } end diff --git a/extensions/ee/spec/graphql/types/ee/types/mutation_type_spec.rb b/extensions/cloud/spec/graphql/types/cloud/types/mutation_type_spec.rb similarity index 58% rename from extensions/ee/spec/graphql/types/ee/types/mutation_type_spec.rb rename to extensions/cloud/spec/graphql/types/cloud/types/mutation_type_spec.rb index 6b02d947..735a4bbd 100644 --- a/extensions/ee/spec/graphql/types/ee/types/mutation_type_spec.rb +++ b/extensions/cloud/spec/graphql/types/cloud/types/mutation_type_spec.rb @@ -3,5 +3,5 @@ require 'rails_helper' RSpec.describe SagittariusSchema.types['Mutation'] do - it { expect(described_class).to include_module(EE::Types::MutationType) } + it { expect(described_class).to include_module(CLOUD::Types::MutationType) } end diff --git a/extensions/ee/spec/graphql/types/ee/types/namespace_type_spec.rb b/extensions/cloud/spec/graphql/types/cloud/types/namespace_type_spec.rb similarity index 73% rename from extensions/ee/spec/graphql/types/ee/types/namespace_type_spec.rb rename to extensions/cloud/spec/graphql/types/cloud/types/namespace_type_spec.rb index 6e2b0114..09a22905 100644 --- a/extensions/ee/spec/graphql/types/ee/types/namespace_type_spec.rb +++ b/extensions/cloud/spec/graphql/types/cloud/types/namespace_type_spec.rb @@ -14,12 +14,12 @@ projects createdAt updatedAt - namespaceLicenses - currentNamespaceLicense + licenses + currentLicense userAbilities ] end - it { expect(described_class).to include_module(EE::Types::NamespaceType) } + it { expect(described_class).to include_module(CLOUD::Types::NamespaceType) } it { expect(described_class).to have_graphql_fields(fields) } end diff --git a/extensions/cloud/spec/models/cloud/license_spec.rb b/extensions/cloud/spec/models/cloud/license_spec.rb new file mode 100644 index 00000000..2cb0c41c --- /dev/null +++ b/extensions/cloud/spec/models/cloud/license_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe License do + it { is_expected.to include_module(CLOUD::License) } + + describe 'associations' do + it { is_expected.to belong_to(:namespace).required } + end + + describe '.current' do + let(:first_license) { create(:license) } + let(:second_license) { create(:license) } + + after do + described_class.clear_memoize(:current_for_namespace) + described_class.clear_memoize(:current_for_namespace_reset_on_change) + end + + it 'memoizes license' do + allow(described_class).to receive(:load_license_for_namespace) + + described_class.current_for_namespace(first_license.namespace) + described_class.current_for_namespace(first_license.namespace) # make a memoized call + + expect(described_class).to have_received(:load_license_for_namespace) + end + + it 'does not memoize license from wrong namespace' do + expect(described_class.current_for_namespace(first_license.namespace)).to eq(first_license) + expect(described_class.current_for_namespace(second_license.namespace)).to eq(second_license) + end + end + + describe '.load_license' do + let(:namespace) { create(:namespace) } + + it 'returns newest license' do + create(:license, namespace: namespace) + current_license = create(:license, namespace: namespace) + + expect(described_class.load_license_for_namespace(namespace)).to eq(current_license) + end + + it 'ignores future licenses' do + current_license = create(:license, namespace: namespace) + create( + :license, + namespace: namespace, + start_date: Time.zone.today + 2, + end_date: Time.zone.today + 3 + ) + + expect(described_class.load_license_for_namespace(namespace)).to eq(current_license) + end + end +end diff --git a/extensions/cloud/spec/models/cloud/namespace_spec.rb b/extensions/cloud/spec/models/cloud/namespace_spec.rb new file mode 100644 index 00000000..5da8797f --- /dev/null +++ b/extensions/cloud/spec/models/cloud/namespace_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Namespace do + it { is_expected.to include_module(CLOUD::Namespace) } + + describe 'associations' do + it { is_expected.to have_many(:licenses).inverse_of(:namespace) } + end +end diff --git a/extensions/cloud/spec/policies/cloud/license_policy_spec.rb b/extensions/cloud/spec/policies/cloud/license_policy_spec.rb new file mode 100644 index 00000000..82441116 --- /dev/null +++ b/extensions/cloud/spec/policies/cloud/license_policy_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LicensePolicy do + it { expect(described_class).to include_module(CLOUD::LicensePolicy) } +end diff --git a/extensions/ee/spec/policies/ee/namespace_policy_spec.rb b/extensions/cloud/spec/policies/cloud/namespace_policy_spec.rb similarity index 55% rename from extensions/ee/spec/policies/ee/namespace_policy_spec.rb rename to extensions/cloud/spec/policies/cloud/namespace_policy_spec.rb index 1707a700..04cc9106 100644 --- a/extensions/ee/spec/policies/ee/namespace_policy_spec.rb +++ b/extensions/cloud/spec/policies/cloud/namespace_policy_spec.rb @@ -3,5 +3,5 @@ require 'rails_helper' RSpec.describe NamespacePolicy do - it { expect(described_class).to include_module(EE::NamespacePolicy) } + it { expect(described_class).to include_module(CLOUD::NamespacePolicy) } end diff --git a/extensions/ee/spec/requests/graphql/mutations/namespaces/licenses/create_mutation_spec.rb b/extensions/cloud/spec/requests/graphql/mutations/namespaces/licenses/create_mutation_spec.rb similarity index 72% rename from extensions/ee/spec/requests/graphql/mutations/namespaces/licenses/create_mutation_spec.rb rename to extensions/cloud/spec/requests/graphql/mutations/namespaces/licenses/create_mutation_spec.rb index 23ffa8a1..28a963cb 100644 --- a/extensions/ee/spec/requests/graphql/mutations/namespaces/licenses/create_mutation_spec.rb +++ b/extensions/cloud/spec/requests/graphql/mutations/namespaces/licenses/create_mutation_spec.rb @@ -12,7 +12,7 @@ mutation($input: NamespacesLicensesCreateInput!) { namespacesLicensesCreate(input: $input) { #{error_query} - namespaceLicense { + license { id namespace { id @@ -25,7 +25,7 @@ let(:namespace) { create(:namespace) } let(:input) do - data = create(:namespace_license).data + data = create(:license).data { namespaceId: namespace.to_global_id.to_s, @@ -39,26 +39,26 @@ context 'when user is a member of the namespace' do before do create(:namespace_member, namespace: namespace, user: current_user) - stub_allowed_ability(NamespacePolicy, :create_namespace_license, user: current_user, subject: namespace) - stub_allowed_ability(NamespacePolicy, :read_namespace_license, user: current_user, subject: namespace) + stub_allowed_ability(NamespacePolicy, :create_license, user: current_user, subject: namespace) + stub_allowed_ability(NamespacePolicy, :read_license, user: current_user, subject: namespace) end it 'creates namespace license' do mutate! - expect(graphql_data_at(:namespaces_licenses_create, :namespace_license, :id)).to be_present + expect(graphql_data_at(:namespaces_licenses_create, :license, :id)).to be_present namespace_license = SagittariusSchema.object_from_id( - graphql_data_at(:namespaces_licenses_create, :namespace_license, :id) + graphql_data_at(:namespaces_licenses_create, :license, :id) ) expect(namespace_license.namespace).to eq(namespace) is_expected.to create_audit_event( - :namespace_license_created, + :license_created, author_id: current_user.id, entity_id: namespace_license.id, - entity_type: 'NamespaceLicense', + entity_type: 'License', target_id: namespace.id, target_type: 'Namespace' ) @@ -70,7 +70,7 @@ it 'returns an error' do mutate! - expect(graphql_data_at(:namespaces_licenses_create, :namespace_license)).to be_nil + expect(graphql_data_at(:namespaces_licenses_create, :license)).to be_nil expect( graphql_data_at(:namespaces_licenses_create, :errors, :details) ).to include([{ 'attribute' => 'data', 'type' => 'invalid' }]) @@ -82,7 +82,7 @@ it 'returns an error' do mutate! - expect(graphql_data_at(:namespaces_licenses_create, :namespace_license)).to be_nil + expect(graphql_data_at(:namespaces_licenses_create, :license)).to be_nil expect(graphql_data_at(:namespaces_licenses_create, :errors, :error_code)).to include('MISSING_PERMISSION') end end diff --git a/extensions/ee/spec/requests/graphql/mutations/namespaces/licenses/delete_mutation_spec.rb b/extensions/cloud/spec/requests/graphql/mutations/namespaces/licenses/delete_mutation_spec.rb similarity index 66% rename from extensions/ee/spec/requests/graphql/mutations/namespaces/licenses/delete_mutation_spec.rb rename to extensions/cloud/spec/requests/graphql/mutations/namespaces/licenses/delete_mutation_spec.rb index 8e443897..86afa032 100644 --- a/extensions/ee/spec/requests/graphql/mutations/namespaces/licenses/delete_mutation_spec.rb +++ b/extensions/cloud/spec/requests/graphql/mutations/namespaces/licenses/delete_mutation_spec.rb @@ -12,7 +12,7 @@ mutation($input: NamespacesLicensesDeleteInput!) { namespacesLicensesDelete(input: $input) { #{error_query} - namespaceLicense { + license { id namespace { id @@ -24,10 +24,10 @@ end let(:namespace) { create(:namespace) } - let(:license) { create(:namespace_license, namespace: namespace) } + let(:license) { create(:license, namespace: namespace) } let(:input) do { - namespaceLicenseId: license.to_global_id.to_s, + licenseId: license.to_global_id.to_s, } end @@ -37,26 +37,26 @@ context 'when user is a member of the namespace' do before do create(:namespace_member, namespace: namespace, user: current_user) - stub_allowed_ability(NamespacePolicy, :delete_namespace_license, user: current_user, subject: namespace) - stub_allowed_ability(NamespacePolicy, :read_namespace_license, user: current_user, subject: namespace) + stub_allowed_ability(NamespacePolicy, :delete_license, user: current_user, subject: namespace) + stub_allowed_ability(NamespacePolicy, :read_license, user: current_user, subject: namespace) end it 'deletes namespace license' do mutate! - expect(graphql_data_at(:namespaces_licenses_delete, :namespace_license, :id)).to be_present + expect(graphql_data_at(:namespaces_licenses_delete, :license, :id)).to be_present namespace_license = SagittariusSchema.object_from_id( - graphql_data_at(:namespaces_licenses_delete, :namespace_license, :id) + graphql_data_at(:namespaces_licenses_delete, :license, :id) ) expect(namespace_license).to be_nil is_expected.to create_audit_event( - :namespace_license_deleted, + :license_deleted, author_id: current_user.id, entity_id: license.id, - entity_type: 'NamespaceLicense', + entity_type: 'License', target_id: namespace.id, target_type: 'Namespace' ) @@ -67,7 +67,7 @@ it 'returns an error' do mutate! - expect(graphql_data_at(:namespaces_licenses_delete, :namespace_license)).to be_nil + expect(graphql_data_at(:namespaces_licenses_delete, :license)).to be_nil expect(graphql_data_at(:namespaces_licenses_delete, :errors, :error_code)).to include('MISSING_PERMISSION') end end diff --git a/extensions/ee/spec/services/ee/namespaces/members/invite_service_spec.rb b/extensions/cloud/spec/services/cloud/namespaces/members/invite_service_spec.rb similarity index 81% rename from extensions/ee/spec/services/ee/namespaces/members/invite_service_spec.rb rename to extensions/cloud/spec/services/cloud/namespaces/members/invite_service_spec.rb index 6cacd15b..2d25bcce 100644 --- a/extensions/ee/spec/services/ee/namespaces/members/invite_service_spec.rb +++ b/extensions/cloud/spec/services/cloud/namespaces/members/invite_service_spec.rb @@ -7,11 +7,11 @@ let(:namespace) { create(:namespace) } let(:user) { create(:user) } - it { expect(described_class).to include_module(EE::Namespaces::Members::InviteService) } + it { expect(described_class).to include_module(CLOUD::Namespaces::Members::InviteService) } context 'when user limit of license is reached' do before do - create(:namespace_license, namespace: namespace, restrictions: { user_count: 1 }) + create(:license, namespace: namespace, restrictions: { user_count: 1 }) create(:namespace_member, namespace: namespace, user: current_user) stub_allowed_ability(NamespacePolicy, :invite_member, user: current_user, subject: namespace) end diff --git a/extensions/ee/spec/services/namespaces/licenses/create_service_spec.rb b/extensions/cloud/spec/services/namespaces/licenses/create_service_spec.rb similarity index 75% rename from extensions/ee/spec/services/namespaces/licenses/create_service_spec.rb rename to extensions/cloud/spec/services/namespaces/licenses/create_service_spec.rb index e75b71b5..d8eaac4f 100644 --- a/extensions/ee/spec/services/namespaces/licenses/create_service_spec.rb +++ b/extensions/cloud/spec/services/namespaces/licenses/create_service_spec.rb @@ -11,7 +11,7 @@ it { is_expected.to be_error } it 'does not create namespace license' do - expect { service_response }.not_to change { NamespaceLicense.count } + expect { service_response }.not_to change { License.count } end it { expect { service_response }.not_to create_audit_event } @@ -21,7 +21,7 @@ let(:current_user) { nil } # rubocop:disable RSpec/LetSetup let!(:params) do - { data: create(:namespace_license).data, namespace: namespace } + { data: create(:license).data, namespace: namespace } end it_behaves_like 'does not create' @@ -37,7 +37,7 @@ end context 'when namespace is invalid' do - let!(:params) { { data: create(:namespace_license).data, namespace: nil } } + let!(:params) { { data: create(:license).data, namespace: nil } } # rubocop:enable RSpec/LetSetup it_behaves_like 'does not create' @@ -58,26 +58,26 @@ # rubocop:disable RSpec/LetSetup let!(:params) do - { data: create(:namespace_license, **license_data).data, namespace: namespace } + { data: create(:license, **license_data).data, namespace: namespace } end # rubocop:enable RSpec/LetSetup before do - stub_allowed_ability(NamespacePolicy, :create_namespace_license, user: current_user, subject: namespace) + stub_allowed_ability(NamespacePolicy, :create_license, user: current_user, subject: namespace) end it { is_expected.to be_success } it { expect(service_response.payload.reload).to be_valid } it 'adds license to the namespace' do - expect { service_response }.to change { NamespaceLicense.where(namespace: namespace).count }.by(1) + expect { service_response }.to change { License.where(namespace: namespace).count }.by(1) end it do is_expected.to create_audit_event( - :namespace_license_created, + :license_created, author_id: current_user.id, - entity_type: 'NamespaceLicense', + entity_type: 'License', details: license_data, target_id: namespace.id, target_type: 'Namespace' diff --git a/extensions/ee/spec/services/namespaces/licenses/delete_service_spec.rb b/extensions/cloud/spec/services/namespaces/licenses/delete_service_spec.rb similarity index 66% rename from extensions/ee/spec/services/namespaces/licenses/delete_service_spec.rb rename to extensions/cloud/spec/services/namespaces/licenses/delete_service_spec.rb index a4a7c559..a7e8dd39 100644 --- a/extensions/ee/spec/services/namespaces/licenses/delete_service_spec.rb +++ b/extensions/cloud/spec/services/namespaces/licenses/delete_service_spec.rb @@ -11,7 +11,7 @@ it { is_expected.to be_error } it 'does not delete namespace license' do - expect { service_response }.not_to change { NamespaceLicense.count } + expect { service_response }.not_to change { License.count } end it { expect { service_response }.not_to create_audit_event } @@ -21,7 +21,7 @@ let(:current_user) { nil } # rubocop:disable RSpec/LetSetup let!(:params) do - { namespace_license: create(:namespace_license) } + { license: create(:license) } end it_behaves_like 'does not delete' @@ -29,28 +29,28 @@ context 'when user and params are valid' do let(:current_user) { create(:user) } - let!(:namespace_license) { create(:namespace_license, namespace: namespace) } + let!(:license) { create(:license, namespace: namespace) } let!(:params) do - { namespace_license: namespace_license } + { license: license } end # rubocop:enable RSpec/LetSetup before do - stub_allowed_ability(NamespacePolicy, :delete_namespace_license, user: current_user, subject: namespace) + stub_allowed_ability(NamespacePolicy, :delete_license, user: current_user, subject: namespace) end it { is_expected.to be_success } it 'removes license to the namespace' do - expect { service_response }.to change { NamespaceLicense.where(namespace: namespace).count }.by(-1) + expect { service_response }.to change { License.where(namespace: namespace).count }.by(-1) end it do is_expected.to create_audit_event( - :namespace_license_deleted, + :license_deleted, author_id: current_user.id, - entity_type: 'NamespaceLicense', + entity_type: 'License', details: {}, target_id: namespace.id, target_type: 'Namespace' diff --git a/extensions/ee/app/graphql/ee/types/namespace_type.rb b/extensions/ee/app/graphql/ee/types/namespace_type.rb deleted file mode 100644 index fb30b126..00000000 --- a/extensions/ee/app/graphql/ee/types/namespace_type.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module EE - module Types - module NamespaceType - extend ActiveSupport::Concern - - prepended do - field :namespace_licenses, ::Types::NamespaceLicenseType.connection_type, - null: false, - description: '(EE only) Licenses of the namespace' - - field :current_namespace_license, ::Types::NamespaceLicenseType, - null: true, - description: '(EE only) Currently active license of the namespace', - method: :current_license - - expose_abilities %i[ - create_namespace_license - ] - end - end - end -end diff --git a/extensions/ee/app/graphql/mutations/namespaces/licenses/delete.rb b/extensions/ee/app/graphql/mutations/namespaces/licenses/delete.rb deleted file mode 100644 index 5f4b0206..00000000 --- a/extensions/ee/app/graphql/mutations/namespaces/licenses/delete.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module Namespaces - module Licenses - class Delete < BaseMutation - description '(EE only) Deletes an namespace license.' - - field :namespace_license, Types::NamespaceLicenseType, null: true, - description: 'The deleted namespace license.' - - argument :namespace_license_id, ::Types::GlobalIdType[::NamespaceLicense], - required: true, - description: 'The license id to delete.' - - def resolve(namespace_license_id:) - license = SagittariusSchema.object_from_id(namespace_license_id) - - if license.nil? - return { organization_license: nil, - errors: [create_error(:license_not_found, 'Invalid license')] } - end - - ::Namespaces::Licenses::DeleteService.new( - current_authentication, - namespace_license: license - ).execute.to_mutation_response(success_key: :namespace_license) - end - end - end - end -end diff --git a/extensions/ee/app/graphql/types/namespace_license_type.rb b/extensions/ee/app/graphql/types/license_type.rb similarity index 55% rename from extensions/ee/app/graphql/types/namespace_license_type.rb rename to extensions/ee/app/graphql/types/license_type.rb index aece7fe4..54064132 100644 --- a/extensions/ee/app/graphql/types/namespace_license_type.rb +++ b/extensions/ee/app/graphql/types/license_type.rb @@ -1,12 +1,10 @@ # frozen_string_literal: true module Types - class NamespaceLicenseType < Types::BaseObject - description '(EE only) Represents a Namespace License' + class LicenseType < Types::BaseObject + description '(EE only) Represents a License' - authorize :read_namespace_license - - field :namespace, Types::NamespaceType, null: false, description: 'The namespace the license belongs to' + authorize :read_license field :start_date, Types::TimeType, null: false, description: 'The start date of the license' @@ -15,10 +13,12 @@ class NamespaceLicenseType < Types::BaseObject field :licensee, GraphQL::Types::JSON, null: false, description: 'The licensee information' expose_abilities %i[ - delete_namespace_license + delete_license ] - id_field NamespaceLicense + id_field License timestamps end end + +Types::LicenseType.prepend_extensions diff --git a/extensions/ee/app/models/namespace_license.rb b/extensions/ee/app/models/license.rb similarity index 66% rename from extensions/ee/app/models/namespace_license.rb rename to extensions/ee/app/models/license.rb index 8f01ca91..d3902e08 100644 --- a/extensions/ee/app/models/namespace_license.rb +++ b/extensions/ee/app/models/license.rb @@ -1,11 +1,8 @@ # frozen_string_literal: true -class NamespaceLicense < ApplicationRecord +class License < ApplicationRecord include Code0::ZeroTrack::Memoize - belongs_to :namespace, inverse_of: :namespace_licenses - - scope :for_namespace, ->(namespace) { where(namespace: namespace) } scope :latest_first, -> { reorder(id: :desc) } scope :last_fifty, -> { latest_first.limit(50) } @@ -14,15 +11,13 @@ class NamespaceLicense < ApplicationRecord class << self include Code0::ZeroTrack::Memoize - def current(namespace) - memoize(:current, reset_on_change: -> { namespace.id }) do - load_license(namespace) - end + def current + load_license end - def load_license(namespace) - for_namespace(namespace).last_fifty.find do |namespace_license| - namespace_license.license.in_active_time? + def load_license + last_fifty.find do |license| + license.license.in_active_time? end end end @@ -64,3 +59,5 @@ def restricted?(attribute) license&.restricted?(attribute) end end + +License.prepend_extensions diff --git a/extensions/ee/app/policies/ee/namespace_policy.rb b/extensions/ee/app/policies/ee/namespace_policy.rb deleted file mode 100644 index dacb0c65..00000000 --- a/extensions/ee/app/policies/ee/namespace_policy.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module EE - module NamespacePolicy - extend ActiveSupport::Concern - - prepended do - customizable_permission :read_namespace_license - customizable_permission :create_namespace_license - customizable_permission :delete_namespace_license - end - end -end diff --git a/extensions/ee/app/policies/license_policy.rb b/extensions/ee/app/policies/license_policy.rb new file mode 100644 index 00000000..f85d48b4 --- /dev/null +++ b/extensions/ee/app/policies/license_policy.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class LicensePolicy < BasePolicy +end + +LicensePolicy.prepend_extensions diff --git a/extensions/ee/app/policies/namespace_license_policy.rb b/extensions/ee/app/policies/namespace_license_policy.rb deleted file mode 100644 index b79972d5..00000000 --- a/extensions/ee/app/policies/namespace_license_policy.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class NamespaceLicensePolicy < BasePolicy - delegate { subject.namespace } -end diff --git a/extensions/ee/app/services/ee/error_code.rb b/extensions/ee/app/services/ee/error_code.rb index 4d817d38..9d036157 100644 --- a/extensions/ee/app/services/ee/error_code.rb +++ b/extensions/ee/app/services/ee/error_code.rb @@ -8,6 +8,8 @@ module ErrorCode def error_codes super.merge( { + invalid_license: { description: 'The license is invalid because of active model errors' }, + license_not_found: { description: 'The license with the given identifier was not found' }, no_free_license_seats: { description: 'There are no free license seats to complete this operation' }, } ) diff --git a/extensions/ee/app/services/ee/users/create_service.rb b/extensions/ee/app/services/ee/users/create_service.rb new file mode 100644 index 00000000..44d20c5d --- /dev/null +++ b/extensions/ee/app/services/ee/users/create_service.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module EE + module Users + module CreateService + include Sagittarius::Override + include EE::Users::ValidateUserLimit + + override :validate_user_limit! + end + end +end diff --git a/extensions/ee/app/services/ee/users/identity/register_service.rb b/extensions/ee/app/services/ee/users/identity/register_service.rb new file mode 100644 index 00000000..68a4b042 --- /dev/null +++ b/extensions/ee/app/services/ee/users/identity/register_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module EE + module Users + module Identity + module RegisterService + include Sagittarius::Override + include EE::Users::ValidateUserLimit + + override :validate_user_limit! + end + end + end +end diff --git a/extensions/ee/app/services/ee/users/register_service.rb b/extensions/ee/app/services/ee/users/register_service.rb new file mode 100644 index 00000000..2ffb325f --- /dev/null +++ b/extensions/ee/app/services/ee/users/register_service.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module EE + module Users + module RegisterService + include Sagittarius::Override + include EE::Users::ValidateUserLimit + + override :validate_user_limit! + end + end +end diff --git a/extensions/ee/app/services/ee/users/validate_user_limit.rb b/extensions/ee/app/services/ee/users/validate_user_limit.rb new file mode 100644 index 00000000..5e372fb0 --- /dev/null +++ b/extensions/ee/app/services/ee/users/validate_user_limit.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module EE + module Users + module ValidateUserLimit + protected + + def validate_user_limit!(t) + license = License.current + return if license.nil? || !license.restricted?(:user_count) + return if User.count <= license.restrictions[:user_count] + + t.rollback_and_return! ServiceResponse.error( + message: 'No free user seats in license', + error_code: :no_free_license_seats + ) + end + end + end +end diff --git a/extensions/ee/spec/factories/namespace_licenses.rb b/extensions/ee/spec/factories/licenses.rb similarity index 91% rename from extensions/ee/spec/factories/namespace_licenses.rb rename to extensions/ee/spec/factories/licenses.rb index a98d3e6e..6e024df3 100644 --- a/extensions/ee/spec/factories/namespace_licenses.rb +++ b/extensions/ee/spec/factories/licenses.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :namespace_license do - namespace - + factory :license do transient do licensee { { company: 'Code0' } } start_date { Time.zone.today - 1 } diff --git a/extensions/ee/spec/graphql/types/license_type_spec.rb b/extensions/ee/spec/graphql/types/license_type_spec.rb new file mode 100644 index 00000000..e6cdf529 --- /dev/null +++ b/extensions/ee/spec/graphql/types/license_type_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SagittariusSchema.types['License'] do + let(:fields) do + %w[ + id + startDate + endDate + licensee + userAbilities + createdAt + updatedAt + ] + end + + it { expect(described_class.graphql_name).to eq('License') } + it { expect(described_class).to have_graphql_fields(fields).allow_unexpected_if_extended } + it { expect(described_class).to require_graphql_authorizations(:read_license) } +end diff --git a/extensions/ee/spec/models/ee/namespace_spec.rb b/extensions/ee/spec/models/ee/namespace_spec.rb deleted file mode 100644 index 8cdd5dba..00000000 --- a/extensions/ee/spec/models/ee/namespace_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Namespace do - it { is_expected.to include_module(EE::Namespace) } - - describe 'associations' do - it { is_expected.to have_many(:namespace_licenses).inverse_of(:namespace) } - end -end diff --git a/extensions/ee/spec/models/license_spec.rb b/extensions/ee/spec/models/license_spec.rb new file mode 100644 index 00000000..aa73223f --- /dev/null +++ b/extensions/ee/spec/models/license_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe License do + subject(:license) { build(:license) } + + describe 'validations' do + it 'loads license with code0-license' do + allow(Code0::License).to receive(:load) + + license.valid? + + expect(Code0::License).to have_received(:load).with(license.data) + end + + context 'when loaded license is nil' do + subject(:license) { build(:license, data: '') } + + it { is_expected.not_to be_valid } + end + + context 'when loaded license is invalid' do + subject(:license) { build(:license, licensee: {}) } + + it { is_expected.not_to be_valid } + end + end + + describe '#license' do + it 'memoizes loaded license' do + allow(Code0::License).to receive(:load).and_return(SecureRandom.hex, SecureRandom.hex) + + loaded = license.license + + expect(license.license).to be loaded + expect(Code0::License).to have_received(:load) + end + end +end diff --git a/extensions/ee/spec/models/namespace_license_spec.rb b/extensions/ee/spec/models/namespace_license_spec.rb deleted file mode 100644 index 64ce0769..00000000 --- a/extensions/ee/spec/models/namespace_license_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe NamespaceLicense do - subject(:namespace_license) { build(:namespace_license) } - - describe 'associations' do - it { is_expected.to belong_to(:namespace).required } - end - - describe 'validations' do - it 'loads license with code0-license' do - allow(Code0::License).to receive(:load) - - namespace_license.valid? - - expect(Code0::License).to have_received(:load).with(namespace_license.data) - end - - context 'when loaded license is nil' do - subject(:namespace_license) { build(:namespace_license, data: '') } - - it { is_expected.not_to be_valid } - end - - context 'when loaded license is invalid' do - subject(:namespace_license) { build(:namespace_license, licensee: {}) } - - it { is_expected.not_to be_valid } - end - end - - describe '#license' do - it 'memoizes loaded license' do - allow(Code0::License).to receive(:load).and_return(SecureRandom.hex, SecureRandom.hex) - - loaded = namespace_license.license - - expect(namespace_license.license).to be loaded - expect(Code0::License).to have_received(:load) - end - end - - describe '.current' do - let(:first_license) { create(:namespace_license) } - let(:second_license) { create(:namespace_license) } - - after do - described_class.clear_memoize(:current) - described_class.clear_memoize(:current_reset_on_change) - end - - it 'memoizes license' do - allow(described_class).to receive(:load_license) - - described_class.current(first_license.namespace) - described_class.current(first_license.namespace) # make a memoized call - - expect(described_class).to have_received(:load_license) - end - - it 'does not memoize license from wrong namespace' do - expect(described_class.current(first_license.namespace)).to eq(first_license) - expect(described_class.current(second_license.namespace)).to eq(second_license) - end - end - - describe '.load_license' do - let(:namespace) { create(:namespace) } - - it 'returns newest license' do - create(:namespace_license, namespace: namespace) - current_license = create(:namespace_license, namespace: namespace) - - expect(described_class.load_license(namespace)).to eq(current_license) - end - - it 'ignores future licenses' do - current_license = create(:namespace_license, namespace: namespace) - create( - :namespace_license, - namespace: namespace, - start_date: Time.zone.today + 2, - end_date: Time.zone.today + 3 - ) - - expect(described_class.load_license(namespace)).to eq(current_license) - end - end -end diff --git a/extensions/ee/spec/services/ee/users/create_service_spec.rb b/extensions/ee/spec/services/ee/users/create_service_spec.rb new file mode 100644 index 00000000..446149d8 --- /dev/null +++ b/extensions/ee/spec/services/ee/users/create_service_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::CreateService do + subject(:service_response) do + described_class.new( + create_authentication(current_user), + username: 'newuser', + email: 'new@example.com', + password: 'password123' + ).execute + end + + let(:current_user) { create(:user, :admin) } + + it { expect(described_class).to include_module(EE::Users::CreateService) } + + context 'when user limit of license is reached' do + before do + current_user + create(:license, restrictions: { user_count: 0 }) + end + + it { is_expected.not_to be_success } + it { expect { service_response }.not_to change { User.count } } + it { expect(service_response.payload[:error_code]).to eq(:no_free_license_seats) } + end +end diff --git a/extensions/ee/spec/services/ee/users/identity/register_service_spec.rb b/extensions/ee/spec/services/ee/users/identity/register_service_spec.rb new file mode 100644 index 00000000..3cfe0a00 --- /dev/null +++ b/extensions/ee/spec/services/ee/users/identity/register_service_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Identity::RegisterService do + subject(:service_response) { service.execute } + + let(:service) do + described_class.new(provider_id, args) + end + let(:provider_id) do + :google + end + let(:args) do + { + code: 'valid_code', + } + end + + def setup_identity_provider(identity) + provider = service.identity_provider + allow(service).to receive(:identity_provider).and_return provider + allow(provider).to receive(:load_identity).and_return identity + end + + before do + setup_identity_provider( + Code0::Identities::Identity.new( + provider_id, + 'identifier', + 'username', + 'test@code0.tech', + 'firstname', + 'lastname' + ) + ) + end + + it { expect(described_class).to include_module(EE::Users::Identity::RegisterService) } + + context 'when user limit of license is reached' do + before do + create(:license, restrictions: { user_count: 0 }) + end + + it { is_expected.not_to be_success } + it { expect { service_response }.not_to change { User.count } } + it { expect(service_response.payload[:error_code]).to eq(:no_free_license_seats) } + end +end diff --git a/extensions/ee/spec/services/ee/users/register_service_spec.rb b/extensions/ee/spec/services/ee/users/register_service_spec.rb new file mode 100644 index 00000000..bfb74086 --- /dev/null +++ b/extensions/ee/spec/services/ee/users/register_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::RegisterService do + subject(:service_response) do + described_class.new('testuser', 'test@example.com', 'password123').execute + end + + it { expect(described_class).to include_module(EE::Users::RegisterService) } + + context 'when user limit of license is reached' do + before do + create(:license, restrictions: { user_count: 0 }) + end + + it { is_expected.not_to be_success } + it { expect { service_response }.not_to change { User.count } } + it { expect(service_response.payload[:error_code]).to eq(:no_free_license_seats) } + end +end diff --git a/lib/sagittarius/extensions.rb b/lib/sagittarius/extensions.rb index 9899c166..e2331611 100644 --- a/lib/sagittarius/extensions.rb +++ b/lib/sagittarius/extensions.rb @@ -4,7 +4,7 @@ module Sagittarius module Extensions module_function - AVAILABLE_EXTENSIONS = %i[ee].freeze + AVAILABLE_EXTENSIONS = %i[ee cloud].freeze def active extensions = [] diff --git a/spec/lib/sagittarius/extensions_spec.rb b/spec/lib/sagittarius/extensions_spec.rb index c82daa52..d1337726 100644 --- a/spec/lib/sagittarius/extensions_spec.rb +++ b/spec/lib/sagittarius/extensions_spec.rb @@ -4,11 +4,12 @@ require_relative '../../../lib/sagittarius/extensions' RSpec.describe Sagittarius::Extensions do - def stub_ee_exist(value) - root = instance_double(Pathname) + let(:root) { instance_double(Pathname) } + + def stub_extension_exist(extension, value) ee_extension_dir = instance_double(Pathname) allow(described_class).to receive(:root).and_return(root) - allow(root).to receive(:join).and_return(ee_extension_dir) + allow(root).to receive(:join).with('extensions', extension).and_return(ee_extension_dir) allow(ee_extension_dir).to receive(:exist?).and_return(value) end @@ -16,17 +17,28 @@ def stub_ee_disabled(value) allow(ENV).to receive(:fetch).with('SAGITTARIUS_DISABLE_EE', 'false').and_return(value) end + def stub_cloud_disabled(value) + allow(ENV).to receive(:fetch).with('SAGITTARIUS_DISABLE_CLOUD', 'false').and_return(value) + end + + before do + stub_extension_exist('ee', false) + stub_extension_exist('cloud', false) + stub_ee_disabled(false) + stub_cloud_disabled(false) + end + describe '.active' do context 'when ee exists' do it do - stub_ee_exist(true) + stub_extension_exist('ee', true) stub_ee_disabled(false) expect(described_class.active).to include(:ee) end context 'when disabled with env' do it do - stub_ee_exist(true) + stub_extension_exist('ee', true) stub_ee_disabled(true) expect(described_class.active).not_to include(:ee) @@ -36,7 +48,7 @@ def stub_ee_disabled(value) context 'when ee does not exist' do it do - stub_ee_exist(false) + stub_extension_exist('ee', false) stub_ee_disabled(false) expect(described_class.active).not_to include(:ee) end @@ -45,13 +57,13 @@ def stub_ee_disabled(value) describe '.ee?' do it 'returns true when ee exists' do - stub_ee_exist(true) + stub_extension_exist('ee', true) stub_ee_disabled(false) expect(described_class.ee?).to be(true) end it 'returns false when ee does not exist' do - stub_ee_exist(false) + stub_extension_exist('ee', false) stub_ee_disabled(false) expect(described_class.ee?).to be(false) end @@ -59,13 +71,13 @@ def stub_ee_disabled(value) describe '.ee' do it 'yields when ee exists' do - stub_ee_exist(true) + stub_extension_exist('ee', true) stub_ee_disabled(false) expect { |block| described_class.ee(&block) }.to yield_control end it 'does not yield when ee does not exist' do - stub_ee_exist(false) + stub_extension_exist('ee', false) stub_ee_disabled(false) expect { |block| described_class.ee(&block) }.not_to yield_control end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index f58f8153..1c355716 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -94,6 +94,12 @@ config.before eager_load: true do Rails.application.eager_load! end + + config.around do |example| + Code0::ZeroTrack::Context.with_context(correlation_id: SecureRandom.uuid) do + example.run + end + end end Shoulda::Matchers.configure do |config| diff --git a/spec/requests/graphql/mutation/users/create_spec.rb b/spec/requests/graphql/mutation/users/create_spec.rb index 6ef2df3d..d4a5019e 100644 --- a/spec/requests/graphql/mutation/users/create_spec.rb +++ b/spec/requests/graphql/mutation/users/create_spec.rb @@ -45,6 +45,8 @@ end it 'creates user' do + expect(graphql_data_at(:users_create, :errors)).to be_empty + expect(graphql_data_at(:users_create, :user, :id)).to be_present expect(graphql_data_at(:users_create, :user, :email)).to eq(input[:email]) expect(graphql_data_at(:users_create, :user, :username)).to eq(input[:username]) From dc9d6cd25049167d217ec21d86b15397f5ae7ce2 Mon Sep 17 00:00:00 2001 From: Niklas van Schrick Date: Wed, 29 Apr 2026 22:33:45 +0200 Subject: [PATCH 2/4] Add global license mutations and queries --- app/graphql/types/application_type.rb | 2 + app/policies/global_policy.rb | 2 + docs/graphql/mutation/licensescreate.md | 20 +++++ docs/graphql/mutation/licensesdelete.md | 20 +++++ docs/graphql/object/application.md | 2 + .../object/applicationuserabilities.md | 1 + .../namespaces/licenses/delete_spec.rb | 7 ++ .../app/graphql/ee/types/application_type.rb | 31 +++++++ .../ee/app/graphql/ee/types/mutation_type.rb | 14 ++++ .../app/graphql/mutations/licenses/create.rb | 20 +++++ .../app/graphql/mutations/licenses/delete.rb | 31 +++++++ .../ee/app/policies/ee/global_policy.rb | 15 ++++ extensions/ee/app/policies/license_policy.rb | 1 + .../app/services/licenses/create_service.rb | 49 +++++++++++ .../app/services/licenses/delete_service.rb | 41 ++++++++++ .../graphql/ee/types/application_type_spec.rb | 23 ++++++ .../graphql/mutations/licenses/create_spec.rb | 7 ++ .../graphql/mutations/licenses/delete_spec.rb | 7 ++ .../ee/spec/policies/ee/global_policy_spec.rb | 7 ++ .../licenses/create_mutation_spec.rb | 76 +++++++++++++++++ .../licenses/delete_mutation_spec.rb | 66 +++++++++++++++ .../services/licenses/create_service_spec.rb | 81 +++++++++++++++++++ .../services/licenses/delete_service_spec.rb | 54 +++++++++++++ spec/graphql/types/application_type_spec.rb | 2 +- 24 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 docs/graphql/mutation/licensescreate.md create mode 100644 docs/graphql/mutation/licensesdelete.md create mode 100644 extensions/cloud/spec/graphql/mutations/namespaces/licenses/delete_spec.rb create mode 100644 extensions/ee/app/graphql/ee/types/application_type.rb create mode 100644 extensions/ee/app/graphql/ee/types/mutation_type.rb create mode 100644 extensions/ee/app/graphql/mutations/licenses/create.rb create mode 100644 extensions/ee/app/graphql/mutations/licenses/delete.rb create mode 100644 extensions/ee/app/policies/ee/global_policy.rb create mode 100644 extensions/ee/app/services/licenses/create_service.rb create mode 100644 extensions/ee/app/services/licenses/delete_service.rb create mode 100644 extensions/ee/spec/graphql/ee/types/application_type_spec.rb create mode 100644 extensions/ee/spec/graphql/mutations/licenses/create_spec.rb create mode 100644 extensions/ee/spec/graphql/mutations/licenses/delete_spec.rb create mode 100644 extensions/ee/spec/policies/ee/global_policy_spec.rb create mode 100644 extensions/ee/spec/requests/graphql/mutations/licenses/create_mutation_spec.rb create mode 100644 extensions/ee/spec/requests/graphql/mutations/licenses/delete_mutation_spec.rb create mode 100644 extensions/ee/spec/services/licenses/create_service_spec.rb create mode 100644 extensions/ee/spec/services/licenses/delete_service_spec.rb diff --git a/app/graphql/types/application_type.rb b/app/graphql/types/application_type.rb index f6e68b18..a79c78bf 100644 --- a/app/graphql/types/application_type.rb +++ b/app/graphql/types/application_type.rb @@ -52,3 +52,5 @@ def legal_notice_url end end end + +Types::ApplicationType.prepend_extensions diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 825f3332..c4ee77e3 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -25,3 +25,5 @@ class GlobalPolicy < BasePolicy enable :create_user end end + +GlobalPolicy.prepend_extensions diff --git a/docs/graphql/mutation/licensescreate.md b/docs/graphql/mutation/licensescreate.md new file mode 100644 index 00000000..85320c61 --- /dev/null +++ b/docs/graphql/mutation/licensescreate.md @@ -0,0 +1,20 @@ +--- +title: licensesCreate +--- + +(EE only) Create a new license. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `data` | [`String!`](../scalar/string.md) | The license data. | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. | +| `license` | [`License`](../object/license.md) | The newly created license. | diff --git a/docs/graphql/mutation/licensesdelete.md b/docs/graphql/mutation/licensesdelete.md new file mode 100644 index 00000000..89a844d1 --- /dev/null +++ b/docs/graphql/mutation/licensesdelete.md @@ -0,0 +1,20 @@ +--- +title: licensesDelete +--- + +(EE only) Deletes an license. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `licenseId` | [`LicenseID!`](../scalar/licenseid.md) | The license id to delete. | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. | +| `license` | [`License`](../object/license.md) | The deleted license. | diff --git a/docs/graphql/object/application.md b/docs/graphql/object/application.md index 39d32cb4..0985557b 100644 --- a/docs/graphql/object/application.md +++ b/docs/graphql/object/application.md @@ -8,7 +8,9 @@ Represents the application instance | Name | Type | Description | |------|------|-------------| +| `currentLicense` | [`License`](../object/license.md) | (EE only) Currently active license of the instance | | `legalNoticeUrl` | [`String`](../scalar/string.md) | URL to the legal notice page | +| `licenses` | [`LicenseConnection!`](../object/licenseconnection.md) | (EE only) Licenses of the instance | | `metadata` | [`Metadata`](../object/metadata.md) | Metadata about the application | | `privacyUrl` | [`String`](../scalar/string.md) | URL to the privacy policy page | | `settings` | [`ApplicationSettings`](../object/applicationsettings.md) | Global application settings | diff --git a/docs/graphql/object/applicationuserabilities.md b/docs/graphql/object/applicationuserabilities.md index 02038b6a..b6b2ffe1 100644 --- a/docs/graphql/object/applicationuserabilities.md +++ b/docs/graphql/object/applicationuserabilities.md @@ -8,6 +8,7 @@ Abilities for the current user on this Application | Name | Type | Description | |------|------|-------------| +| `createLicense` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `create_license` ability on this Application | | `createOrganization` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `create_organization` ability on this Application | | `createRuntime` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `create_runtime` ability on this Application | | `deleteRuntime` | [`Boolean!`](../scalar/boolean.md) | Shows if the current user has the `delete_runtime` ability on this Application | diff --git a/extensions/cloud/spec/graphql/mutations/namespaces/licenses/delete_spec.rb b/extensions/cloud/spec/graphql/mutations/namespaces/licenses/delete_spec.rb new file mode 100644 index 00000000..4df12940 --- /dev/null +++ b/extensions/cloud/spec/graphql/mutations/namespaces/licenses/delete_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Namespaces::Licenses::Delete do + it { expect(described_class.graphql_name).to eq('NamespacesLicensesDelete') } +end diff --git a/extensions/ee/app/graphql/ee/types/application_type.rb b/extensions/ee/app/graphql/ee/types/application_type.rb new file mode 100644 index 00000000..28e37a1e --- /dev/null +++ b/extensions/ee/app/graphql/ee/types/application_type.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EE + module Types + module ApplicationType + extend ActiveSupport::Concern + + prepended do + field :licenses, ::Types::LicenseType.connection_type, + null: false, + description: '(EE only) Licenses of the instance' + + field :current_license, ::Types::LicenseType, + null: true, + description: '(EE only) Currently active license of the instance' + + expose_abilities %i[ + create_license + ], subject_resolver: -> { :global } + end + + def licenses + License.all + end + + def current_license + License.current + end + end + end +end diff --git a/extensions/ee/app/graphql/ee/types/mutation_type.rb b/extensions/ee/app/graphql/ee/types/mutation_type.rb new file mode 100644 index 00000000..9f736b45 --- /dev/null +++ b/extensions/ee/app/graphql/ee/types/mutation_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module EE + module Types + module MutationType + extend ActiveSupport::Concern + + prepended do + mount_mutation Mutations::Licenses::Create + mount_mutation Mutations::Licenses::Delete + end + end + end +end diff --git a/extensions/ee/app/graphql/mutations/licenses/create.rb b/extensions/ee/app/graphql/mutations/licenses/create.rb new file mode 100644 index 00000000..fdc1b1c1 --- /dev/null +++ b/extensions/ee/app/graphql/mutations/licenses/create.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module Licenses + class Create < BaseMutation + description '(EE only) Create a new license.' + + field :license, Types::LicenseType, null: true, description: 'The newly created license.' + + argument :data, String, required: true, description: 'The license data.' + + def resolve(data:) + ::Licenses::CreateService.new( + current_authentication, + data: data + ).execute.to_mutation_response(success_key: :license) + end + end + end +end diff --git a/extensions/ee/app/graphql/mutations/licenses/delete.rb b/extensions/ee/app/graphql/mutations/licenses/delete.rb new file mode 100644 index 00000000..9b93bc14 --- /dev/null +++ b/extensions/ee/app/graphql/mutations/licenses/delete.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module Licenses + class Delete < BaseMutation + description '(EE only) Deletes an license.' + + field :license, Types::LicenseType, + null: true, + description: 'The deleted license.' + + argument :license_id, ::Types::GlobalIdType[::License], + required: true, + description: 'The license id to delete.' + + def resolve(license_id:) + license = SagittariusSchema.object_from_id(license_id) + + if license.nil? + return { license: nil, + errors: [create_error(:license_not_found, 'Invalid license')] } + end + + ::Licenses::DeleteService.new( + current_authentication, + license: license + ).execute.to_mutation_response(success_key: :license) + end + end + end +end diff --git a/extensions/ee/app/policies/ee/global_policy.rb b/extensions/ee/app/policies/ee/global_policy.rb new file mode 100644 index 00000000..94167d93 --- /dev/null +++ b/extensions/ee/app/policies/ee/global_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module EE + module GlobalPolicy + extend ActiveSupport::Concern + + prepended do + rule { admin }.policy do + enable :read_license + enable :create_license + enable :delete_license + end + end + end +end diff --git a/extensions/ee/app/policies/license_policy.rb b/extensions/ee/app/policies/license_policy.rb index f85d48b4..32c37de0 100644 --- a/extensions/ee/app/policies/license_policy.rb +++ b/extensions/ee/app/policies/license_policy.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class LicensePolicy < BasePolicy + delegate { :global } end LicensePolicy.prepend_extensions diff --git a/extensions/ee/app/services/licenses/create_service.rb b/extensions/ee/app/services/licenses/create_service.rb new file mode 100644 index 00000000..f693142a --- /dev/null +++ b/extensions/ee/app/services/licenses/create_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Licenses + class CreateService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :data + + def initialize(current_authentication, data:) + @current_authentication = current_authentication + @data = data + end + + def execute + unless Ability.allowed?(current_authentication, :create_license) + return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) + end + + transactional do |t| + license = License.create(data: data) + unless license.persisted? + t.rollback_and_return! ServiceResponse.error( + message: 'Failed to create license', + error_code: :invalid_license, + details: license.errors + ) + end + + license_data = license.license + + AuditService.audit( + :license_created, + author_id: current_authentication.user.id, + entity: license, + target: AuditEvent::GLOBAL_TARGET, + details: { + licensee: license_data.licensee, + start_date: license_data.start_date, + end_date: license_data.end_date, + restrictions: license_data.restrictions, + options: license_data.options, + } + ) + + ServiceResponse.success(message: 'Created new license', payload: license) + end + end + end +end diff --git a/extensions/ee/app/services/licenses/delete_service.rb b/extensions/ee/app/services/licenses/delete_service.rb new file mode 100644 index 00000000..7cf4d8f9 --- /dev/null +++ b/extensions/ee/app/services/licenses/delete_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Licenses + class DeleteService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :license + + def initialize(current_authentication, license:) + @current_authentication = current_authentication + @license = license + end + + def execute + unless Ability.allowed?(current_authentication, :delete_license, license) + return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) + end + + transactional do |t| + license.delete + if license.persisted? + t.rollback_and_return! ServiceResponse.error( + message: 'Failed to delete license', + error_code: :invalid_license, + details: license.errors + ) + end + + AuditService.audit( + :license_deleted, + author_id: current_authentication.user.id, + entity: license, + details: {}, + target: AuditEvent::GLOBAL_TARGET + ) + + ServiceResponse.success(message: 'Deleted license', payload: license) + end + end + end +end diff --git a/extensions/ee/spec/graphql/ee/types/application_type_spec.rb b/extensions/ee/spec/graphql/ee/types/application_type_spec.rb new file mode 100644 index 00000000..d48bcaba --- /dev/null +++ b/extensions/ee/spec/graphql/ee/types/application_type_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Types::ApplicationType do + let(:fields) do + %w[ + settings + metadata + privacyUrl + termsAndConditionsUrl + legalNoticeUrl + licenses + currentLicense + user_abilities + ] + end + + it { expect(described_class).to include_module(EE::Types::ApplicationType) } + + it { expect(described_class.graphql_name).to eq('Application') } + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/extensions/ee/spec/graphql/mutations/licenses/create_spec.rb b/extensions/ee/spec/graphql/mutations/licenses/create_spec.rb new file mode 100644 index 00000000..0cfce037 --- /dev/null +++ b/extensions/ee/spec/graphql/mutations/licenses/create_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Licenses::Create do + it { expect(described_class.graphql_name).to eq('LicensesCreate') } +end diff --git a/extensions/ee/spec/graphql/mutations/licenses/delete_spec.rb b/extensions/ee/spec/graphql/mutations/licenses/delete_spec.rb new file mode 100644 index 00000000..b78452a6 --- /dev/null +++ b/extensions/ee/spec/graphql/mutations/licenses/delete_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Licenses::Delete do + it { expect(described_class.graphql_name).to eq('LicensesDelete') } +end diff --git a/extensions/ee/spec/policies/ee/global_policy_spec.rb b/extensions/ee/spec/policies/ee/global_policy_spec.rb new file mode 100644 index 00000000..3b8f874b --- /dev/null +++ b/extensions/ee/spec/policies/ee/global_policy_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe GlobalPolicy do + it { expect(described_class).to include_module(EE::GlobalPolicy) } +end diff --git a/extensions/ee/spec/requests/graphql/mutations/licenses/create_mutation_spec.rb b/extensions/ee/spec/requests/graphql/mutations/licenses/create_mutation_spec.rb new file mode 100644 index 00000000..035ae68e --- /dev/null +++ b/extensions/ee/spec/requests/graphql/mutations/licenses/create_mutation_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'licensesCreate Mutation', unless: Sagittarius::Extensions.cloud? do + include GraphqlHelpers + + subject(:mutate!) { post_graphql mutation, variables: variables, current_user: current_user } + + let(:mutation) do + <<~QUERY + mutation($input: LicensesCreateInput!) { + licensesCreate(input: $input) { + #{error_query} + license { + id + } + } + } + QUERY + end + + let(:input) do + { + data: create(:license).data, + } + end + + let(:variables) { { input: input } } + let(:current_user) { create(:user) } + + context 'when user is a admin' do + let(:current_user) { create(:user, :admin) } + + it 'creates license' do + mutate! + + expect(graphql_data_at(:licenses_create, :license, :id)).to be_present + + license = SagittariusSchema.object_from_id( + graphql_data_at(:licenses_create, :license, :id) + ) + + is_expected.to create_audit_event( + :license_created, + author_id: current_user.id, + entity_id: license.id, + entity_type: 'License', + target_id: 0, + target_type: 'global' + ) + end + + context 'when license is invalid' do + let(:input) { { data: 'invalid license' } } + + it 'returns an error' do + mutate! + + expect(graphql_data_at(:licenses_create, :license)).to be_nil + expect( + graphql_data_at(:licenses_create, :errors, :details) + ).to include([{ 'attribute' => 'data', 'type' => 'invalid' }]) + end + end + end + + context 'when user is not an admin' do + it 'returns an error' do + mutate! + + expect(graphql_data_at(:licenses_create, :license)).to be_nil + expect(graphql_data_at(:licenses_create, :errors, :error_code)).to include('MISSING_PERMISSION') + end + end +end diff --git a/extensions/ee/spec/requests/graphql/mutations/licenses/delete_mutation_spec.rb b/extensions/ee/spec/requests/graphql/mutations/licenses/delete_mutation_spec.rb new file mode 100644 index 00000000..c688be54 --- /dev/null +++ b/extensions/ee/spec/requests/graphql/mutations/licenses/delete_mutation_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'licensesDelete Mutation', unless: Sagittarius::Extensions.cloud? do + include GraphqlHelpers + + subject(:mutate!) { post_graphql mutation, variables: variables, current_user: current_user } + + let(:mutation) do + <<~QUERY + mutation($input: LicensesDeleteInput!) { + licensesDelete(input: $input) { + #{error_query} + license { + id + } + } + } + QUERY + end + + let(:license) { create(:license) } + let(:input) do + { + licenseId: license.to_global_id.to_s, + } + end + + let(:variables) { { input: input } } + let(:current_user) { create(:user) } + + context 'when user is admin' do + let(:current_user) { create(:user, :admin) } + + it 'deletes license' do + mutate! + + expect(graphql_data_at(:licenses_delete, :license, :id)).to be_present + + deleted_license = SagittariusSchema.object_from_id( + graphql_data_at(:licenses_delete, :license, :id) + ) + + expect(deleted_license).to be_nil + + is_expected.to create_audit_event( + :license_deleted, + author_id: current_user.id, + entity_id: license.id, + entity_type: 'License', + target_id: 0, + target_type: 'global' + ) + end + end + + context 'when user is not an admin' do + it 'returns an error' do + mutate! + + expect(graphql_data_at(:licenses_delete, :license)).to be_nil + expect(graphql_data_at(:licenses_delete, :errors, :error_code)).to include('MISSING_PERMISSION') + end + end +end diff --git a/extensions/ee/spec/services/licenses/create_service_spec.rb b/extensions/ee/spec/services/licenses/create_service_spec.rb new file mode 100644 index 00000000..53e166f5 --- /dev/null +++ b/extensions/ee/spec/services/licenses/create_service_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Licenses::CreateService, unless: Sagittarius::Extensions.cloud? do + subject(:service_response) { described_class.new(create_authentication(current_user), **params).execute } + + shared_examples 'does not create' do + it { is_expected.to be_error } + + it 'does not create namespace license' do + expect { service_response }.not_to change { License.count } + end + + it { expect { service_response }.not_to create_audit_event } + end + + context 'when user does not exist' do + let(:current_user) { nil } + # rubocop:disable RSpec/LetSetup + let!(:params) do + { data: create(:license).data } + end + + it_behaves_like 'does not create' + end + + context 'when params are invalid' do + let(:current_user) { create(:user, :admin) } + + let(:params) { { data: '' } } + + it_behaves_like 'does not create' + end + + context 'when user does not have permission' do + let(:current_user) { create(:user) } + + let!(:params) do + { data: create(:license).data } + end + + it_behaves_like 'does not create' + end + + context 'when user and params are valid' do + let(:current_user) { create(:user, :admin) } + let(:license_data) do + { + licensee: { 'company' => 'Code0' }, + start_date: (Time.zone.today - 1).to_s, + end_date: (Time.zone.today + 1).to_s, + restrictions: {}, + options: {}, + } + end + + let!(:params) do + { data: create(:license, **license_data).data } + end + # rubocop:enable RSpec/LetSetup + + it { is_expected.to be_success } + it { expect(service_response.payload.reload).to be_valid } + + it 'adds license to the namespace' do + expect { service_response }.to change { License.count }.by(1) + end + + it do + is_expected.to create_audit_event( + :license_created, + author_id: current_user.id, + entity_type: 'License', + details: license_data, + target_id: 0, + target_type: 'global' + ) + end + end +end diff --git a/extensions/ee/spec/services/licenses/delete_service_spec.rb b/extensions/ee/spec/services/licenses/delete_service_spec.rb new file mode 100644 index 00000000..dff8b503 --- /dev/null +++ b/extensions/ee/spec/services/licenses/delete_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Licenses::DeleteService, unless: Sagittarius::Extensions.cloud? do + subject(:service_response) { described_class.new(create_authentication(current_user), **params).execute } + + shared_examples 'does not delete' do + it { is_expected.to be_error } + + it 'does not delete namespace license' do + expect { service_response }.not_to change { License.count } + end + + it { expect { service_response }.not_to create_audit_event } + end + + context 'when user does not exist' do + let(:current_user) { nil } + # rubocop:disable RSpec/LetSetup + let!(:params) do + { license: create(:license) } + end + + it_behaves_like 'does not delete' + end + + context 'when user and params are valid' do + let(:current_user) { create(:user, :admin) } + let!(:license) { create(:license) } + + let!(:params) do + { license: license } + end + # rubocop:enable RSpec/LetSetup + + it { is_expected.to be_success } + + it 'removes license to the namespace' do + expect { service_response }.to change { License.count }.by(-1) + end + + it do + is_expected.to create_audit_event( + :license_deleted, + author_id: current_user.id, + entity_type: 'License', + details: {}, + target_id: 0, + target_type: 'global' + ) + end + end +end diff --git a/spec/graphql/types/application_type_spec.rb b/spec/graphql/types/application_type_spec.rb index 732ad63b..02a1af04 100644 --- a/spec/graphql/types/application_type_spec.rb +++ b/spec/graphql/types/application_type_spec.rb @@ -15,5 +15,5 @@ end it { expect(described_class.graphql_name).to eq('Application') } - it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to have_graphql_fields(fields).allow_unexpected_if_extended } end From 615a1230159e4dad584a0a4f23fe2c8cb9a8dd3b Mon Sep 17 00:00:00 2001 From: Niklas van Schrick Date: Wed, 29 Apr 2026 22:38:56 +0200 Subject: [PATCH 3/4] Add missing production-boot for cloud extension --- .gitlab-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 769e71b9..84a3e81d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -117,6 +117,7 @@ boot:production-mode: - EDITION: - ce - ee + - cloud variables: POSTGRES_DB: sagittarius_production POSTGRES_USER: sagittarius @@ -124,7 +125,8 @@ boot:production-mode: RAILS_ENV: production BUNDLE_WITHOUT: 'development:test' script: - - '[[ "$EDITION" = "ee" ]] || rm -rf extensions/ee/' + - '[[ "$EDITION" = "ce" ]] && rm -rf extensions/{ee,cloud}/' + - '[[ "$EDITION" = "ee" ]] && rm -rf extensions/cloud' - bundle exec rake db:prepare db:seed_fu - bin/rails s & - curl --fail -sv --retry 20 --retry-delay 3 --retry-connrefused http://127.0.0.1:3000/health/liveness From 8177a65946b5aae48856bdcde9c07dee6c3f220d Mon Sep 17 00:00:00 2001 From: Niklas van Schrick Date: Thu, 30 Apr 2026 23:03:57 +0200 Subject: [PATCH 4/4] Finalize license migration --- .../graphql/ee/types/mutation_type_spec.rb | 7 +++ .../services/licenses/create_service_spec.rb | 4 +- .../services/licenses/delete_service_spec.rb | 4 +- spec/lib/sagittarius/extensions_spec.rb | 53 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 extensions/ee/spec/graphql/ee/types/mutation_type_spec.rb diff --git a/extensions/ee/spec/graphql/ee/types/mutation_type_spec.rb b/extensions/ee/spec/graphql/ee/types/mutation_type_spec.rb new file mode 100644 index 00000000..6b02d947 --- /dev/null +++ b/extensions/ee/spec/graphql/ee/types/mutation_type_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SagittariusSchema.types['Mutation'] do + it { expect(described_class).to include_module(EE::Types::MutationType) } +end diff --git a/extensions/ee/spec/services/licenses/create_service_spec.rb b/extensions/ee/spec/services/licenses/create_service_spec.rb index 53e166f5..994ad1ca 100644 --- a/extensions/ee/spec/services/licenses/create_service_spec.rb +++ b/extensions/ee/spec/services/licenses/create_service_spec.rb @@ -8,7 +8,7 @@ shared_examples 'does not create' do it { is_expected.to be_error } - it 'does not create namespace license' do + it 'does not create license' do expect { service_response }.not_to change { License.count } end @@ -63,7 +63,7 @@ it { is_expected.to be_success } it { expect(service_response.payload.reload).to be_valid } - it 'adds license to the namespace' do + it 'adds license' do expect { service_response }.to change { License.count }.by(1) end diff --git a/extensions/ee/spec/services/licenses/delete_service_spec.rb b/extensions/ee/spec/services/licenses/delete_service_spec.rb index dff8b503..1f00c4d5 100644 --- a/extensions/ee/spec/services/licenses/delete_service_spec.rb +++ b/extensions/ee/spec/services/licenses/delete_service_spec.rb @@ -8,7 +8,7 @@ shared_examples 'does not delete' do it { is_expected.to be_error } - it 'does not delete namespace license' do + it 'does not delete license' do expect { service_response }.not_to change { License.count } end @@ -36,7 +36,7 @@ it { is_expected.to be_success } - it 'removes license to the namespace' do + it 'removes license' do expect { service_response }.to change { License.count }.by(-1) end diff --git a/spec/lib/sagittarius/extensions_spec.rb b/spec/lib/sagittarius/extensions_spec.rb index d1337726..3a97907d 100644 --- a/spec/lib/sagittarius/extensions_spec.rb +++ b/spec/lib/sagittarius/extensions_spec.rb @@ -53,6 +53,31 @@ def stub_cloud_disabled(value) expect(described_class.active).not_to include(:ee) end end + + context 'when cloud exists' do + it do + stub_extension_exist('cloud', true) + stub_cloud_disabled(false) + expect(described_class.active).to include(:cloud) + end + + context 'when disabled with env' do + it do + stub_extension_exist('cloud', true) + stub_cloud_disabled(true) + + expect(described_class.active).not_to include(:cloud) + end + end + end + + context 'when cloud does not exist' do + it do + stub_extension_exist('cloud', false) + stub_cloud_disabled(false) + expect(described_class.active).not_to include(:cloud) + end + end end describe '.ee?' do @@ -82,4 +107,32 @@ def stub_cloud_disabled(value) expect { |block| described_class.ee(&block) }.not_to yield_control end end + + describe '.cloud?' do + it 'returns true when cloud exists' do + stub_extension_exist('cloud', true) + stub_cloud_disabled(false) + expect(described_class.cloud?).to be(true) + end + + it 'returns false when cloud does not exist' do + stub_extension_exist('cloud', false) + stub_cloud_disabled(false) + expect(described_class.cloud?).to be(false) + end + end + + describe '.cloud' do + it 'yields when cloud exists' do + stub_extension_exist('cloud', true) + stub_cloud_disabled(false) + expect { |block| described_class.cloud(&block) }.to yield_control + end + + it 'does not yield when cloud does not exist' do + stub_extension_exist('cloud', false) + stub_cloud_disabled(false) + expect { |block| described_class.cloud(&block) }.not_to yield_control + end + end end