From fe43d1b73a677813aba1a619eebfdbd45ffc24df Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 25 May 2026 16:05:02 +0200 Subject: [PATCH 1/5] feat: created module configuration table --- ...0525100000_create_module_configurations.rb | 23 ++++ db/schema_migrations/20260525100000 | 1 + db/structure.sql | 122 ++++++++++++++++-- 3 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 db/migrate/20260525100000_create_module_configurations.rb create mode 100644 db/schema_migrations/20260525100000 diff --git a/db/migrate/20260525100000_create_module_configurations.rb b/db/migrate/20260525100000_create_module_configurations.rb new file mode 100644 index 00000000..867dd80b --- /dev/null +++ b/db/migrate/20260525100000_create_module_configurations.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CreateModuleConfigurations < Code0::ZeroTrack::Database::Migration[1.0] + def change + create_table :module_configurations do |t| + t.references :namespace_project_runtime_assignment, + null: false, + foreign_key: { on_delete: :cascade }, + index: { name: 'idx_module_configs_on_assignment_id' } + t.references :module_configuration_definition, + null: false, + foreign_key: { on_delete: :cascade }, + index: { name: 'idx_module_configs_on_definition_id' } + t.jsonb :value + + t.index %i[namespace_project_runtime_assignment_id module_configuration_definition_id], + unique: true, + name: 'idx_module_configs_on_assignment_id_and_definition_id' + + t.timestamps_with_timezone + end + end +end diff --git a/db/schema_migrations/20260525100000 b/db/schema_migrations/20260525100000 new file mode 100644 index 00000000..d2b10bf7 --- /dev/null +++ b/db/schema_migrations/20260525100000 @@ -0,0 +1 @@ +3f2823a860b64e5e3493e2338757dcef5a7860df08bfb9bf1e0063a5c08392ae \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 0375537b..65ac8379 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -205,7 +205,9 @@ CREATE TABLE flow_settings ( flow_setting_id text NOT NULL, object jsonb NOT NULL, created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL + updated_at timestamp with time zone NOT NULL, + "cast" text, + CONSTRAINT check_65f98666ae CHECK ((char_length("cast") <= 500)) ); CREATE SEQUENCE flow_settings_id_seq @@ -465,6 +467,24 @@ CREATE SEQUENCE module_configuration_definitions_id_seq ALTER SEQUENCE module_configuration_definitions_id_seq OWNED BY module_configuration_definitions.id; +CREATE TABLE module_configurations ( + id bigint NOT NULL, + namespace_project_runtime_assignment_id bigint CONSTRAINT module_configurations_namespace_project_runtime_assign_not_null NOT NULL, + module_configuration_definition_id bigint CONSTRAINT module_configurations_module_configuration_definition__not_null NOT NULL, + value jsonb, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE module_configurations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE module_configurations_id_seq OWNED BY module_configurations.id; + CREATE TABLE namespace_member_roles ( id bigint CONSTRAINT organization_member_roles_id_not_null NOT NULL, role_id bigint CONSTRAINT organization_member_roles_role_id_not_null NOT NULL, @@ -614,8 +634,7 @@ CREATE TABLE node_functions ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, flow_id bigint NOT NULL, - function_definition_id bigint NOT NULL, - value_of_node_parameter_id bigint + function_definition_id bigint NOT NULL ); CREATE SEQUENCE node_functions_id_seq @@ -633,7 +652,9 @@ CREATE TABLE node_parameters ( literal_value jsonb, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - parameter_definition_id bigint NOT NULL + parameter_definition_id bigint NOT NULL, + "cast" text, + CONSTRAINT check_6439c80497 CHECK ((char_length("cast") <= 500)) ); CREATE SEQUENCE node_parameters_id_seq @@ -945,6 +966,47 @@ CREATE TABLE schema_migrations ( version character varying NOT NULL ); +CREATE TABLE sub_flow_settings ( + id bigint NOT NULL, + sub_flow_id bigint NOT NULL, + identifier text NOT NULL, + default_value jsonb, + optional boolean DEFAULT false NOT NULL, + hidden boolean DEFAULT false NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE sub_flow_settings_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE sub_flow_settings_id_seq OWNED BY sub_flow_settings.id; + +CREATE TABLE sub_flows ( + id bigint NOT NULL, + node_parameter_id bigint NOT NULL, + starting_node_id bigint, + function_definition_id bigint, + signature text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT check_53a99b1dd3 CHECK ((num_nonnulls(starting_node_id, function_definition_id) = 1)), + CONSTRAINT check_943d01babb CHECK ((char_length(signature) <= 500)) +); + +CREATE SEQUENCE sub_flows_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE sub_flows_id_seq OWNED BY sub_flows.id; + CREATE TABLE translations ( id bigint NOT NULL, code text NOT NULL, @@ -1068,6 +1130,8 @@ ALTER TABLE ONLY module_configuration_definition_data_type_links ALTER COLUMN id ALTER TABLE ONLY module_configuration_definitions ALTER COLUMN id SET DEFAULT nextval('module_configuration_definitions_id_seq'::regclass); +ALTER TABLE ONLY module_configurations ALTER COLUMN id SET DEFAULT nextval('module_configurations_id_seq'::regclass); + ALTER TABLE ONLY namespace_member_roles ALTER COLUMN id SET DEFAULT nextval('namespace_member_roles_id_seq'::regclass); ALTER TABLE ONLY namespace_members ALTER COLUMN id SET DEFAULT nextval('namespace_members_id_seq'::regclass); @@ -1116,6 +1180,10 @@ ALTER TABLE ONLY runtime_statuses ALTER COLUMN id SET DEFAULT nextval('runtime_s ALTER TABLE ONLY runtimes ALTER COLUMN id SET DEFAULT nextval('runtimes_id_seq'::regclass); +ALTER TABLE ONLY sub_flow_settings ALTER COLUMN id SET DEFAULT nextval('sub_flow_settings_id_seq'::regclass); + +ALTER TABLE ONLY sub_flows ALTER COLUMN id SET DEFAULT nextval('sub_flows_id_seq'::regclass); + ALTER TABLE ONLY translations ALTER COLUMN id SET DEFAULT nextval('translations_id_seq'::regclass); ALTER TABLE ONLY user_identities ALTER COLUMN id SET DEFAULT nextval('user_identities_id_seq'::regclass); @@ -1199,6 +1267,9 @@ ALTER TABLE ONLY module_configuration_definition_data_type_links ALTER TABLE ONLY module_configuration_definitions ADD CONSTRAINT module_configuration_definitions_pkey PRIMARY KEY (id); +ALTER TABLE ONLY module_configurations + ADD CONSTRAINT module_configurations_pkey PRIMARY KEY (id); + ALTER TABLE ONLY namespace_member_roles ADD CONSTRAINT namespace_member_roles_pkey PRIMARY KEY (id); @@ -1274,6 +1345,12 @@ ALTER TABLE ONLY runtimes ALTER TABLE ONLY schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); +ALTER TABLE ONLY sub_flow_settings + ADD CONSTRAINT sub_flow_settings_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY sub_flows + ADD CONSTRAINT sub_flows_pkey PRIMARY KEY (id); + ALTER TABLE ONLY translations ADD CONSTRAINT translations_pkey PRIMARY KEY (id); @@ -1294,6 +1371,12 @@ CREATE UNIQUE INDEX idx_function_definitions_on_runtime_id_identifier ON functio CREATE UNIQUE INDEX idx_module_config_links_on_config_id_data_type_id ON module_configuration_definition_data_type_links USING btree (module_configuration_definition_id, referenced_data_type_id); +CREATE INDEX idx_module_configs_on_assignment_id ON module_configurations USING btree (namespace_project_runtime_assignment_id); + +CREATE UNIQUE INDEX idx_module_configs_on_assignment_id_and_definition_id ON module_configurations USING btree (namespace_project_runtime_assignment_id, module_configuration_definition_id); + +CREATE INDEX idx_module_configs_on_definition_id ON module_configurations USING btree (module_configuration_definition_id); + CREATE UNIQUE INDEX idx_module_configs_on_module_id_identifier ON module_configuration_definitions USING btree (runtime_module_id, identifier); CREATE UNIQUE INDEX idx_on_data_type_id_referenced_data_type_id_bb9b090c90 ON data_type_data_type_links USING btree (data_type_id, referenced_data_type_id); @@ -1442,8 +1525,6 @@ CREATE INDEX index_node_functions_on_function_definition_id ON node_functions US CREATE INDEX index_node_functions_on_next_node_id ON node_functions USING btree (next_node_id); -CREATE INDEX index_node_functions_on_value_of_node_parameter_id ON node_functions USING btree (value_of_node_parameter_id); - CREATE INDEX index_node_parameters_on_node_function_id ON node_parameters USING btree (node_function_id); CREATE INDEX index_node_parameters_on_parameter_definition_id ON node_parameters USING btree (parameter_definition_id); @@ -1470,6 +1551,14 @@ CREATE INDEX index_runtimes_on_namespace_id ON runtimes USING btree (namespace_i CREATE UNIQUE INDEX index_runtimes_on_token ON runtimes USING btree (token); +CREATE INDEX index_sub_flow_settings_on_sub_flow_id ON sub_flow_settings USING btree (sub_flow_id); + +CREATE INDEX index_sub_flows_on_function_definition_id ON sub_flows USING btree (function_definition_id); + +CREATE UNIQUE INDEX index_sub_flows_on_node_parameter_id ON sub_flows USING btree (node_parameter_id); + +CREATE INDEX index_sub_flows_on_starting_node_id ON sub_flows USING btree (starting_node_id); + CREATE INDEX index_translations_on_owner ON translations USING btree (owner_type, owner_id); CREATE UNIQUE INDEX index_user_identities_on_provider_id_and_identifier ON user_identities USING btree (provider_id, identifier); @@ -1512,6 +1601,9 @@ ALTER TABLE ONLY function_definitions ALTER TABLE ONLY node_parameters ADD CONSTRAINT fk_rails_2ed7c53167 FOREIGN KEY (parameter_definition_id) REFERENCES parameter_definitions(id) ON DELETE RESTRICT; +ALTER TABLE ONLY sub_flows + ADD CONSTRAINT fk_rails_32ab48790a FOREIGN KEY (node_parameter_id) REFERENCES node_parameters(id) ON DELETE CASCADE; + ALTER TABLE ONLY runtime_flow_types ADD CONSTRAINT fk_rails_3675f29c4e FOREIGN KEY (runtime_id) REFERENCES runtimes(id) ON DELETE CASCADE; @@ -1533,9 +1625,15 @@ ALTER TABLE ONLY parameter_definitions ALTER TABLE ONLY module_configuration_definition_data_type_links ADD CONSTRAINT fk_rails_42593aae68 FOREIGN KEY (module_configuration_definition_id) REFERENCES module_configuration_definitions(id) ON DELETE CASCADE; +ALTER TABLE ONLY module_configurations + ADD CONSTRAINT fk_rails_42e0cac371 FOREIGN KEY (module_configuration_definition_id) REFERENCES module_configuration_definitions(id) ON DELETE CASCADE; + ALTER TABLE ONLY data_type_data_type_links ADD CONSTRAINT fk_rails_443c90661b FOREIGN KEY (referenced_data_type_id) REFERENCES data_types(id) ON DELETE RESTRICT; +ALTER TABLE ONLY module_configurations + ADD CONSTRAINT fk_rails_47f7323aca FOREIGN KEY (namespace_project_runtime_assignment_id) REFERENCES namespace_project_runtime_assignments(id) ON DELETE CASCADE; + ALTER TABLE ONLY function_definitions ADD CONSTRAINT fk_rails_48f4bbe3b6 FOREIGN KEY (runtime_function_definition_id) REFERENCES runtime_function_definitions(id) ON DELETE CASCADE; @@ -1554,6 +1652,9 @@ ALTER TABLE ONLY node_functions ALTER TABLE ONLY backup_codes ADD CONSTRAINT fk_rails_556c1feac3 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY sub_flow_settings + ADD CONSTRAINT fk_rails_55f76c79cc FOREIGN KEY (sub_flow_id) REFERENCES sub_flows(id) ON DELETE CASCADE; + ALTER TABLE ONLY namespace_members ADD CONSTRAINT fk_rails_567f152a62 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; @@ -1629,6 +1730,9 @@ ALTER TABLE ONLY user_sessions ALTER TABLE ONLY namespace_members ADD CONSTRAINT fk_rails_a0a760b9b4 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY sub_flows + ADD CONSTRAINT fk_rails_a99aa3478f FOREIGN KEY (function_definition_id) REFERENCES function_definitions(id) ON DELETE RESTRICT; + ALTER TABLE ONLY flows ADD CONSTRAINT fk_rails_ab927e0ecb FOREIGN KEY (project_id) REFERENCES namespace_projects(id) ON DELETE CASCADE; @@ -1662,6 +1766,9 @@ ALTER TABLE ONLY flows ALTER TABLE ONLY flow_settings ADD CONSTRAINT fk_rails_da3b2fb3c5 FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE CASCADE; +ALTER TABLE ONLY sub_flows + ADD CONSTRAINT fk_rails_e27dd4d82a FOREIGN KEY (starting_node_id) REFERENCES node_functions(id) ON DELETE RESTRICT; + ALTER TABLE ONLY runtime_flow_types ADD CONSTRAINT fk_rails_e729dc57e7 FOREIGN KEY (runtime_module_id) REFERENCES runtime_modules(id) ON DELETE CASCADE; @@ -1674,9 +1781,6 @@ ALTER TABLE ONLY runtimes ALTER TABLE ONLY flow_data_type_links ADD CONSTRAINT fk_rails_f4202724d3 FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE CASCADE; -ALTER TABLE ONLY node_functions - ADD CONSTRAINT fk_rails_f5d1a9d316 FOREIGN KEY (value_of_node_parameter_id) REFERENCES node_parameters(id) ON DELETE CASCADE; - ALTER TABLE ONLY audit_events ADD CONSTRAINT fk_rails_f64374fc56 FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL; From a41c4beb05c42eeefd137437eaf7d21750720156 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 25 May 2026 16:07:38 +0200 Subject: [PATCH 2/5] feat: added module configuration service --- .../update_module_configurations.rb | 41 ++++++ .../input/module_configuration_input_type.rb | 16 +++ .../types/module_configuration_type.rb | 20 +++ app/graphql/types/mutation_type.rb | 1 + ...mespace_project_runtime_assignment_type.rb | 18 +++ app/graphql/types/namespace_project_type.rb | 4 +- app/grpc/flow_handler.rb | 53 +++++++- app/models/audit_event.rb | 1 + app/models/module_configuration.rb | 29 +++++ app/models/module_configuration_definition.rb | 1 + .../namespace_project_runtime_assignment.rb | 4 + app/models/runtime.rb | 1 + app/policies/module_configuration_policy.rb | 8 ++ ...space_project_runtime_assignment_policy.rb | 8 ++ app/services/error_code.rb | 1 + .../update_module_configurations_service.rb | 101 +++++++++++++++ docs/graphql/enum/errorcodeenum.md | 1 + .../input_object/moduleconfigurationinput.md | 12 ++ ...meassignmentsupdatemoduleconfigurations.md | 21 +++ docs/graphql/object/moduleconfiguration.md | 15 +++ .../object/moduleconfigurationconnection.md | 14 ++ .../graphql/object/moduleconfigurationedge.md | 12 ++ docs/graphql/object/namespaceproject.md | 2 +- .../namespaceprojectruntimeassignment.md | 16 +++ ...spaceprojectruntimeassignmentconnection.md | 14 ++ .../namespaceprojectruntimeassignmentedge.md | 12 ++ docs/graphql/scalar/moduleconfigurationid.md | 5 + .../namespaceprojectruntimeassignmentid.md | 5 + spec/factories/module_configurations.rb | 12 ++ .../types/module_configuration_type_spec.rb | 19 +++ ...ce_project_runtime_assignment_type_spec.rb | 20 +++ .../types/namespace_project_type_spec.rb | 2 +- spec/models/module_configuration_spec.rb | 42 ++++++ ...mespace_project_runtime_assignment_spec.rb | 1 + ...ate_module_configurations_mutation_spec.rb | 121 ++++++++++++++++++ ..._project_runtime_assignments_query_spec.rb | 89 +++++++++++++ ...date_module_configurations_service_spec.rb | 107 ++++++++++++++++ 37 files changed, 845 insertions(+), 4 deletions(-) create mode 100644 app/graphql/mutations/namespaces/projects/runtime_assignments/update_module_configurations.rb create mode 100644 app/graphql/types/input/module_configuration_input_type.rb create mode 100644 app/graphql/types/module_configuration_type.rb create mode 100644 app/graphql/types/namespace_project_runtime_assignment_type.rb create mode 100644 app/models/module_configuration.rb create mode 100644 app/policies/module_configuration_policy.rb create mode 100644 app/policies/namespace_project_runtime_assignment_policy.rb create mode 100644 app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb create mode 100644 docs/graphql/input_object/moduleconfigurationinput.md create mode 100644 docs/graphql/mutation/namespacesprojectsruntimeassignmentsupdatemoduleconfigurations.md create mode 100644 docs/graphql/object/moduleconfiguration.md create mode 100644 docs/graphql/object/moduleconfigurationconnection.md create mode 100644 docs/graphql/object/moduleconfigurationedge.md create mode 100644 docs/graphql/object/namespaceprojectruntimeassignment.md create mode 100644 docs/graphql/object/namespaceprojectruntimeassignmentconnection.md create mode 100644 docs/graphql/object/namespaceprojectruntimeassignmentedge.md create mode 100644 docs/graphql/scalar/moduleconfigurationid.md create mode 100644 docs/graphql/scalar/namespaceprojectruntimeassignmentid.md create mode 100644 spec/factories/module_configurations.rb create mode 100644 spec/graphql/types/module_configuration_type_spec.rb create mode 100644 spec/graphql/types/namespace_project_runtime_assignment_type_spec.rb create mode 100644 spec/models/module_configuration_spec.rb create mode 100644 spec/requests/graphql/mutation/namespace/projects/runtime_assignments/update_module_configurations_mutation_spec.rb create mode 100644 spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb create mode 100644 spec/services/namespaces/projects/runtime_assignments/update_module_configurations_service_spec.rb diff --git a/app/graphql/mutations/namespaces/projects/runtime_assignments/update_module_configurations.rb b/app/graphql/mutations/namespaces/projects/runtime_assignments/update_module_configurations.rb new file mode 100644 index 00000000..9305dc71 --- /dev/null +++ b/app/graphql/mutations/namespaces/projects/runtime_assignments/update_module_configurations.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Mutations + module Namespaces + module Projects + module RuntimeAssignments + class UpdateModuleConfigurations < BaseMutation + description 'Updates the saved module configurations for a project runtime assignment.' + + argument :module_configurations, [Types::Input::ModuleConfigurationInputType], + required: true, + description: 'The full set of saved module configurations for this assignment.' + argument :namespace_project_runtime_assignment_id, + Types::GlobalIdType[::NamespaceProjectRuntimeAssignment], + description: 'The project runtime assignment to update.' + + field :namespace_project_runtime_assignment, Types::NamespaceProjectRuntimeAssignmentType, + null: true, + description: 'The updated project runtime assignment.' + + def resolve(namespace_project_runtime_assignment_id:, module_configurations:) + runtime_assignment = SagittariusSchema.object_from_id(namespace_project_runtime_assignment_id) + + if runtime_assignment.nil? + return { + namespace_project_runtime_assignment: nil, + errors: [create_error(:runtime_not_assigned, 'Invalid project runtime assignment')], + } + end + + ::Namespaces::Projects::RuntimeAssignments::UpdateModuleConfigurationsService.new( + current_authentication, + runtime_assignment, + module_configurations + ).execute.to_mutation_response(success_key: :namespace_project_runtime_assignment) + end + end + end + end + end +end diff --git a/app/graphql/types/input/module_configuration_input_type.rb b/app/graphql/types/input/module_configuration_input_type.rb new file mode 100644 index 00000000..beebcd1f --- /dev/null +++ b/app/graphql/types/input/module_configuration_input_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Input + class ModuleConfigurationInputType < Types::BaseInputObject + description 'Input type for saving a module configuration value.' + + argument :module_configuration_definition_id, Types::GlobalIdType[::ModuleConfigurationDefinition], + required: true, + description: 'The configuration definition to save a value for.' + argument :value, GraphQL::Types::JSON, + required: false, + description: 'The saved configuration value.' + end + end +end diff --git a/app/graphql/types/module_configuration_type.rb b/app/graphql/types/module_configuration_type.rb new file mode 100644 index 00000000..4c467906 --- /dev/null +++ b/app/graphql/types/module_configuration_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + class ModuleConfigurationType < Types::BaseObject + description 'Represents a saved module configuration value for a project runtime assignment.' + + authorize :read_module_configuration + + field :definition, Types::ModuleConfigurationDefinitionType, + null: false, + method: :module_configuration_definition, + description: 'The configuration definition this saved value belongs to.' + field :value, GraphQL::Types::JSON, + null: true, + description: 'The saved configuration value.' + + id_field ModuleConfiguration + timestamps + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index b3833d21..3381b749 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -13,6 +13,7 @@ class MutationType < Types::BaseObject mount_mutation Mutations::Namespaces::Projects::AssignRuntimes mount_mutation Mutations::Namespaces::Projects::Create mount_mutation Mutations::Namespaces::Projects::Delete + mount_mutation Mutations::Namespaces::Projects::RuntimeAssignments::UpdateModuleConfigurations mount_mutation Mutations::Namespaces::Projects::Update mount_mutation Mutations::Namespaces::Projects::Flows::Create mount_mutation Mutations::Namespaces::Projects::Flows::Delete diff --git a/app/graphql/types/namespace_project_runtime_assignment_type.rb b/app/graphql/types/namespace_project_runtime_assignment_type.rb new file mode 100644 index 00000000..4ea1147b --- /dev/null +++ b/app/graphql/types/namespace_project_runtime_assignment_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + class NamespaceProjectRuntimeAssignmentType < Types::BaseObject + description 'Represents a runtime assignment for a project.' + + authorize :read_namespace_project_runtime_assignment + + field :compatible, Boolean, null: false, description: 'Whether the assigned runtime is compatible.' + field :module_configurations, Types::ModuleConfigurationType.connection_type, + null: false, + description: 'Saved module configuration values for this project runtime assignment.' + field :runtime, Types::RuntimeType, null: false, description: 'The assigned runtime.' + + id_field NamespaceProjectRuntimeAssignment + timestamps + end +end diff --git a/app/graphql/types/namespace_project_type.rb b/app/graphql/types/namespace_project_type.rb index c03de707..d6835c1a 100644 --- a/app/graphql/types/namespace_project_type.rb +++ b/app/graphql/types/namespace_project_type.rb @@ -12,7 +12,9 @@ class NamespaceProjectType < Types::BaseObject field :slug, String, null: false, description: 'Slug of the project used in URLs to identify flows' - field :runtimes, Types::RuntimeType.connection_type, null: false, description: 'Runtimes assigned to this project' + field :runtime_assignments, Types::NamespaceProjectRuntimeAssignmentType.connection_type, + null: false, + description: 'Runtime assignments of this project.' field :roles, Types::NamespaceRoleType.connection_type, null: false, description: 'Roles assigned to this project', diff --git a/app/grpc/flow_handler.rb b/app/grpc/flow_handler.rb index e570549d..4861a86d 100644 --- a/app/grpc/flow_handler.rb +++ b/app/grpc/flow_handler.rb @@ -8,8 +8,13 @@ class FlowHandler < Tucana::Sagittarius::FlowService::Service grpc_stream :update def self.update_runtime(runtime) + assignments = runtime.project_assignments.compatible.includes( + :namespace_project, + module_configurations: { module_configuration_definition: :runtime_module } + ) + flows = [] - runtime.project_assignments.compatible.each do |assignment| + assignments.each do |assignment| assignment.namespace_project.flows.validation_status_valid.each do |flow| flows << flow.to_grpc end @@ -23,6 +28,15 @@ def self.update_runtime(runtime) ), runtime.id ) + + grouped_module_configurations(assignments).each do |module_configuration| + send_update( + Tucana::Sagittarius::FlowResponse.new( + module_configurations: module_configuration + ), + runtime.id + ) + end end def self.update_started(runtime_id) @@ -34,6 +48,43 @@ def self.update_started(runtime_id) update_runtime(runtime) end + def self.grouped_module_configurations(assignments) + grouped_entries = assignments.flat_map do |assignment| + assignment.module_configurations.map do |configuration| + [ + configuration.module_configuration_definition.runtime_module.identifier, + assignment, + configuration + ] + end + end.group_by(&:first) + + grouped_entries.sort_by(&:first).map do |module_identifier, entries| + Tucana::Shared::ModuleConfigurations.new( + module_identifier: module_identifier, + module_configurations: grouped_project_configurations(entries) + ) + end + end + + def self.grouped_project_configurations(entries) + entries.group_by { |_, assignment, _| assignment.id } + .sort_by { |_, grouped_entries| grouped_entries.first[1].namespace_project_id } + .map do |_, grouped_entries| + assignment = grouped_entries.first[1] + Tucana::Shared::ModuleProjectConfigurations.new( + project_id: assignment.namespace_project_id, + module_configurations: grpc_module_configurations(grouped_entries) + ) + end + end + + def self.grpc_module_configurations(entries) + entries.map(&:last) + .sort_by { |configuration| configuration.module_configuration_definition.identifier } + .map(&:to_grpc) + end + def self.encoders = { update: ->(grpc_object) { Tucana::Sagittarius::FlowResponse.encode(grpc_object) } } def self.decoders = { update: ->(string) { Tucana::Sagittarius::FlowResponse.decode(string) } } diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 332d468a..d8d66d16 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -41,6 +41,7 @@ class AuditEvent < ApplicationRecord password_reset: 37, user_deleted: 38, user_created: 39, + project_module_configurations_updated: 40, }.with_indifferent_access # rubocop:disable Lint/StructNewOverride diff --git a/app/models/module_configuration.rb b/app/models/module_configuration.rb new file mode 100644 index 00000000..7cd1abbb --- /dev/null +++ b/app/models/module_configuration.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ModuleConfiguration < ApplicationRecord + belongs_to :namespace_project_runtime_assignment, inverse_of: :module_configurations + belongs_to :module_configuration_definition, inverse_of: :module_configurations + + validates :module_configuration_definition_id, + uniqueness: { scope: :namespace_project_runtime_assignment_id } + validate :validate_runtime + + def to_grpc + Tucana::Shared::ModuleConfiguration.new( + identifier: module_configuration_definition.identifier, + value: Tucana::Shared::Value.from_ruby(value) + ) + end + + private + + def validate_runtime + return if namespace_project_runtime_assignment.nil? || module_configuration_definition.nil? + + definition_runtime_id = module_configuration_definition.runtime_module.runtime_id + assignment_runtime_id = namespace_project_runtime_assignment.runtime_id + return if definition_runtime_id == assignment_runtime_id + + errors.add(:module_configuration_definition, 'must belong to the assigned runtime') + end +end diff --git a/app/models/module_configuration_definition.rb b/app/models/module_configuration_definition.rb index bad2405d..a40fbb42 100644 --- a/app/models/module_configuration_definition.rb +++ b/app/models/module_configuration_definition.rb @@ -7,6 +7,7 @@ class ModuleConfigurationDefinition < ApplicationRecord belongs_to :runtime_module, inverse_of: :module_configuration_definitions + has_many :module_configurations, inverse_of: :module_configuration_definition has_many :module_configuration_definition_data_type_links, inverse_of: :module_configuration_definition has_many :referenced_data_types, through: :module_configuration_definition_data_type_links, diff --git a/app/models/namespace_project_runtime_assignment.rb b/app/models/namespace_project_runtime_assignment.rb index 12832568..9aa50b78 100644 --- a/app/models/namespace_project_runtime_assignment.rb +++ b/app/models/namespace_project_runtime_assignment.rb @@ -4,6 +4,10 @@ class NamespaceProjectRuntimeAssignment < ApplicationRecord belongs_to :runtime, inverse_of: :project_assignments belongs_to :namespace_project, inverse_of: :runtime_assignments + has_many :module_configurations, + inverse_of: :namespace_project_runtime_assignment, + dependent: :destroy + validates :runtime, uniqueness: { scope: :namespace_project_id } validate :validate_namespaces, if: :runtime_changed? diff --git a/app/models/runtime.rb b/app/models/runtime.rb index 9cb85dd8..7f7dcd25 100644 --- a/app/models/runtime.rb +++ b/app/models/runtime.rb @@ -15,6 +15,7 @@ class Runtime < ApplicationRecord has_many :primary_projects, class_name: 'NamespaceProject', inverse_of: :primary_runtime has_many :runtime_modules, inverse_of: :runtime + has_many :module_configuration_definitions, through: :runtime_modules has_many :data_types, inverse_of: :runtime diff --git a/app/policies/module_configuration_policy.rb b/app/policies/module_configuration_policy.rb new file mode 100644 index 00000000..b75cc8f5 --- /dev/null +++ b/app/policies/module_configuration_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ModuleConfigurationPolicy < BasePolicy + delegate { subject.namespace_project_runtime_assignment } + + rule { can?(:read_namespace_project_runtime_assignment) }.enable :read_module_configuration + rule { can?(:update_namespace_project_runtime_assignment) }.enable :update_module_configuration +end diff --git a/app/policies/namespace_project_runtime_assignment_policy.rb b/app/policies/namespace_project_runtime_assignment_policy.rb new file mode 100644 index 00000000..e4fb87be --- /dev/null +++ b/app/policies/namespace_project_runtime_assignment_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class NamespaceProjectRuntimeAssignmentPolicy < BasePolicy + delegate { subject.namespace_project } + + rule { can?(:read_namespace_project) }.enable :read_namespace_project_runtime_assignment + rule { can?(:assign_project_runtimes) }.enable :update_namespace_project_runtime_assignment +end diff --git a/app/services/error_code.rb b/app/services/error_code.rb index c94cd5db..d41430ae 100644 --- a/app/services/error_code.rb +++ b/app/services/error_code.rb @@ -78,6 +78,7 @@ def self.error_codes invalid_runtime_parameter_definition: { description: 'The runtime parameter definition is invalid' }, invalid_runtime_function_definition: { description: 'The runtime function definition is invalid' }, invalid_runtime_module: { description: 'The runtime module is invalid' }, + invalid_module_configuration: { description: 'The module configuration is invalid because of active model errors' }, invalid_module_configuration_definition: { description: 'The module configuration definition is invalid' }, invalid_function_definition: { description: 'The function definition is invalid' }, invalid_parameter_definition: { description: 'The parameter definition is invalid' }, diff --git a/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb b/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb new file mode 100644 index 00000000..269a10cc --- /dev/null +++ b/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Namespaces + module Projects + module RuntimeAssignments + class UpdateModuleConfigurationsService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :runtime_assignment, :module_configurations + + def initialize(current_authentication, runtime_assignment, module_configurations) + @current_authentication = current_authentication + @runtime_assignment = runtime_assignment + @module_configurations = module_configurations + end + + def execute + unless Ability.allowed?( + current_authentication, + :assign_project_runtimes, + runtime_assignment.namespace_project + ) + return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) + end + + response = transactional do |t| + db_configurations = update_configurations(t) + + AuditService.audit( + :project_module_configurations_updated, + author_id: current_authentication.user.id, + entity: runtime_assignment, + target: runtime_assignment.namespace_project, + details: { + runtime_assignment_id: runtime_assignment.id, + module_configurations: db_configurations.map do |configuration| + { + id: configuration.id, + module_configuration_definition_id: configuration.module_configuration_definition_id, + value: configuration.value, + } + end, + } + ) + + ServiceResponse.success(message: 'Updated module configurations', payload: runtime_assignment) + end + + return response if response.error? + + FlowHandler.update_runtime(runtime_assignment.runtime) + response + end + + private + + def update_configurations(t) + existing_configurations = runtime_assignment.module_configurations.index_by( + &:module_configuration_definition_id + ) + db_configurations = [] + kept_definition_ids = [] + + module_configurations.each do |configuration_input| + definition = runtime_assignment.runtime.module_configuration_definitions.find_by( + id: configuration_input.module_configuration_definition_id.model_id + ) + + if definition.nil? + t.rollback_and_return! ServiceResponse.error( + message: 'Invalid module configuration definition', + error_code: :invalid_module_configuration_definition + ) + end + + db_configuration = existing_configurations[definition.id] || runtime_assignment.module_configurations.build + db_configuration.module_configuration_definition = definition + db_configuration.value = configuration_input.try(:value) + + unless db_configuration.save + t.rollback_and_return! ServiceResponse.error( + message: 'Invalid module configuration', + error_code: :invalid_module_configuration, + details: db_configuration.errors + ) + end + + kept_definition_ids << definition.id + db_configurations << db_configuration + end + + runtime_assignment.module_configurations + .where.not(module_configuration_definition_id: kept_definition_ids) + .destroy_all + + db_configurations + end + end + end + end +end diff --git a/docs/graphql/enum/errorcodeenum.md b/docs/graphql/enum/errorcodeenum.md index 6aa35ecd..62d94294 100644 --- a/docs/graphql/enum/errorcodeenum.md +++ b/docs/graphql/enum/errorcodeenum.md @@ -36,6 +36,7 @@ Represents the available error responses | `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_MODULE_CONFIGURATION` | The module configuration is invalid because of active model errors | | `INVALID_MODULE_CONFIGURATION_DEFINITION` | The module configuration definition is invalid | | `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 | diff --git a/docs/graphql/input_object/moduleconfigurationinput.md b/docs/graphql/input_object/moduleconfigurationinput.md new file mode 100644 index 00000000..b44ebcce --- /dev/null +++ b/docs/graphql/input_object/moduleconfigurationinput.md @@ -0,0 +1,12 @@ +--- +title: ModuleConfigurationInput +--- + +Input type for saving a module configuration value. + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `moduleConfigurationDefinitionId` | [`ModuleConfigurationDefinitionID!`](../scalar/moduleconfigurationdefinitionid.md) | The configuration definition to save a value for. | +| `value` | [`JSON`](../scalar/json.md) | The saved configuration value. | diff --git a/docs/graphql/mutation/namespacesprojectsruntimeassignmentsupdatemoduleconfigurations.md b/docs/graphql/mutation/namespacesprojectsruntimeassignmentsupdatemoduleconfigurations.md new file mode 100644 index 00000000..014f999f --- /dev/null +++ b/docs/graphql/mutation/namespacesprojectsruntimeassignmentsupdatemoduleconfigurations.md @@ -0,0 +1,21 @@ +--- +title: namespacesProjectsRuntimeAssignmentsUpdateModuleConfigurations +--- + +Updates the saved module configurations for a project runtime assignment. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `moduleConfigurations` | [`[ModuleConfigurationInput!]!`](../input_object/moduleconfigurationinput.md) | The full set of saved module configurations for this assignment. | +| `namespaceProjectRuntimeAssignmentId` | [`NamespaceProjectRuntimeAssignmentID!`](../scalar/namespaceprojectruntimeassignmentid.md) | The project runtime assignment to update. | + +## 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. | +| `namespaceProjectRuntimeAssignment` | [`NamespaceProjectRuntimeAssignment`](../object/namespaceprojectruntimeassignment.md) | The updated project runtime assignment. | diff --git a/docs/graphql/object/moduleconfiguration.md b/docs/graphql/object/moduleconfiguration.md new file mode 100644 index 00000000..7fd3b7d0 --- /dev/null +++ b/docs/graphql/object/moduleconfiguration.md @@ -0,0 +1,15 @@ +--- +title: ModuleConfiguration +--- + +Represents a saved module configuration value for a project runtime assignment. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `createdAt` | [`Time!`](../scalar/time.md) | Time when this ModuleConfiguration was created | +| `definition` | [`ModuleConfigurationDefinition!`](../object/moduleconfigurationdefinition.md) | The configuration definition this saved value belongs to. | +| `id` | [`ModuleConfigurationID!`](../scalar/moduleconfigurationid.md) | Global ID of this ModuleConfiguration | +| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this ModuleConfiguration was last updated | +| `value` | [`JSON`](../scalar/json.md) | The saved configuration value. | diff --git a/docs/graphql/object/moduleconfigurationconnection.md b/docs/graphql/object/moduleconfigurationconnection.md new file mode 100644 index 00000000..f5d67412 --- /dev/null +++ b/docs/graphql/object/moduleconfigurationconnection.md @@ -0,0 +1,14 @@ +--- +title: ModuleConfigurationConnection +--- + +The connection type for ModuleConfiguration. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `count` | [`Int!`](../scalar/int.md) | Total count of collection. | +| `edges` | [`[ModuleConfigurationEdge]`](../object/moduleconfigurationedge.md) | A list of edges. | +| `nodes` | [`[ModuleConfiguration]`](../object/moduleconfiguration.md) | A list of nodes. | +| `pageInfo` | [`PageInfo!`](../object/pageinfo.md) | Information to aid in pagination. | diff --git a/docs/graphql/object/moduleconfigurationedge.md b/docs/graphql/object/moduleconfigurationedge.md new file mode 100644 index 00000000..2f6156d5 --- /dev/null +++ b/docs/graphql/object/moduleconfigurationedge.md @@ -0,0 +1,12 @@ +--- +title: ModuleConfigurationEdge +--- + +An edge in a connection. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `cursor` | [`String!`](../scalar/string.md) | A cursor for use in pagination. | +| `node` | [`ModuleConfiguration`](../object/moduleconfiguration.md) | The item at the end of the edge. | diff --git a/docs/graphql/object/namespaceproject.md b/docs/graphql/object/namespaceproject.md index 56a47619..6eb52610 100644 --- a/docs/graphql/object/namespaceproject.md +++ b/docs/graphql/object/namespaceproject.md @@ -16,7 +16,7 @@ Represents a namespace project | `namespace` | [`Namespace!`](../object/namespace.md) | The namespace where this project belongs to | | `primaryRuntime` | [`Runtime`](../object/runtime.md) | The primary runtime for the project | | `roles` | [`NamespaceRoleConnection!`](../object/namespaceroleconnection.md) | Roles assigned to this project | -| `runtimes` | [`RuntimeConnection!`](../object/runtimeconnection.md) | Runtimes assigned to this project | +| `runtimeAssignments` | [`NamespaceProjectRuntimeAssignmentConnection!`](../object/namespaceprojectruntimeassignmentconnection.md) | Runtime assignments of this project. | | `slug` | [`String!`](../scalar/string.md) | Slug of the project used in URLs to identify flows | | `updatedAt` | [`Time!`](../scalar/time.md) | Time when this NamespaceProject was last updated | | `userAbilities` | [`NamespaceProjectUserAbilities!`](../object/namespaceprojectuserabilities.md) | Abilities for the current user on this NamespaceProject | diff --git a/docs/graphql/object/namespaceprojectruntimeassignment.md b/docs/graphql/object/namespaceprojectruntimeassignment.md new file mode 100644 index 00000000..e3747416 --- /dev/null +++ b/docs/graphql/object/namespaceprojectruntimeassignment.md @@ -0,0 +1,16 @@ +--- +title: NamespaceProjectRuntimeAssignment +--- + +Represents a runtime assignment for a project. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `compatible` | [`Boolean!`](../scalar/boolean.md) | Whether the assigned runtime is compatible. | +| `createdAt` | [`Time!`](../scalar/time.md) | Time when this NamespaceProjectRuntimeAssignment was created | +| `id` | [`NamespaceProjectRuntimeAssignmentID!`](../scalar/namespaceprojectruntimeassignmentid.md) | Global ID of this NamespaceProjectRuntimeAssignment | +| `moduleConfigurations` | [`ModuleConfigurationConnection!`](../object/moduleconfigurationconnection.md) | Saved module configuration values for this project runtime assignment. | +| `runtime` | [`Runtime!`](../object/runtime.md) | The assigned runtime. | +| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this NamespaceProjectRuntimeAssignment was last updated | diff --git a/docs/graphql/object/namespaceprojectruntimeassignmentconnection.md b/docs/graphql/object/namespaceprojectruntimeassignmentconnection.md new file mode 100644 index 00000000..d0cef105 --- /dev/null +++ b/docs/graphql/object/namespaceprojectruntimeassignmentconnection.md @@ -0,0 +1,14 @@ +--- +title: NamespaceProjectRuntimeAssignmentConnection +--- + +The connection type for NamespaceProjectRuntimeAssignment. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `count` | [`Int!`](../scalar/int.md) | Total count of collection. | +| `edges` | [`[NamespaceProjectRuntimeAssignmentEdge]`](../object/namespaceprojectruntimeassignmentedge.md) | A list of edges. | +| `nodes` | [`[NamespaceProjectRuntimeAssignment]`](../object/namespaceprojectruntimeassignment.md) | A list of nodes. | +| `pageInfo` | [`PageInfo!`](../object/pageinfo.md) | Information to aid in pagination. | diff --git a/docs/graphql/object/namespaceprojectruntimeassignmentedge.md b/docs/graphql/object/namespaceprojectruntimeassignmentedge.md new file mode 100644 index 00000000..406830ac --- /dev/null +++ b/docs/graphql/object/namespaceprojectruntimeassignmentedge.md @@ -0,0 +1,12 @@ +--- +title: NamespaceProjectRuntimeAssignmentEdge +--- + +An edge in a connection. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `cursor` | [`String!`](../scalar/string.md) | A cursor for use in pagination. | +| `node` | [`NamespaceProjectRuntimeAssignment`](../object/namespaceprojectruntimeassignment.md) | The item at the end of the edge. | diff --git a/docs/graphql/scalar/moduleconfigurationid.md b/docs/graphql/scalar/moduleconfigurationid.md new file mode 100644 index 00000000..4d161e51 --- /dev/null +++ b/docs/graphql/scalar/moduleconfigurationid.md @@ -0,0 +1,5 @@ +--- +title: ModuleConfigurationID +--- + +A unique identifier for all ModuleConfiguration entities of the application diff --git a/docs/graphql/scalar/namespaceprojectruntimeassignmentid.md b/docs/graphql/scalar/namespaceprojectruntimeassignmentid.md new file mode 100644 index 00000000..069d7c8a --- /dev/null +++ b/docs/graphql/scalar/namespaceprojectruntimeassignmentid.md @@ -0,0 +1,5 @@ +--- +title: NamespaceProjectRuntimeAssignmentID +--- + +A unique identifier for all NamespaceProjectRuntimeAssignment entities of the application diff --git a/spec/factories/module_configurations.rb b/spec/factories/module_configurations.rb new file mode 100644 index 00000000..8d192fa6 --- /dev/null +++ b/spec/factories/module_configurations.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :module_configuration do + namespace_project_runtime_assignment + module_configuration_definition do + association :module_configuration_definition, + runtime_module: association(:runtime_module, runtime: namespace_project_runtime_assignment.runtime) + end + value { 'configured-value' } + end +end diff --git a/spec/graphql/types/module_configuration_type_spec.rb b/spec/graphql/types/module_configuration_type_spec.rb new file mode 100644 index 00000000..d9e51903 --- /dev/null +++ b/spec/graphql/types/module_configuration_type_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SagittariusSchema.types['ModuleConfiguration'] do + let(:fields) do + %w[ + id + definition + value + createdAt + updatedAt + ] + end + + it { expect(described_class.graphql_name).to eq('ModuleConfiguration') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_module_configuration) } +end diff --git a/spec/graphql/types/namespace_project_runtime_assignment_type_spec.rb b/spec/graphql/types/namespace_project_runtime_assignment_type_spec.rb new file mode 100644 index 00000000..6e639bcd --- /dev/null +++ b/spec/graphql/types/namespace_project_runtime_assignment_type_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SagittariusSchema.types['NamespaceProjectRuntimeAssignment'] do + let(:fields) do + %w[ + id + compatible + moduleConfigurations + runtime + createdAt + updatedAt + ] + end + + it { expect(described_class.graphql_name).to eq('NamespaceProjectRuntimeAssignment') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_namespace_project_runtime_assignment) } +end diff --git a/spec/graphql/types/namespace_project_type_spec.rb b/spec/graphql/types/namespace_project_type_spec.rb index 53530b7e..2b3d42fb 100644 --- a/spec/graphql/types/namespace_project_type_spec.rb +++ b/spec/graphql/types/namespace_project_type_spec.rb @@ -11,7 +11,7 @@ description namespace primary_runtime - runtimes + runtimeAssignments roles flows flow diff --git a/spec/models/module_configuration_spec.rb b/spec/models/module_configuration_spec.rb new file mode 100644 index 00000000..72cb808c --- /dev/null +++ b/spec/models/module_configuration_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ModuleConfiguration do + subject(:module_configuration) { create(:module_configuration) } + + describe 'associations' do + it { is_expected.to belong_to(:namespace_project_runtime_assignment).inverse_of(:module_configurations) } + it { is_expected.to belong_to(:module_configuration_definition).inverse_of(:module_configurations) } + end + + describe 'validations' do + it do + is_expected.to validate_uniqueness_of(:module_configuration_definition_id) + .scoped_to(:namespace_project_runtime_assignment_id) + end + + it 'requires the definition to belong to the assigned runtime' do + assignment = create(:namespace_project_runtime_assignment) + other_runtime = create(:runtime, namespace: assignment.namespace_project.namespace) + other_runtime_module = create(:runtime_module, runtime: other_runtime) + other_definition = create(:module_configuration_definition, runtime_module: other_runtime_module) + + module_configuration.module_configuration_definition = other_definition + + expect(module_configuration).not_to be_valid + expect(module_configuration.errors[:module_configuration_definition]).to include( + 'must belong to the assigned runtime' + ) + end + end + + describe '#to_grpc' do + it 'serializes the identifier and value' do + grpc_configuration = module_configuration.to_grpc + + expect(grpc_configuration.identifier).to eq(module_configuration.module_configuration_definition.identifier) + expect(grpc_configuration.value.to_ruby(true)).to eq(module_configuration.value) + end + end +end diff --git a/spec/models/namespace_project_runtime_assignment_spec.rb b/spec/models/namespace_project_runtime_assignment_spec.rb index 9789f845..c397a3f8 100644 --- a/spec/models/namespace_project_runtime_assignment_spec.rb +++ b/spec/models/namespace_project_runtime_assignment_spec.rb @@ -8,6 +8,7 @@ describe 'associations' do it { is_expected.to belong_to(:runtime).inverse_of(:project_assignments) } it { is_expected.to belong_to(:namespace_project).inverse_of(:runtime_assignments) } + it { is_expected.to have_many(:module_configurations).inverse_of(:namespace_project_runtime_assignment) } end describe 'validations' do diff --git a/spec/requests/graphql/mutation/namespace/projects/runtime_assignments/update_module_configurations_mutation_spec.rb b/spec/requests/graphql/mutation/namespace/projects/runtime_assignments/update_module_configurations_mutation_spec.rb new file mode 100644 index 00000000..e5c226b5 --- /dev/null +++ b/spec/requests/graphql/mutation/namespace/projects/runtime_assignments/update_module_configurations_mutation_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'namespacesProjectsRuntimeAssignmentsUpdateModuleConfigurations Mutation' do + include GraphqlHelpers + + subject(:mutate!) { post_graphql mutation, variables: variables, current_user: current_user } + + let(:mutation) do + <<~QUERY + mutation($input: NamespacesProjectsRuntimeAssignmentsUpdateModuleConfigurationsInput!) { + namespacesProjectsRuntimeAssignmentsUpdateModuleConfigurations(input: $input) { + #{error_query} + namespaceProjectRuntimeAssignment { + id + compatible + runtime { id } + moduleConfigurations { + nodes { + id + value + definition { + id + identifier + } + } + } + } + } + } + QUERY + end + + let(:namespace) { create(:namespace) } + let(:project) { create(:namespace_project, namespace: namespace) } + let(:runtime) { create(:runtime, namespace: namespace) } + let(:runtime_assignment) do + create(:namespace_project_runtime_assignment, namespace_project: project, runtime: runtime, compatible: true) + end + let(:runtime_module) { create(:runtime_module, runtime: runtime, identifier: 'core') } + let(:definition_one) do + create(:module_configuration_definition, runtime_module: runtime_module, identifier: 'apiKey') + end + let(:definition_two) do + create(:module_configuration_definition, runtime_module: runtime_module, identifier: 'region') + end + let(:current_user) { create(:user) } + let(:variables) do + { + input: { + namespaceProjectRuntimeAssignmentId: runtime_assignment.to_global_id.to_s, + moduleConfigurations: [ + { + moduleConfigurationDefinitionId: definition_one.to_global_id.to_s, + value: 'secret', + }, + { + moduleConfigurationDefinitionId: definition_two.to_global_id.to_s, + value: 'eu-central-1', + } + ], + }, + } + end + + context 'when user has permission' do + before do + create(:namespace_member, namespace: namespace, user: current_user) + stub_allowed_ability(NamespaceProjectPolicy, :assign_project_runtimes, user: current_user, subject: project) + stub_allowed_ability(NamespaceProjectPolicy, :read_namespace_project, user: current_user, subject: project) + end + + it 'persists the module configurations on the runtime assignment' do + allow(FlowHandler).to receive(:update_runtime) + + mutate! + + response_assignment = graphql_data_at( + :namespaces_projects_runtime_assignments_update_module_configurations, + :namespace_project_runtime_assignment + ) + + expect(response_assignment['id']).to eq(runtime_assignment.to_global_id.to_s) + expect(response_assignment['compatible']).to be(true) + expect(response_assignment.dig('runtime', 'id')).to eq(runtime.to_global_id.to_s) + expect(response_assignment.dig('moduleConfigurations', 'nodes')).to contain_exactly( + a_hash_including( + 'value' => 'secret', + 'definition' => a_hash_including( + 'id' => definition_one.to_global_id.to_s, + 'identifier' => 'apiKey' + ) + ), + a_hash_including( + 'value' => 'eu-central-1', + 'definition' => a_hash_including( + 'id' => definition_two.to_global_id.to_s, + 'identifier' => 'region' + ) + ) + ) + expect(runtime_assignment.reload.module_configurations.count).to eq(2) + expect(FlowHandler).to have_received(:update_runtime).with(runtime) + end + end + + context 'when user does not have permission' do + it 'returns an error' do + mutate! + + expect( + graphql_data_at(:namespaces_projects_runtime_assignments_update_module_configurations, + :namespace_project_runtime_assignment) + ).to be_nil + expect( + graphql_data_at(:namespaces_projects_runtime_assignments_update_module_configurations, :errors, :error_code) + ).to include('MISSING_PERMISSION') + end + end +end diff --git a/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb b/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb new file mode 100644 index 00000000..649a5f4f --- /dev/null +++ b/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'namespace project runtime assignments Query' do + include GraphqlHelpers + + let(:query) do + <<~QUERY + query($namespaceId: NamespaceID!, $projectId: NamespaceProjectID!) { + namespace(id: $namespaceId) { + project(id: $projectId) { + id + runtimeAssignments { + nodes { + id + compatible + runtime { id } + moduleConfigurations { + nodes { + id + value + definition { + id + identifier + } + } + } + } + } + } + } + } + QUERY + end + + let(:namespace) { create(:namespace) } + let(:project) { create(:namespace_project, namespace: namespace) } + let(:runtime) { create(:runtime, namespace: namespace) } + let(:runtime_assignment) do + create(:namespace_project_runtime_assignment, namespace_project: project, runtime: runtime, compatible: true) + end + let(:runtime_module) { create(:runtime_module, runtime: runtime, identifier: 'core') } + let(:definition) { create(:module_configuration_definition, runtime_module: runtime_module, identifier: 'apiKey') } + let!(:module_configuration) do + create(:module_configuration, + namespace_project_runtime_assignment: runtime_assignment, + module_configuration_definition: definition, + value: 'secret') + end + let(:current_user) do + create(:user).tap do |user| + create(:namespace_member, namespace: namespace, user: user) + end + end + + before do + stub_allowed_ability(NamespaceProjectPolicy, :read_namespace_project, user: current_user, subject: project) + + post_graphql query, + variables: { + namespaceId: namespace.to_global_id.to_s, + projectId: project.to_global_id.to_s, + }, + current_user: current_user + end + + it 'returns runtime assignments with saved module configurations' do + response_project = graphql_data_at(:namespace, :project) + response_assignment = response_project.dig('runtimeAssignments', 'nodes', 0) + + expect(response_project['id']).to eq(project.to_global_id.to_s) + expect(response_assignment).to include( + 'id' => runtime_assignment.to_global_id.to_s, + 'compatible' => true, + 'runtime' => { 'id' => runtime.to_global_id.to_s } + ) + expect(response_assignment.dig('moduleConfigurations', 'nodes')).to contain_exactly( + a_hash_including( + 'id' => module_configuration.to_global_id.to_s, + 'value' => 'secret', + 'definition' => a_hash_including( + 'id' => definition.to_global_id.to_s, + 'identifier' => 'apiKey' + ) + ) + ) + end +end diff --git a/spec/services/namespaces/projects/runtime_assignments/update_module_configurations_service_spec.rb b/spec/services/namespaces/projects/runtime_assignments/update_module_configurations_service_spec.rb new file mode 100644 index 00000000..c0d5920d --- /dev/null +++ b/spec/services/namespaces/projects/runtime_assignments/update_module_configurations_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Namespaces::Projects::RuntimeAssignments::UpdateModuleConfigurationsService do + subject(:service_response) do + described_class.new(create_authentication(current_user), runtime_assignment, module_configurations).execute + end + + let(:input_class) { Struct.new(:module_configuration_definition_id, :value) } + let(:current_user) { create(:user) } + let(:namespace) { create(:namespace) } + let(:project) { create(:namespace_project, namespace: namespace) } + let(:runtime) { create(:runtime, namespace: namespace) } + let(:runtime_assignment) do + create(:namespace_project_runtime_assignment, namespace_project: project, runtime: runtime, compatible: true) + end + let(:runtime_module) { create(:runtime_module, runtime: runtime, identifier: 'core') } + let(:definition_one) do + create(:module_configuration_definition, runtime_module: runtime_module, identifier: 'apiKey') + end + let(:definition_two) do + create(:module_configuration_definition, runtime_module: runtime_module, identifier: 'region') + end + let(:module_configurations) do + [ + input_class.new(definition_one.to_global_id, 'secret'), + input_class.new(definition_two.to_global_id, 'eu-central-1') + ] + end + + context 'when user does not have permission' do + it { is_expected.not_to be_success } + it { expect(service_response.payload[:error_code]).to eq(:missing_permission) } + it { expect { service_response }.not_to change { ModuleConfiguration.count } } + + it do + allow(FlowHandler).to receive(:update_runtime) + + service_response + + expect(FlowHandler).not_to have_received(:update_runtime) + end + end + + context 'when user has permission' do + before do + stub_allowed_ability(NamespaceProjectPolicy, :assign_project_runtimes, user: current_user, subject: project) + end + + it { is_expected.to be_success } + it { expect(service_response.payload).to eq(runtime_assignment) } + it { expect { service_response }.to change { ModuleConfiguration.count }.by(2) } + + it 'updates the runtime directly after persisting' do + allow(FlowHandler).to receive(:update_runtime) + + service_response + + expect(FlowHandler).to have_received(:update_runtime).with(runtime) + end + + it 'creates an audit event' do + expect { service_response }.to create_audit_event( + :project_module_configurations_updated, + author_id: current_user.id, + entity_id: runtime_assignment.id, + entity_type: 'NamespaceProjectRuntimeAssignment', + target_id: project.id, + target_type: 'NamespaceProject' + ) + end + + it 'stores values against their definitions' do + service_response + + expect( + runtime_assignment.reload.module_configurations.order(:module_configuration_definition_id).pluck(:value) + ).to eq(%w[secret eu-central-1]) + end + + context 'when configurations already exist' do + let!(:existing_configuration) do + create(:module_configuration, + namespace_project_runtime_assignment: runtime_assignment, + module_configuration_definition: definition_one, + value: 'old-secret') + end + let!(:removed_configuration) do + create(:module_configuration, + namespace_project_runtime_assignment: runtime_assignment, + module_configuration_definition: definition_two, + value: 'old-region') + end + let(:module_configurations) do + [input_class.new(definition_one.to_global_id, 'new-secret')] + end + + it 'replaces the saved set by definition identity' do + expect { service_response }.to change { ModuleConfiguration.count }.by(-1) + + expect(existing_configuration.reload.value).to eq('new-secret') + expect(ModuleConfiguration.exists?(removed_configuration.id)).to be(false) + end + end + end +end From 01155ef1c09862423f57d5a4cc568d6c2e133d1a Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 25 May 2026 16:28:48 +0200 Subject: [PATCH 3/5] fix: adjusted for code review --- app/policies/module_configuration_policy.rb | 2 +- .../update_module_configurations_service.rb | 1 - db/structure.sql | 87 ++----------------- spec/models/module_configuration_spec.rb | 2 +- ..._project_runtime_assignments_query_spec.rb | 1 + 5 files changed, 12 insertions(+), 81 deletions(-) diff --git a/app/policies/module_configuration_policy.rb b/app/policies/module_configuration_policy.rb index b75cc8f5..817388d3 100644 --- a/app/policies/module_configuration_policy.rb +++ b/app/policies/module_configuration_policy.rb @@ -3,6 +3,6 @@ class ModuleConfigurationPolicy < BasePolicy delegate { subject.namespace_project_runtime_assignment } - rule { can?(:read_namespace_project_runtime_assignment) }.enable :read_module_configuration + rule { can?(:update_namespace_project_runtime_assignment) }.enable :read_module_configuration rule { can?(:update_namespace_project_runtime_assignment) }.enable :update_module_configuration end diff --git a/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb b/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb index 269a10cc..89d4fb16 100644 --- a/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb +++ b/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb @@ -37,7 +37,6 @@ def execute { id: configuration.id, module_configuration_definition_id: configuration.module_configuration_definition_id, - value: configuration.value, } end, } diff --git a/db/structure.sql b/db/structure.sql index 65ac8379..343c5466 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -205,9 +205,7 @@ CREATE TABLE flow_settings ( flow_setting_id text NOT NULL, object jsonb NOT NULL, created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - "cast" text, - CONSTRAINT check_65f98666ae CHECK ((char_length("cast") <= 500)) + updated_at timestamp with time zone NOT NULL ); CREATE SEQUENCE flow_settings_id_seq @@ -634,7 +632,8 @@ CREATE TABLE node_functions ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, flow_id bigint NOT NULL, - function_definition_id bigint NOT NULL + function_definition_id bigint NOT NULL, + value_of_node_parameter_id bigint ); CREATE SEQUENCE node_functions_id_seq @@ -652,9 +651,7 @@ CREATE TABLE node_parameters ( literal_value jsonb, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - parameter_definition_id bigint NOT NULL, - "cast" text, - CONSTRAINT check_6439c80497 CHECK ((char_length("cast") <= 500)) + parameter_definition_id bigint NOT NULL ); CREATE SEQUENCE node_parameters_id_seq @@ -966,47 +963,6 @@ CREATE TABLE schema_migrations ( version character varying NOT NULL ); -CREATE TABLE sub_flow_settings ( - id bigint NOT NULL, - sub_flow_id bigint NOT NULL, - identifier text NOT NULL, - default_value jsonb, - optional boolean DEFAULT false NOT NULL, - hidden boolean DEFAULT false NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL -); - -CREATE SEQUENCE sub_flow_settings_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE sub_flow_settings_id_seq OWNED BY sub_flow_settings.id; - -CREATE TABLE sub_flows ( - id bigint NOT NULL, - node_parameter_id bigint NOT NULL, - starting_node_id bigint, - function_definition_id bigint, - signature text NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - CONSTRAINT check_53a99b1dd3 CHECK ((num_nonnulls(starting_node_id, function_definition_id) = 1)), - CONSTRAINT check_943d01babb CHECK ((char_length(signature) <= 500)) -); - -CREATE SEQUENCE sub_flows_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE sub_flows_id_seq OWNED BY sub_flows.id; - CREATE TABLE translations ( id bigint NOT NULL, code text NOT NULL, @@ -1180,10 +1136,6 @@ ALTER TABLE ONLY runtime_statuses ALTER COLUMN id SET DEFAULT nextval('runtime_s ALTER TABLE ONLY runtimes ALTER COLUMN id SET DEFAULT nextval('runtimes_id_seq'::regclass); -ALTER TABLE ONLY sub_flow_settings ALTER COLUMN id SET DEFAULT nextval('sub_flow_settings_id_seq'::regclass); - -ALTER TABLE ONLY sub_flows ALTER COLUMN id SET DEFAULT nextval('sub_flows_id_seq'::regclass); - ALTER TABLE ONLY translations ALTER COLUMN id SET DEFAULT nextval('translations_id_seq'::regclass); ALTER TABLE ONLY user_identities ALTER COLUMN id SET DEFAULT nextval('user_identities_id_seq'::regclass); @@ -1345,12 +1297,6 @@ ALTER TABLE ONLY runtimes ALTER TABLE ONLY schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); -ALTER TABLE ONLY sub_flow_settings - ADD CONSTRAINT sub_flow_settings_pkey PRIMARY KEY (id); - -ALTER TABLE ONLY sub_flows - ADD CONSTRAINT sub_flows_pkey PRIMARY KEY (id); - ALTER TABLE ONLY translations ADD CONSTRAINT translations_pkey PRIMARY KEY (id); @@ -1525,6 +1471,8 @@ CREATE INDEX index_node_functions_on_function_definition_id ON node_functions US CREATE INDEX index_node_functions_on_next_node_id ON node_functions USING btree (next_node_id); +CREATE INDEX index_node_functions_on_value_of_node_parameter_id ON node_functions USING btree (value_of_node_parameter_id); + CREATE INDEX index_node_parameters_on_node_function_id ON node_parameters USING btree (node_function_id); CREATE INDEX index_node_parameters_on_parameter_definition_id ON node_parameters USING btree (parameter_definition_id); @@ -1551,14 +1499,6 @@ CREATE INDEX index_runtimes_on_namespace_id ON runtimes USING btree (namespace_i CREATE UNIQUE INDEX index_runtimes_on_token ON runtimes USING btree (token); -CREATE INDEX index_sub_flow_settings_on_sub_flow_id ON sub_flow_settings USING btree (sub_flow_id); - -CREATE INDEX index_sub_flows_on_function_definition_id ON sub_flows USING btree (function_definition_id); - -CREATE UNIQUE INDEX index_sub_flows_on_node_parameter_id ON sub_flows USING btree (node_parameter_id); - -CREATE INDEX index_sub_flows_on_starting_node_id ON sub_flows USING btree (starting_node_id); - CREATE INDEX index_translations_on_owner ON translations USING btree (owner_type, owner_id); CREATE UNIQUE INDEX index_user_identities_on_provider_id_and_identifier ON user_identities USING btree (provider_id, identifier); @@ -1601,9 +1541,6 @@ ALTER TABLE ONLY function_definitions ALTER TABLE ONLY node_parameters ADD CONSTRAINT fk_rails_2ed7c53167 FOREIGN KEY (parameter_definition_id) REFERENCES parameter_definitions(id) ON DELETE RESTRICT; -ALTER TABLE ONLY sub_flows - ADD CONSTRAINT fk_rails_32ab48790a FOREIGN KEY (node_parameter_id) REFERENCES node_parameters(id) ON DELETE CASCADE; - ALTER TABLE ONLY runtime_flow_types ADD CONSTRAINT fk_rails_3675f29c4e FOREIGN KEY (runtime_id) REFERENCES runtimes(id) ON DELETE CASCADE; @@ -1652,9 +1589,6 @@ ALTER TABLE ONLY node_functions ALTER TABLE ONLY backup_codes ADD CONSTRAINT fk_rails_556c1feac3 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE ONLY sub_flow_settings - ADD CONSTRAINT fk_rails_55f76c79cc FOREIGN KEY (sub_flow_id) REFERENCES sub_flows(id) ON DELETE CASCADE; - ALTER TABLE ONLY namespace_members ADD CONSTRAINT fk_rails_567f152a62 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; @@ -1730,9 +1664,6 @@ ALTER TABLE ONLY user_sessions ALTER TABLE ONLY namespace_members ADD CONSTRAINT fk_rails_a0a760b9b4 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE ONLY sub_flows - ADD CONSTRAINT fk_rails_a99aa3478f FOREIGN KEY (function_definition_id) REFERENCES function_definitions(id) ON DELETE RESTRICT; - ALTER TABLE ONLY flows ADD CONSTRAINT fk_rails_ab927e0ecb FOREIGN KEY (project_id) REFERENCES namespace_projects(id) ON DELETE CASCADE; @@ -1766,9 +1697,6 @@ ALTER TABLE ONLY flows ALTER TABLE ONLY flow_settings ADD CONSTRAINT fk_rails_da3b2fb3c5 FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE CASCADE; -ALTER TABLE ONLY sub_flows - ADD CONSTRAINT fk_rails_e27dd4d82a FOREIGN KEY (starting_node_id) REFERENCES node_functions(id) ON DELETE RESTRICT; - ALTER TABLE ONLY runtime_flow_types ADD CONSTRAINT fk_rails_e729dc57e7 FOREIGN KEY (runtime_module_id) REFERENCES runtime_modules(id) ON DELETE CASCADE; @@ -1781,6 +1709,9 @@ ALTER TABLE ONLY runtimes ALTER TABLE ONLY flow_data_type_links ADD CONSTRAINT fk_rails_f4202724d3 FOREIGN KEY (flow_id) REFERENCES flows(id) ON DELETE CASCADE; +ALTER TABLE ONLY node_functions + ADD CONSTRAINT fk_rails_f5d1a9d316 FOREIGN KEY (value_of_node_parameter_id) REFERENCES node_parameters(id) ON DELETE CASCADE; + ALTER TABLE ONLY audit_events ADD CONSTRAINT fk_rails_f64374fc56 FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL; diff --git a/spec/models/module_configuration_spec.rb b/spec/models/module_configuration_spec.rb index 72cb808c..a163f93b 100644 --- a/spec/models/module_configuration_spec.rb +++ b/spec/models/module_configuration_spec.rb @@ -17,7 +17,7 @@ end it 'requires the definition to belong to the assigned runtime' do - assignment = create(:namespace_project_runtime_assignment) + assignment = module_configuration.namespace_project_runtime_assignment other_runtime = create(:runtime, namespace: assignment.namespace_project.namespace) other_runtime_module = create(:runtime_module, runtime: other_runtime) other_definition = create(:module_configuration_definition, runtime_module: other_runtime_module) diff --git a/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb b/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb index 649a5f4f..53fcf1cf 100644 --- a/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb +++ b/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb @@ -56,6 +56,7 @@ before do stub_allowed_ability(NamespaceProjectPolicy, :read_namespace_project, user: current_user, subject: project) + stub_allowed_ability(NamespaceProjectPolicy, :assign_project_runtimes, user: current_user, subject: project) post_graphql query, variables: { From 3bd1698c3d89017a5c1fd912bbb5a44b098711e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raphael=20G=C3=B6tz?= <52959657+raphael-goetz@users.noreply.github.com> Date: Thu, 28 May 2026 20:32:26 +0200 Subject: [PATCH 4/5] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Niklas van Schrick Signed-off-by: Raphael Götz <52959657+raphael-goetz@users.noreply.github.com> --- app/policies/module_configuration_policy.rb | 1 - .../runtime_assignments/update_module_configurations_service.rb | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/policies/module_configuration_policy.rb b/app/policies/module_configuration_policy.rb index 817388d3..24107624 100644 --- a/app/policies/module_configuration_policy.rb +++ b/app/policies/module_configuration_policy.rb @@ -4,5 +4,4 @@ class ModuleConfigurationPolicy < BasePolicy delegate { subject.namespace_project_runtime_assignment } rule { can?(:update_namespace_project_runtime_assignment) }.enable :read_module_configuration - rule { can?(:update_namespace_project_runtime_assignment) }.enable :update_module_configuration end diff --git a/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb b/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb index 89d4fb16..0a5fd7c6 100644 --- a/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb +++ b/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb @@ -18,7 +18,7 @@ def execute unless Ability.allowed?( current_authentication, :assign_project_runtimes, - runtime_assignment.namespace_project + runtime_assignment ) return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) end From 3de0c6c3bcb4c874ed3163819d0a0af89f2b1d82 Mon Sep 17 00:00:00 2001 From: Raphael Date: Fri, 29 May 2026 16:26:16 +0200 Subject: [PATCH 5/5] feat: requested changes from code review --- .../update_module_configurations.rb | 1 + app/graphql/types/namespace_project_type.rb | 1 + .../namespace_project_runtime_assignment.rb | 3 +- app/models/namespace_role_ability.rb | 1 + .../concerns/customizable_permission.rb | 10 +++++- app/policies/module_configuration_policy.rb | 2 +- ...space_project_runtime_assignment_policy.rb | 6 +++- .../update_module_configurations_service.rb | 18 +++++++--- docs/graphql/enum/namespaceroleability.md | 1 + docs/graphql/object/namespaceproject.md | 1 + .../types/namespace_project_type_spec.rb | 1 + .../concerns/customizable_permission_spec.rb | 36 +++++++++++++++++++ ...ate_module_configurations_mutation_spec.rb | 7 +++- ..._project_runtime_assignments_query_spec.rb | 1 - ...date_module_configurations_service_spec.rb | 7 +++- 15 files changed, 83 insertions(+), 13 deletions(-) diff --git a/app/graphql/mutations/namespaces/projects/runtime_assignments/update_module_configurations.rb b/app/graphql/mutations/namespaces/projects/runtime_assignments/update_module_configurations.rb index 9305dc71..91ceceb8 100644 --- a/app/graphql/mutations/namespaces/projects/runtime_assignments/update_module_configurations.rb +++ b/app/graphql/mutations/namespaces/projects/runtime_assignments/update_module_configurations.rb @@ -12,6 +12,7 @@ class UpdateModuleConfigurations < BaseMutation description: 'The full set of saved module configurations for this assignment.' argument :namespace_project_runtime_assignment_id, Types::GlobalIdType[::NamespaceProjectRuntimeAssignment], + required: true, description: 'The project runtime assignment to update.' field :namespace_project_runtime_assignment, Types::NamespaceProjectRuntimeAssignmentType, diff --git a/app/graphql/types/namespace_project_type.rb b/app/graphql/types/namespace_project_type.rb index d6835c1a..d1dedc61 100644 --- a/app/graphql/types/namespace_project_type.rb +++ b/app/graphql/types/namespace_project_type.rb @@ -15,6 +15,7 @@ class NamespaceProjectType < Types::BaseObject field :runtime_assignments, Types::NamespaceProjectRuntimeAssignmentType.connection_type, null: false, description: 'Runtime assignments of this project.' + field :runtimes, Types::RuntimeType.connection_type, null: false, description: 'Runtimes assigned to this project' field :roles, Types::NamespaceRoleType.connection_type, null: false, description: 'Roles assigned to this project', diff --git a/app/models/namespace_project_runtime_assignment.rb b/app/models/namespace_project_runtime_assignment.rb index 9aa50b78..4b313ff6 100644 --- a/app/models/namespace_project_runtime_assignment.rb +++ b/app/models/namespace_project_runtime_assignment.rb @@ -5,8 +5,7 @@ class NamespaceProjectRuntimeAssignment < ApplicationRecord belongs_to :namespace_project, inverse_of: :runtime_assignments has_many :module_configurations, - inverse_of: :namespace_project_runtime_assignment, - dependent: :destroy + inverse_of: :namespace_project_runtime_assignment validates :runtime, uniqueness: { scope: :namespace_project_id } diff --git a/app/models/namespace_role_ability.rb b/app/models/namespace_role_ability.rb index a32937bd..ac639967 100644 --- a/app/models/namespace_role_ability.rb +++ b/app/models/namespace_role_ability.rb @@ -28,6 +28,7 @@ class NamespaceRoleAbility < ApplicationRecord create_flow: { db: 24, description: 'Allows to create flows in a namespace project' }, delete_flow: { db: 25, description: 'Allows to delete flows in a namespace project' }, update_flow: { db: 26, description: 'Allows to update flows in the project' }, + update_module_configurations: { db: 27, description: 'Allows to update module configurations in the project' }, }.with_indifferent_access enum :ability, ABILITIES.transform_values { |v| v[:db] }, prefix: :can diff --git a/app/policies/concerns/customizable_permission.rb b/app/policies/concerns/customizable_permission.rb index bf9cbe73..6faa9603 100644 --- a/app/policies/concerns/customizable_permission.rb +++ b/app/policies/concerns/customizable_permission.rb @@ -37,9 +37,17 @@ def user_has_ability?(ability, user, subject) roles = namespace_member(user, subject).roles - roles = roles.applicable_to_project(subject) if subject.is_a?(NamespaceProject) + project = project_scope(subject) + roles = roles.applicable_to_project(project) if project.present? roles.joins(:abilities).exists?(namespace_role_abilities: { ability: ability }) end + + def project_scope(subject) + return subject if subject.is_a?(NamespaceProject) + return subject.namespace_project if subject.respond_to?(:namespace_project) + + subject.project if subject.respond_to?(:project) + end end end diff --git a/app/policies/module_configuration_policy.rb b/app/policies/module_configuration_policy.rb index 24107624..98de1641 100644 --- a/app/policies/module_configuration_policy.rb +++ b/app/policies/module_configuration_policy.rb @@ -3,5 +3,5 @@ class ModuleConfigurationPolicy < BasePolicy delegate { subject.namespace_project_runtime_assignment } - rule { can?(:update_namespace_project_runtime_assignment) }.enable :read_module_configuration + rule { can?(:read_namespace_project_runtime_assignment) }.enable :read_module_configuration end diff --git a/app/policies/namespace_project_runtime_assignment_policy.rb b/app/policies/namespace_project_runtime_assignment_policy.rb index e4fb87be..19cb7cc3 100644 --- a/app/policies/namespace_project_runtime_assignment_policy.rb +++ b/app/policies/namespace_project_runtime_assignment_policy.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class NamespaceProjectRuntimeAssignmentPolicy < BasePolicy + include CustomizablePermission + delegate { subject.namespace_project } + namespace_resolver { |runtime_assignment| runtime_assignment.namespace_project.namespace } + rule { can?(:read_namespace_project) }.enable :read_namespace_project_runtime_assignment - rule { can?(:assign_project_runtimes) }.enable :update_namespace_project_runtime_assignment + customizable_permission :update_module_configurations end diff --git a/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb b/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb index 0a5fd7c6..32106e1f 100644 --- a/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb +++ b/app/services/namespaces/projects/runtime_assignments/update_module_configurations_service.rb @@ -17,7 +17,7 @@ def initialize(current_authentication, runtime_assignment, module_configurations def execute unless Ability.allowed?( current_authentication, - :assign_project_runtimes, + :update_module_configurations, runtime_assignment ) return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) @@ -59,11 +59,11 @@ def update_configurations(t) ) db_configurations = [] kept_definition_ids = [] + definitions = configuration_definitions.index_by(&:id) module_configurations.each do |configuration_input| - definition = runtime_assignment.runtime.module_configuration_definitions.find_by( - id: configuration_input.module_configuration_definition_id.model_id - ) + definition_id = configuration_input.module_configuration_definition_id.model_id.to_i + definition = definitions[definition_id] if definition.nil? t.rollback_and_return! ServiceResponse.error( @@ -90,10 +90,18 @@ def update_configurations(t) runtime_assignment.module_configurations .where.not(module_configuration_definition_id: kept_definition_ids) - .destroy_all + .delete_all db_configurations end + + def configuration_definitions + definition_ids = module_configurations.map do |configuration_input| + configuration_input.module_configuration_definition_id.model_id + end + + runtime_assignment.runtime.module_configuration_definitions.where(id: definition_ids) + end end end end diff --git a/docs/graphql/enum/namespaceroleability.md b/docs/graphql/enum/namespaceroleability.md index 0cd23ff4..3adf6739 100644 --- a/docs/graphql/enum/namespaceroleability.md +++ b/docs/graphql/enum/namespaceroleability.md @@ -28,6 +28,7 @@ Represents abilities that can be granted to roles in namespaces. | `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 | +| `UPDATE_MODULE_CONFIGURATIONS` | Allows to update module configurations in the project | | `UPDATE_NAMESPACE_PROJECT` | Allows to update the project of the namespace | | `UPDATE_NAMESPACE_ROLE` | Allows to update the namespace role | | `UPDATE_ORGANIZATION` | Allows to update the organization | diff --git a/docs/graphql/object/namespaceproject.md b/docs/graphql/object/namespaceproject.md index 6eb52610..d2d842da 100644 --- a/docs/graphql/object/namespaceproject.md +++ b/docs/graphql/object/namespaceproject.md @@ -17,6 +17,7 @@ Represents a namespace project | `primaryRuntime` | [`Runtime`](../object/runtime.md) | The primary runtime for the project | | `roles` | [`NamespaceRoleConnection!`](../object/namespaceroleconnection.md) | Roles assigned to this project | | `runtimeAssignments` | [`NamespaceProjectRuntimeAssignmentConnection!`](../object/namespaceprojectruntimeassignmentconnection.md) | Runtime assignments of this project. | +| `runtimes` | [`RuntimeConnection!`](../object/runtimeconnection.md) | Runtimes assigned to this project | | `slug` | [`String!`](../scalar/string.md) | Slug of the project used in URLs to identify flows | | `updatedAt` | [`Time!`](../scalar/time.md) | Time when this NamespaceProject was last updated | | `userAbilities` | [`NamespaceProjectUserAbilities!`](../object/namespaceprojectuserabilities.md) | Abilities for the current user on this NamespaceProject | diff --git a/spec/graphql/types/namespace_project_type_spec.rb b/spec/graphql/types/namespace_project_type_spec.rb index 2b3d42fb..20b76bfc 100644 --- a/spec/graphql/types/namespace_project_type_spec.rb +++ b/spec/graphql/types/namespace_project_type_spec.rb @@ -12,6 +12,7 @@ namespace primary_runtime runtimeAssignments + runtimes roles flows flow diff --git a/spec/policies/concerns/customizable_permission_spec.rb b/spec/policies/concerns/customizable_permission_spec.rb index 152906fc..b99de777 100644 --- a/spec/policies/concerns/customizable_permission_spec.rb +++ b/spec/policies/concerns/customizable_permission_spec.rb @@ -79,5 +79,41 @@ end end + context 'when the subject belongs to a project' do + subject { policy_class.new(create_authentication(current_user), runtime_assignment) } + + let(:policy_class) do + Class.new(BasePolicy) do + include CustomizablePermission + + namespace_resolver { |runtime_assignment| runtime_assignment.namespace_project.namespace } + + customizable_permission :update_module_configurations + end + end + let(:runtime_assignment) do + create(:namespace_project_runtime_assignment, namespace_project: project, runtime: runtime) + end + let(:project) { create(:namespace_project, namespace: namespace) } + let(:runtime) { create(:runtime, namespace: namespace) } + + before do + create(:namespace_role_project_assignment, role: namespace_role, project: assigned_project) + create(:namespace_role_ability, namespace_role: namespace_role, ability: :update_module_configurations) + end + + context 'when checking on an assignment for the assigned project' do + let(:assigned_project) { project } + + it { is_expected.to be_allowed(:update_module_configurations) } + end + + context 'when checking on an assignment for another project' do + let(:assigned_project) { create(:namespace_project, namespace: namespace) } + + it { is_expected.not_to be_allowed(:update_module_configurations) } + end + end + it { is_expected.not_to be_allowed(:create_namespace_role) } end diff --git a/spec/requests/graphql/mutation/namespace/projects/runtime_assignments/update_module_configurations_mutation_spec.rb b/spec/requests/graphql/mutation/namespace/projects/runtime_assignments/update_module_configurations_mutation_spec.rb index e5c226b5..1718df3a 100644 --- a/spec/requests/graphql/mutation/namespace/projects/runtime_assignments/update_module_configurations_mutation_spec.rb +++ b/spec/requests/graphql/mutation/namespace/projects/runtime_assignments/update_module_configurations_mutation_spec.rb @@ -67,7 +67,12 @@ context 'when user has permission' do before do create(:namespace_member, namespace: namespace, user: current_user) - stub_allowed_ability(NamespaceProjectPolicy, :assign_project_runtimes, user: current_user, subject: project) + stub_allowed_ability( + NamespaceProjectRuntimeAssignmentPolicy, + :update_module_configurations, + user: current_user, + subject: runtime_assignment + ) stub_allowed_ability(NamespaceProjectPolicy, :read_namespace_project, user: current_user, subject: project) end diff --git a/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb b/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb index 53fcf1cf..649a5f4f 100644 --- a/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb +++ b/spec/requests/graphql/query/namespace_project_runtime_assignments_query_spec.rb @@ -56,7 +56,6 @@ before do stub_allowed_ability(NamespaceProjectPolicy, :read_namespace_project, user: current_user, subject: project) - stub_allowed_ability(NamespaceProjectPolicy, :assign_project_runtimes, user: current_user, subject: project) post_graphql query, variables: { diff --git a/spec/services/namespaces/projects/runtime_assignments/update_module_configurations_service_spec.rb b/spec/services/namespaces/projects/runtime_assignments/update_module_configurations_service_spec.rb index c0d5920d..d2669802 100644 --- a/spec/services/namespaces/projects/runtime_assignments/update_module_configurations_service_spec.rb +++ b/spec/services/namespaces/projects/runtime_assignments/update_module_configurations_service_spec.rb @@ -45,7 +45,12 @@ context 'when user has permission' do before do - stub_allowed_ability(NamespaceProjectPolicy, :assign_project_runtimes, user: current_user, subject: project) + stub_allowed_ability( + NamespaceProjectRuntimeAssignmentPolicy, + :update_module_configurations, + user: current_user, + subject: runtime_assignment + ) end it { is_expected.to be_success }