From 4903ae0c63e0b04c76274d16a22e8f060b645a72 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 19 Nov 2025 22:24:40 -0800 Subject: [PATCH 01/75] move service deletion to advanced tab --- app/views/projects/services/_advanced.html.erb | 15 +++++++++++++++ app/views/projects/services/_overview.html.erb | 5 ----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/views/projects/services/_advanced.html.erb b/app/views/projects/services/_advanced.html.erb index 9f675240..2cec9e48 100644 --- a/app/views/projects/services/_advanced.html.erb +++ b/app/views/projects/services/_advanced.html.erb @@ -57,4 +57,19 @@ <% end %> + + +
+

Danger Zone

+
+ + <%= button_to( + [@service.project, @service], + method: :delete, + class: "btn btn-error btn-outline", + form: { data: { turbo: false, confirm: t("are_you_sure") } } + ) do %> + + Delete service + <% end %>
\ No newline at end of file diff --git a/app/views/projects/services/_overview.html.erb b/app/views/projects/services/_overview.html.erb index d2472706..0c7bb7c8 100644 --- a/app/views/projects/services/_overview.html.erb +++ b/app/views/projects/services/_overview.html.erb @@ -55,9 +55,4 @@ <%= form.submit class: "btn btn-primary" %> <% end %> - - <%= button_to [service.project, service], method: :delete, class: "btn btn-error btn-outline mt-2", form: { data: { turbo_confirm: t("are_you_sure") } } do %> - - Delete service - <% end %> \ No newline at end of file From 79077f45a83fbf44ef52c7af9a0bbd80c3134228 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 21 Nov 2025 10:24:18 -0800 Subject: [PATCH 02/75] added authentication schemes --- Gemfile | 1 + Gemfile.lock | 50 ++++++++++ app/avo/resources/ldap_configuration.rb | 21 ++++ app/avo/resources/oidc_configuration.rb | 19 ++++ app/avo/resources/sso_provider.rb | 15 +++ .../accounts/sso_providers_controller.rb | 71 ++++++++++++++ .../avo/ldap_configurations_controller.rb | 4 + .../avo/oidc_configurations_controller.rb | 4 + .../avo/sso_providers_controller.rb | 4 + .../users/omniauth_callbacks_controller.rb | 42 +++++++- app/controllers/users/sessions_controller.rb | 1 + app/models/account.rb | 5 + app/models/ldap_configuration.rb | 43 ++++++++ app/models/oidc_configuration.rb | 19 ++++ app/models/sso_provider.rb | 36 +++++++ .../accounts/sso_providers/_form.html.erb | 98 +++++++++++++++++++ .../accounts/sso_providers/edit.html.erb | 13 +++ app/views/accounts/sso_providers/new.html.erb | 13 +++ .../accounts/sso_providers/show.html.erb | 55 +++++++++++ app/views/devise/sessions/new.html.erb | 18 +++- app/views/settings/_layout.html.erb | 6 ++ config/initializers/devise.rb | 15 ++- config/initializers/inflections.rb | 7 +- config/routes.rb | 1 + .../20251121024059_create_sso_providers.rb | 12 +++ ...251121024136_create_oidc_configurations.rb | 16 +++ ...251121043926_create_ldap_configurations.rb | 18 ++++ db/schema.rb | 28 +++++- lib/devise/strategies/ldap_authenticatable.rb | 79 +++++++++++++++ lib/omniauth/strategies/dynamic_oidc.rb | 61 ++++++++++++ spec/factories/ldap_configurations.rb | 14 +++ spec/factories/oidc_configurations.rb | 28 ++++++ spec/factories/sso_providers.rb | 30 ++++++ spec/models/ldap_configuration_spec.rb | 5 + spec/models/oidc_configuration_spec.rb | 21 ++++ spec/models/service_spec.rb | 8 ++ spec/models/sso_provider_spec.rb | 27 +++++ 37 files changed, 896 insertions(+), 12 deletions(-) create mode 100644 app/avo/resources/ldap_configuration.rb create mode 100644 app/avo/resources/oidc_configuration.rb create mode 100644 app/avo/resources/sso_provider.rb create mode 100644 app/controllers/accounts/sso_providers_controller.rb create mode 100644 app/controllers/avo/ldap_configurations_controller.rb create mode 100644 app/controllers/avo/oidc_configurations_controller.rb create mode 100644 app/controllers/avo/sso_providers_controller.rb create mode 100644 app/models/ldap_configuration.rb create mode 100644 app/models/oidc_configuration.rb create mode 100644 app/models/sso_provider.rb create mode 100644 app/views/accounts/sso_providers/_form.html.erb create mode 100644 app/views/accounts/sso_providers/edit.html.erb create mode 100644 app/views/accounts/sso_providers/new.html.erb create mode 100644 app/views/accounts/sso_providers/show.html.erb create mode 100644 db/migrate/20251121024059_create_sso_providers.rb create mode 100644 db/migrate/20251121024136_create_oidc_configurations.rb create mode 100644 db/migrate/20251121043926_create_ldap_configurations.rb create mode 100644 lib/devise/strategies/ldap_authenticatable.rb create mode 100644 lib/omniauth/strategies/dynamic_oidc.rb create mode 100644 spec/factories/ldap_configurations.rb create mode 100644 spec/factories/oidc_configurations.rb create mode 100644 spec/factories/sso_providers.rb create mode 100644 spec/models/ldap_configuration_spec.rb create mode 100644 spec/models/oidc_configuration_spec.rb create mode 100644 spec/models/sso_provider_spec.rb diff --git a/Gemfile b/Gemfile index b595e0a1..16758cb9 100644 --- a/Gemfile +++ b/Gemfile @@ -82,6 +82,7 @@ gem "pagy", "~> 9.4" gem "oj", "~> 3.16" gem "omniauth", "~> 2.1" gem "omniauth-rails_csrf_protection", "~> 1.0" +gem "omniauth_openid_connect", "~> 0.8" gem "annotate", "~> 3.2" diff --git a/Gemfile.lock b/Gemfile.lock index 9c76845c..ed064f46 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -97,10 +97,12 @@ GEM tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) ast (2.4.3) + attr_required (1.0.2) avo (3.25.3) actionview (>= 6.1) active_link_to @@ -122,6 +124,7 @@ GEM bcrypt (3.1.20) benchmark (0.5.0) bigdecimal (3.3.1) + bindata (2.5.1) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) @@ -192,6 +195,8 @@ GEM dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) + email_validator (2.2.4) + activemodel erb (5.1.3) erubi (1.13.1) et-orbi (1.4.0) @@ -208,6 +213,8 @@ GEM faraday-net_http (>= 2.0, < 3.5) json logger + faraday-follow_redirects (0.4.0) + faraday (>= 1, < 3) faraday-net_http (3.4.1) net-http (>= 0.5.0) ffi (1.17.0-aarch64-linux-gnu) @@ -284,6 +291,13 @@ GEM jsbundling-rails (1.3.1) railties (>= 6.0.0) json (2.13.2) + json-jwt (1.17.0) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects jsonpath (1.1.5) multi_json jwt (2.9.1) @@ -398,6 +412,22 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + omniauth_openid_connect (0.8.0) + omniauth (>= 1.9, < 3) + openid_connect (~> 2.2) + openid_connect (2.3.1) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) orm_adapter (0.5.0) ostruct (0.6.2) pagy (9.4.0) @@ -432,6 +462,13 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.1.18) + rack-oauth2 (2.3.0) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) rack-protection (4.0.0) base64 (>= 0.1.0) rack (>= 3.0.0, < 4) @@ -583,6 +620,11 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects sys-proctable (1.3.0) ffi (~> 1.1) tailwindcss-rails (2.7.6) @@ -613,6 +655,9 @@ GEM unicode-emoji (4.0.4) uri (1.0.3) useragent (0.16.11) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix version_gem (1.1.4) view_component (4.1.0) activesupport (>= 7.1.0, < 8.2) @@ -624,6 +669,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -683,6 +732,7 @@ DEPENDENCIES omniauth-github (~> 2.0) omniauth-gitlab (~> 4.1) omniauth-rails_csrf_protection (~> 1.0) + omniauth_openid_connect (~> 0.8) pagy (~> 9.4) pg (~> 1.6) pretender (~> 0.6.0) diff --git a/app/avo/resources/ldap_configuration.rb b/app/avo/resources/ldap_configuration.rb new file mode 100644 index 00000000..fc4384f1 --- /dev/null +++ b/app/avo/resources/ldap_configuration.rb @@ -0,0 +1,21 @@ +class Avo::Resources::LdapConfiguration < Avo::BaseResource + # self.includes = [] + # self.attachments = [] + # self.search = { + # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } + # } + + def fields + field :id, as: :id + field :host, as: :text + field :port, as: :number + field :encryption, as: :text + field :base_dn, as: :text + field :bind_dn, as: :text + field :bind_password, as: :text + field :uid_attribute, as: :text + field :email_attribute, as: :text + field :name_attribute, as: :text + field :filter, as: :text + end +end diff --git a/app/avo/resources/oidc_configuration.rb b/app/avo/resources/oidc_configuration.rb new file mode 100644 index 00000000..1a603d96 --- /dev/null +++ b/app/avo/resources/oidc_configuration.rb @@ -0,0 +1,19 @@ +class Avo::Resources::OIDCConfiguration < Avo::BaseResource + # self.includes = [] + # self.attachments = [] + # self.search = { + # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } + # } + + def fields + field :id, as: :id + field :issuer, as: :text + field :client_id, as: :text + field :client_secret, as: :text + field :authorization_endpoint, as: :text + field :token_endpoint, as: :text + field :userinfo_endpoint, as: :text + field :jwks_uri, as: :text + field :scopes, as: :text + end +end diff --git a/app/avo/resources/sso_provider.rb b/app/avo/resources/sso_provider.rb new file mode 100644 index 00000000..29ca0a49 --- /dev/null +++ b/app/avo/resources/sso_provider.rb @@ -0,0 +1,15 @@ +class Avo::Resources::SSOProvider < Avo::BaseResource + # self.includes = [] + # self.attachments = [] + # self.search = { + # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } + # } + + def fields + field :id, as: :id + field :account, as: :belongs_to + field :configuration, as: :text + field :name, as: :text + field :enabled, as: :boolean + end +end diff --git a/app/controllers/accounts/sso_providers_controller.rb b/app/controllers/accounts/sso_providers_controller.rb new file mode 100644 index 00000000..a9b95045 --- /dev/null +++ b/app/controllers/accounts/sso_providers_controller.rb @@ -0,0 +1,71 @@ +module Accounts + class SSOProvidersController < ApplicationController + def show + @sso_provider = current_account.sso_provider + @oidc_configuration = @sso_provider&.configuration + end + + def new + @sso_provider = current_account.build_sso_provider + @oidc_configuration = OIDCConfiguration.new + end + + def create + @oidc_configuration = OIDCConfiguration.new(oidc_configuration_params) + @sso_provider = current_account.build_sso_provider(sso_provider_params) + @sso_provider.configuration = @oidc_configuration + + if @oidc_configuration.save && @sso_provider.save + redirect_to sso_provider_path, notice: "SSO provider created successfully" + else + render :new, status: :unprocessable_entity + end + end + + def edit + @sso_provider = current_account.sso_provider + redirect_to new_sso_provider_path, alert: "No SSO provider configured" unless @sso_provider + @oidc_configuration = @sso_provider&.configuration + end + + def update + @sso_provider = current_account.sso_provider + @oidc_configuration = @sso_provider.configuration + + if @oidc_configuration.update(oidc_configuration_params) && @sso_provider.update(sso_provider_params) + redirect_to sso_provider_path, notice: "SSO provider updated successfully" + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @sso_provider = current_account.sso_provider + + if @sso_provider&.destroy + redirect_to sso_provider_path, notice: "SSO provider deleted" + else + redirect_to sso_provider_path, alert: "Failed to delete SSO provider" + end + end + + private + + def sso_provider_params + params.require(:sso_provider).permit(:name, :enabled) + end + + def oidc_configuration_params + params.require(:oidc_configuration).permit( + :issuer, + :client_id, + :client_secret, + :authorization_endpoint, + :token_endpoint, + :userinfo_endpoint, + :jwks_uri, + :scopes + ) + end + end +end diff --git a/app/controllers/avo/ldap_configurations_controller.rb b/app/controllers/avo/ldap_configurations_controller.rb new file mode 100644 index 00000000..db3b9ff6 --- /dev/null +++ b/app/controllers/avo/ldap_configurations_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::LdapConfigurationsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_configurations_controller.rb b/app/controllers/avo/oidc_configurations_controller.rb new file mode 100644 index 00000000..08b8968b --- /dev/null +++ b/app/controllers/avo/oidc_configurations_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::OIDCConfigurationsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/sso_providers_controller.rb b/app/controllers/avo/sso_providers_controller.rb new file mode 100644 index 00000000..bc0e5792 --- /dev/null +++ b/app/controllers/avo/sso_providers_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::SSOProvidersController < Avo::ResourcesController +end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 5800bf61..9f1f3949 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -1,7 +1,7 @@ module Users class OmniauthCallbacksController < Devise::OmniauthCallbacksController - before_action :set_provider, except: [ :failure ] - before_action :set_user, except: [ :failure ] + before_action :set_provider, except: [ :failure, :oidc ] + before_action :set_user, except: [ :failure, :oidc ] attr_reader :provider, :user @@ -13,8 +13,46 @@ module Users handle_auth "Github" end + def oidc + sso_provider_id = session["sso_provider_id"] + sso_provider = SSOProvider.find_by(id: sso_provider_id) if sso_provider_id.present? + + if sso_provider + @user = find_or_create_oidc_user + handle_oidc_auth(sso_provider) + else + redirect_to root_path, alert: "SSO provider not found" + end + end + private + def find_or_create_oidc_user + if user_signed_in? + current_user + else + # Find or create user from OIDC data + create_user + end + end + + def handle_oidc_auth(sso_provider) + # For OIDC, we don't store in Provider model, just authenticate + if user_signed_in? + flash[:notice] = "Your #{sso_provider.name} account was connected." + redirect_to edit_user_registration_path + else + sign_in_and_redirect @user, event: :authentication + # Set account from SSO provider + session[:account_id] = sso_provider.account_id + set_flash_message :notice, :success, kind: sso_provider.name + end + + # Clear SSO session data + session.delete("sso_provider_id") + session.delete("sso_account_id") + end + def handle_auth(kind) if provider.present? provider.update(provider_attrs) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 43df8e2a..b5199475 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -40,6 +40,7 @@ class Users::SessionsController < Devise::SessionsController def account_login self.resource = resource_class.new(sign_in_params) clean_up_passwords(resource) + @sso_provider = @account.sso_provider if @account.sso_enabled? if @account.stack_manager&.portainer? render "devise/sessions/portainer" else diff --git a/app/models/account.rb b/app/models/account.rb index 1112e036..f41460f0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -26,6 +26,7 @@ class Account < ApplicationRecord has_many :account_users, dependent: :destroy has_many :users, through: :account_users has_one :stack_manager, dependent: :destroy + has_one :sso_provider, dependent: :destroy has_many :clusters, dependent: :destroy has_many :build_clouds, through: :clusters @@ -47,4 +48,8 @@ class Account < ApplicationRecord def github_provider @_github_account ||= owner.providers.find_by(provider: "github") end + + def sso_enabled? + sso_provider&.enabled? + end end diff --git a/app/models/ldap_configuration.rb b/app/models/ldap_configuration.rb new file mode 100644 index 00000000..49f78280 --- /dev/null +++ b/app/models/ldap_configuration.rb @@ -0,0 +1,43 @@ +# == Schema Information +# +# Table name: ldap_configurations +# +# id :bigint not null, primary key +# base_dn :string not null +# bind_dn :string +# bind_password :string +# email_attribute :string default("mail") +# encryption :string default("plain") +# filter :string +# host :string not null +# name_attribute :string default("cn") +# port :integer default(389), not null +# uid_attribute :string default("uid"), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class LdapConfiguration < ApplicationRecord + has_one :sso_provider, as: :configuration, dependent: :destroy + + validates :host, presence: true + validates :port, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :base_dn, presence: true + validates :uid_attribute, presence: true + validates :encryption, inclusion: { in: %w[plain simple_tls start_tls] } + + # Encryption options: plain (no encryption), simple_tls (LDAPS), start_tls (STARTTLS) + def encryption_method + case encryption + when "simple_tls" + :simple_tls + when "start_tls" + :start_tls + else + nil + end + end + + def requires_auth? + bind_dn.present? && bind_password.present? + end +end diff --git a/app/models/oidc_configuration.rb b/app/models/oidc_configuration.rb new file mode 100644 index 00000000..3928992b --- /dev/null +++ b/app/models/oidc_configuration.rb @@ -0,0 +1,19 @@ +# == Schema Information +# +# Table name: oidc_configurations +# +# id :bigint not null, primary key +# authorization_endpoint :string +# client_secret :string not null +# issuer :string not null +# jwks_uri :string +# scopes :string default("openid email profile") +# token_endpoint :string +# userinfo_endpoint :string +# created_at :datetime not null +# updated_at :datetime not null +# client_id :string not null +# +class OIDCConfiguration < ApplicationRecord + has_one :sso_provider, as: :configuration, dependent: :destroy +end diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb new file mode 100644 index 00000000..da7a3f84 --- /dev/null +++ b/app/models/sso_provider.rb @@ -0,0 +1,36 @@ +# == Schema Information +# +# Table name: sso_providers +# +# id :bigint not null, primary key +# configuration_type :string not null +# enabled :boolean default(TRUE), not null +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# configuration_id :bigint not null +# +# Indexes +# +# index_sso_providers_on_account_id (account_id) UNIQUE +# index_sso_providers_on_configuration (configuration_type,configuration_id) +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# +class SSOProvider < ApplicationRecord + belongs_to :account + belongs_to :configuration, polymorphic: true + + validates :account_id, uniqueness: true + + def oidc? + configuration_type == "OIDCConfiguration" + end + + def ldap? + configuration_type == "LdapConfiguration" + end +end diff --git a/app/views/accounts/sso_providers/_form.html.erb b/app/views/accounts/sso_providers/_form.html.erb new file mode 100644 index 00000000..b1b7df1f --- /dev/null +++ b/app/views/accounts/sso_providers/_form.html.erb @@ -0,0 +1,98 @@ +<%= form_with model: sso_provider, url: sso_provider.persisted? ? sso_provider_path : sso_provider_path do |form| %> + <%= render "shared/error_messages", resource: form.object %> + +
+ + <%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %> + +
+ +
+ +
+ +
OIDC Configuration
+ + <%= fields_for :oidc_configuration, oidc_configuration do |oidc_form| %> +
+ + <%= oidc_form.text_field :issuer, class: "input input-bordered", required: true, placeholder: "https://your-idp.com" %> + +
+ +
+ + <%= oidc_form.text_field :client_id, class: "input input-bordered", required: true %> +
+ +
+ + <%= oidc_form.password_field :client_secret, class: "input input-bordered", required: true %> +
+ +
+ + <%= oidc_form.text_field :scopes, class: "input input-bordered", placeholder: "openid email profile" %> + +
+ +
+ + Advanced Settings (Optional) + +
+
+ + <%= oidc_form.text_field :authorization_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> +
+ +
+ + <%= oidc_form.text_field :token_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> +
+ +
+ + <%= oidc_form.text_field :userinfo_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> +
+ +
+ + <%= oidc_form.text_field :jwks_uri, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> +
+
+
+ <% end %> + + +<% end %> diff --git a/app/views/accounts/sso_providers/edit.html.erb b/app/views/accounts/sso_providers/edit.html.erb new file mode 100644 index 00000000..fbb4b760 --- /dev/null +++ b/app/views/accounts/sso_providers/edit.html.erb @@ -0,0 +1,13 @@ +<%= settings_layout do %> + <%= turbo_frame_tag "sso_provider" do %> +
+ Edit <%= @sso_provider.name %> +
+ +
+ Update your OIDC provider configuration. +
+ + <%= render "accounts/sso_providers/form", sso_provider: @sso_provider, oidc_configuration: @oidc_configuration %> + <% end %> +<% end %> diff --git a/app/views/accounts/sso_providers/new.html.erb b/app/views/accounts/sso_providers/new.html.erb new file mode 100644 index 00000000..1e0fb228 --- /dev/null +++ b/app/views/accounts/sso_providers/new.html.erb @@ -0,0 +1,13 @@ +<%= settings_layout do %> + <%= turbo_frame_tag "sso_provider" do %> +
+ Add OIDC Provider +
+ +
+ Configure an OpenID Connect (OIDC) provider for SSO authentication. This will allow users to sign in using your organization's identity provider. +
+ + <%= render "accounts/sso_providers/form", sso_provider: @sso_provider, oidc_configuration: @oidc_configuration %> + <% end %> +<% end %> diff --git a/app/views/accounts/sso_providers/show.html.erb b/app/views/accounts/sso_providers/show.html.erb new file mode 100644 index 00000000..8a86767a --- /dev/null +++ b/app/views/accounts/sso_providers/show.html.erb @@ -0,0 +1,55 @@ +<%= settings_layout do %> +

Authentication

+
+ + <%= turbo_frame_tag "sso_provider" do %> + <% if @sso_provider.present? %> +
+
+
+
+

<%= @sso_provider.name %>

+
+ OIDC + <% if @sso_provider.enabled %> + Enabled + <% else %> + Disabled + <% end %> +
+
+
+ <%= link_to "Edit", edit_sso_provider_path, class: "btn btn-sm btn-outline" %> + <%= button_to "Delete", sso_provider_path, method: :delete, class: "btn btn-sm btn-error", data: { turbo_confirm: "Are you sure you want to delete this SSO provider?" } %> +
+
+ + <% if @oidc_configuration %> +
+
+
+ Issuer: +
<%= @oidc_configuration.issuer %>
+
+
+ Client ID: +
<%= @oidc_configuration.client_id %>
+
+
+ Scopes: +
<%= @oidc_configuration.scopes || "openid email profile" %>
+
+
+ <% end %> +
+
+ <% else %> +
+ + No SSO provider configured for this account. +
+ + <%= link_to "+ Add OIDC Provider", new_sso_provider_path, class: "btn btn-primary btn-sm" %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index fc55f950..c95cd60f 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -41,13 +41,27 @@ <%= f.submit "Sign in", class: "btn btn-primary w-full" %> <% end %> - <% unless Rails.application.config.local_mode %> - + <% if defined?(@sso_provider) && @sso_provider.present? %>
OR
+ + <%= button_to "Sign in with #{@sso_provider.name}", + user_oidc_omniauth_authorize_path(sso_provider_id: @sso_provider.id), + class: "btn btn-outline w-full", + data: { turbo: false } %> + <% end %> + + <% unless Rails.application.config.local_mode %> + <% unless defined?(@sso_provider) && @sso_provider.present? %> +
+
+ OR +
+
+ <% end %> <%= render "devise/shared/links" %> <% end %> diff --git a/app/views/settings/_layout.html.erb b/app/views/settings/_layout.html.erb index 169ccf8a..a14ce47e 100644 --- a/app/views/settings/_layout.html.erb +++ b/app/views/settings/_layout.html.erb @@ -39,6 +39,12 @@ Credentials <% end %> +
  • + <%= link_to sso_provider_path do %> + + Authentication + <% end %> +
  • <% if Flipper.enabled?(:stack_manager, current_account) %>
  • <%= link_to stack_manager_path do %> diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index c74a6b1d..d74342d4 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -274,14 +274,21 @@ Devise.setup do |config| # TODO (chris): add digital ocean? config.omniauth :github, ENV["OMNIAUTH_GITHUB_PUBLIC_KEY"], ENV["OMNIAUTH_GITHUB_PRIVATE_KEY"], scope: "user,repo,write:packages,read:org" config.omniauth :developer if Rails.env.test? + + # Dynamic OIDC provider + require Rails.root.join("lib/omniauth/strategies/dynamic_oidc") + config.omniauth :oidc, strategy_class: OmniAuth::Strategies::DynamicOIDC + # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or # change the failure app, you can configure them inside the config.warden block. # - # config.warden do |manager| - # manager.intercept_401 = false - # manager.default_strategies(scope: :user).unshift :some_external_strategy - # end + # Load custom LDAP authentication strategy + require Rails.root.join('lib', 'devise', 'strategies', 'ldap_authenticatable') + + config.warden do |manager| + manager.strategies.add(:ldap_authenticatable, Devise::Strategies::LdapAuthenticatable) + end # ==> Mountable engine configurations # When using Devise inside an engine, let's call it `MyEngine`, and this engine diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 3860f659..ad5df37e 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -11,6 +11,7 @@ # end # These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym "RESTful" -# end +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym "OIDC" + inflect.acronym "SSO" +end diff --git a/config/routes.rb b/config/routes.rb index 34020f20..e2f9bcee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,7 @@ Rails.application.routes.draw do resources :accounts, only: [ :create ] do collection do resources :account_users, only: %i[create index destroy], module: :accounts + resource :sso_provider, only: %i[show new create edit update destroy], module: :accounts end member do get :switch diff --git a/db/migrate/20251121024059_create_sso_providers.rb b/db/migrate/20251121024059_create_sso_providers.rb new file mode 100644 index 00000000..2d8edbd5 --- /dev/null +++ b/db/migrate/20251121024059_create_sso_providers.rb @@ -0,0 +1,12 @@ +class CreateSSOProviders < ActiveRecord::Migration[7.2] + def change + create_table :sso_providers do |t| + t.references :account, null: false, foreign_key: true, index: { unique: true } + t.references :configuration, polymorphic: true, null: false + t.string :name, null: false + t.boolean :enabled, default: true, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20251121024136_create_oidc_configurations.rb b/db/migrate/20251121024136_create_oidc_configurations.rb new file mode 100644 index 00000000..bb5a5595 --- /dev/null +++ b/db/migrate/20251121024136_create_oidc_configurations.rb @@ -0,0 +1,16 @@ +class CreateOIDCConfigurations < ActiveRecord::Migration[7.2] + def change + create_table :oidc_configurations do |t| + t.string :issuer, null: false + t.string :client_id, null: false + t.string :client_secret, null: false + t.string :authorization_endpoint + t.string :token_endpoint + t.string :userinfo_endpoint + t.string :jwks_uri + t.string :scopes, default: "openid email profile" + + t.timestamps + end + end +end diff --git a/db/migrate/20251121043926_create_ldap_configurations.rb b/db/migrate/20251121043926_create_ldap_configurations.rb new file mode 100644 index 00000000..48ad11dd --- /dev/null +++ b/db/migrate/20251121043926_create_ldap_configurations.rb @@ -0,0 +1,18 @@ +class CreateLdapConfigurations < ActiveRecord::Migration[7.2] + def change + create_table :ldap_configurations do |t| + t.string :host, null: false + t.integer :port, default: 389, null: false + t.string :encryption, default: "plain" + t.string :base_dn, null: false + t.string :bind_dn + t.string :bind_password + t.string :uid_attribute, default: "uid", null: false + t.string :email_attribute, default: "mail" + t.string :name_attribute, default: "cn" + t.string :filter + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 71cdc21f..924d3720 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do +ActiveRecord::Schema[7.2].define(version: 2025_11_21_024136) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -375,6 +375,19 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient" end + create_table "oidc_configurations", force: :cascade do |t| + t.string "issuer", null: false + t.string "client_id", null: false + t.string "client_secret", null: false + t.string "authorization_endpoint" + t.string "token_endpoint" + t.string "userinfo_endpoint" + t.string "jwks_uri" + t.string "scopes", default: "openid email profile" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "project_add_ons", force: :cascade do |t| t.bigint "project_id", null: false t.bigint "add_on_id", null: false @@ -477,6 +490,18 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do t.index ["project_id"], name: "index_services_on_project_id" end + create_table "sso_providers", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "configuration_type", null: false + t.bigint "configuration_id", null: false + t.string "name", null: false + t.boolean "enabled", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_sso_providers_on_account_id", unique: true + t.index ["configuration_type", "configuration_id"], name: "index_sso_providers_on_configuration" + end + create_table "stack_managers", force: :cascade do |t| t.string "provider_url", null: false t.integer "stack_manager_type", default: 0, null: false @@ -544,6 +569,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do add_foreign_key "projects", "clusters", column: "project_fork_cluster_id" add_foreign_key "providers", "users" add_foreign_key "services", "projects" + add_foreign_key "sso_providers", "accounts" add_foreign_key "stack_managers", "accounts" add_foreign_key "volumes", "projects" end diff --git a/lib/devise/strategies/ldap_authenticatable.rb b/lib/devise/strategies/ldap_authenticatable.rb new file mode 100644 index 00000000..1587cba1 --- /dev/null +++ b/lib/devise/strategies/ldap_authenticatable.rb @@ -0,0 +1,79 @@ +require 'net/ldap' +require 'devise/strategies/authenticatable' + +module Devise + module Strategies + class LdapAuthenticatable < Authenticatable + def authenticate! + if params[:user] + ldap_config = find_ldap_configuration + + return fail(:invalid_login) unless ldap_config + + ldap = Net::LDAP.new( + host: ldap_config.host, + port: ldap_config.port, + encryption: ldap_config.encryption_method + ) + + # Build the user DN for authentication + user_dn = build_user_dn(ldap_config, email) + ldap.auth user_dn, password + + if ldap.bind + # LDAP authentication successful, find or create user + user = User.find_or_create_by(email: email) do |u| + u.password = SecureRandom.hex(32) # Set random password since LDAP handles auth + u.account = find_or_create_account_for_ldap(ldap_config) + end + success!(user) + else + Rails.logger.info "LDAP bind failed for #{email}: #{ldap.get_operation_result.message}" + return fail(:invalid_login) + end + end + end + + def email + params[:user][:email] + end + + def password + params[:user][:password] + end + + private + + def find_ldap_configuration + # Find the LDAP configuration for the account + # This could be based on email domain or a selection during login + account_id = session[:ldap_account_id] || params[:account_id] + + if account_id + sso_provider = SSOProvider.find_by(account_id: account_id, enabled: true) + return sso_provider.configuration if sso_provider&.ldap? + end + + # Fallback: try to find by email domain or return first enabled LDAP config + LdapConfiguration.joins(:sso_provider) + .where(sso_providers: { enabled: true }) + .first + end + + def build_user_dn(ldap_config, email) + # Extract username from email if needed + username = email.split('@').first + + # Build the DN using the uid attribute and base DN + "#{ldap_config.uid_attribute}=#{username},#{ldap_config.base_dn}" + end + + def find_or_create_account_for_ldap(ldap_config) + sso_provider = ldap_config.sso_provider + sso_provider&.account + end + end + end +end + +Warden::Strategies.add(:ldap_authenticatable, Devise::Strategies::LdapAuthenticatable) diff --git a/lib/omniauth/strategies/dynamic_oidc.rb b/lib/omniauth/strategies/dynamic_oidc.rb new file mode 100644 index 00000000..9591901b --- /dev/null +++ b/lib/omniauth/strategies/dynamic_oidc.rb @@ -0,0 +1,61 @@ +require "omniauth_openid_connect" + +module OmniAuth + module Strategies + class DynamicOIDC < OmniAuth::Strategies::OpenIDConnect + option :name, "oidc" + option :issuer, "placeholder" # Will be overridden + + # Run before request_phase to load config from database + def request_phase + load_configuration_from_database + super + end + + # Run before callback_phase to load config from database + def callback_phase + load_configuration_from_database + super + end + + private + + def load_configuration_from_database + sso_provider_id = request.params["sso_provider_id"] || session["sso_provider_id"] + + return unless sso_provider_id.present? + + begin + sso_provider = SSOProvider.find_by(id: sso_provider_id, enabled: true) + + if sso_provider&.oidc? && sso_provider.configuration + oidc_config = sso_provider.configuration + + # Override the options with database values + options[:issuer] = oidc_config.issuer + options[:client_options] = { + identifier: oidc_config.client_id, + secret: oidc_config.client_secret, + redirect_uri: callback_url, + authorization_endpoint: oidc_config.authorization_endpoint.presence, + token_endpoint: oidc_config.token_endpoint.presence, + userinfo_endpoint: oidc_config.userinfo_endpoint.presence, + jwks_uri: oidc_config.jwks_uri.presence + }.compact + + options[:scope] = oidc_config.scopes&.split(" ") || [ :openid, :email, :profile ] + options[:response_type] = :code + options[:uid_field] = "sub" + + # Store in session for callback + session["sso_provider_id"] = sso_provider.id + session["sso_account_id"] = sso_provider.account_id + end + rescue ActiveRecord::StatementInvalid, PG::UndefinedTable + # Database not ready yet or table doesn't exist + Rails.logger.debug "DynamicOIDC: database not ready" + end + end + end + end +end diff --git a/spec/factories/ldap_configurations.rb b/spec/factories/ldap_configurations.rb new file mode 100644 index 00000000..bf7e136e --- /dev/null +++ b/spec/factories/ldap_configurations.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :ldap_configuration do + host { "MyString" } + port { 1 } + encryption { "MyString" } + base_dn { "MyString" } + bind_dn { "MyString" } + bind_password { "MyString" } + uid_attribute { "MyString" } + email_attribute { "MyString" } + name_attribute { "MyString" } + filter { "MyString" } + end +end diff --git a/spec/factories/oidc_configurations.rb b/spec/factories/oidc_configurations.rb new file mode 100644 index 00000000..668b106f --- /dev/null +++ b/spec/factories/oidc_configurations.rb @@ -0,0 +1,28 @@ +# == Schema Information +# +# Table name: oidc_configurations +# +# id :bigint not null, primary key +# authorization_endpoint :string +# client_secret :string not null +# issuer :string not null +# jwks_uri :string +# scopes :string default("openid email profile") +# token_endpoint :string +# userinfo_endpoint :string +# created_at :datetime not null +# updated_at :datetime not null +# client_id :string not null +# +FactoryBot.define do + factory :oidc_configuration do + issuer { "MyString" } + client_id { "MyString" } + client_secret { "MyString" } + authorization_endpoint { "MyString" } + token_endpoint { "MyString" } + userinfo_endpoint { "MyString" } + jwks_uri { "MyString" } + scopes { "MyString" } + end +end diff --git a/spec/factories/sso_providers.rb b/spec/factories/sso_providers.rb new file mode 100644 index 00000000..2a8c4171 --- /dev/null +++ b/spec/factories/sso_providers.rb @@ -0,0 +1,30 @@ +# == Schema Information +# +# Table name: sso_providers +# +# id :bigint not null, primary key +# configuration_type :string not null +# enabled :boolean default(TRUE), not null +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# configuration_id :bigint not null +# +# Indexes +# +# index_sso_providers_on_account_id (account_id) UNIQUE +# index_sso_providers_on_configuration (configuration_type,configuration_id) +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# +FactoryBot.define do + factory :sso_provider do + account { nil } + configuration { nil } + name { "MyString" } + enabled { false } + end +end diff --git a/spec/models/ldap_configuration_spec.rb b/spec/models/ldap_configuration_spec.rb new file mode 100644 index 00000000..22dd7518 --- /dev/null +++ b/spec/models/ldap_configuration_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe LdapConfiguration, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/oidc_configuration_spec.rb b/spec/models/oidc_configuration_spec.rb new file mode 100644 index 00000000..3fe2e56e --- /dev/null +++ b/spec/models/oidc_configuration_spec.rb @@ -0,0 +1,21 @@ +# == Schema Information +# +# Table name: oidc_configurations +# +# id :bigint not null, primary key +# authorization_endpoint :string +# client_secret :string not null +# issuer :string not null +# jwks_uri :string +# scopes :string default("openid email profile") +# token_endpoint :string +# userinfo_endpoint :string +# created_at :datetime not null +# updated_at :datetime not null +# client_id :string not null +# +require 'rails_helper' + +RSpec.describe OIDCConfiguration, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 251f1778..6c39f1e7 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -18,6 +18,14 @@ # updated_at :datetime not null # project_id :bigint not null # +# Indexes +# +# index_services_on_project_id (project_id) +# +# Foreign Keys +# +# fk_rails_... (project_id => projects.id) +# require 'rails_helper' RSpec.describe Service, type: :model do diff --git a/spec/models/sso_provider_spec.rb b/spec/models/sso_provider_spec.rb new file mode 100644 index 00000000..3aa365a1 --- /dev/null +++ b/spec/models/sso_provider_spec.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: sso_providers +# +# id :bigint not null, primary key +# configuration_type :string not null +# enabled :boolean default(TRUE), not null +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# configuration_id :bigint not null +# +# Indexes +# +# index_sso_providers_on_account_id (account_id) UNIQUE +# index_sso_providers_on_configuration (configuration_type,configuration_id) +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# +require 'rails_helper' + +RSpec.describe SSOProvider, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From 7344f4255219d52a5c6358a2d0c06272a4c5184a Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 21 Nov 2025 10:28:27 -0800 Subject: [PATCH 03/75] floating image for 404 page --- public/404.html | 2 +- public/images/illustrations/floating.webp | Bin 0 -> 303224 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 public/images/illustrations/floating.webp diff --git a/public/404.html b/public/404.html index 27b27b83..0453bd96 100644 --- a/public/404.html +++ b/public/404.html @@ -54,7 +54,7 @@
    - Error illustration + Error illustration
    diff --git a/public/images/illustrations/floating.webp b/public/images/illustrations/floating.webp new file mode 100644 index 0000000000000000000000000000000000000000..06c339ed142b95cbccb1066429a6023685de7f46 GIT binary patch literal 303224 zcmZ^|Wk4KFur9o~OCY#=@Zb&sf@|=_-QC?KxVwkozPP(<@WtKTosajPALstKU++%O zQ%_ZO_0&lBOe;x=i`(Zx1JuPt6x9^DH4p&+!1vEB7Y)FR29OdJRaAle6akPb)i-6~Oc<0sepd|7)8NAP=zn^fmgd z?ExMDM}P&u3}6m$1~7cGE}yNNeC|y@d6UndHNfM){D1m=mjBJGlPMeP|Dhm|MF0Sp z^^cDmDgXd35de6L`}lat|M+{Iuj9#F6d0H}C< zo~j7|AUy*Bp#HQ+(-xLhlni%+06=`uP)c5YxLf`Fdk#ciX+)uT`l!xqUSs{JXOoMPRTq9n}mKiW0e`TZD61LSTJ>WSDf&Uv^11RAkvj z&6DRH9p*tUtM-eR_QBup3jPAGA2==HzL=$L)7am>x;1SpqPn@ceLFvw=iw$qr9VIC ztOG}TC9+#2P|Ichm8x^ldV3YFHYr7}r_I{lMBc)cawL5)W)ur04`#cgp3Q)3t z7Rt}U&HXqOOR~}B_d)#i>+RV}lbv3dFA6d;GCVx|`a48VyxKx#%B3C227ia!TknWyTT_J& zuibNSe2}M`#bUegL_{It!AJ&ge7UJa@_rJjSP{S2*rN4n_6921UN{*tSli7qJ+Tl?u3Q#R*&LCOc|kS< zr?qcY5~pJ|dGNPRXT<2GetUd{e7lBs~d2OBa)++4MyyXEV)8J;(^it}onA z`wTh{u>pyxXWT9iI^1I_GD}Udb~&pZuJ4Zl*#zp0Icz4UH4tylx99V9UQJaxye_Ag z5^aq(noZXK+JT9K(fAA1>P_;M860Kml}as2A=i7ODNL%MpS6F)5OEmdx_)eJ7mlfy z{wfxpO|ju~J_5UKPRH+jUU(dSSJ(3=O{Ysvo5Le@IMwT|ACKz|H$#m~>HIaKLDwF$ z?w0qb+|*;bW%6whi7B>19tSqxWJc4be|)IN+UfClId9yf&|^<#@Ow&b*q&*zSE!ac z$MXYcsUAMB3P#RmYUykazmZXwjJJOcK9R5-bSZR6d``!I*O_3w>49xFAK{Tr z+0A=6y%_(l`#z2)>fjnH(%1|nV;iF_RlWhgx$U!$U^L^7pqerY-y<@@O)h)C?q}+! z1nlRz#a8F8H(7uy9D-UKs`OgitDM7i*g;+3IBtb`*S`IW=11PZf%WUN`LkKCg>j;l zYM(bm7*=Z6nc*rks}Y=zB(>@-2;}mID0cEF<5zU|o5ExQ%4Em*{r;IRCCr!r4Zf zOhFn4#HM*Os#)dnftM*S^wc9lbaS-TvPNHoYOPmn+(=0c64I1OD|gH!b85==!<&SB zJG)A+L>juY1vV&pGDn{#-A`&YO=an*zy8rktAs$4x6Kgvt8)iQOEeN=!6@A6>9c>@ za+3`Me!ZIf=Q0adUzSCa9)6m+fU#S?=ytx|CZrKuu(82k?BT{tIUzyeRnia)gMXXbbfNn_(ssA}M$7cxN{KZa6D(Cp#W0=s z@%r{4s$maxPN>aUWOT$?abBM1?-}u`O(jZFc$HydTe914^@JHP)aX@Rq|J9(l-^Dnn>9F1!ip|xzmINP z`uI7_{+(2FF!)}~!tm$6fZ%dhVmr{{GNWB$ zJVDimKE#`Y)5-4tgj*PNR`p`!ze<6*9oxGr(88Ac8~?U|5ZRDjeU_H~E4E|OV3a%ZdU&pHu7 zk$sn$8ucN{qM@y35Gb9SXVh~)#0Gi9(l#qml+u{hB%?B z3Ops6NGNd7Sz7r(lIVp7=>}2mMrC`o_`SYWX&D;WT_`k!W&ht~I3tp0oX=9u03S0y zPJi#?&ggNE3oG9zRb(wked;yQ`*^<8UbW}V0YcNdspQu`JCnrjPm|!Isb|;}b~U1@ zKH{^zKPGGwuznImkIi3|jy2w3aSF#s}x0Y1DsI zDOB8a5$0$JRf^x6(^POgX+xj9#KH~EOBKcoENkw&(i7Kp`gPekPwX1!HY#5Qcy-oP zJ*)`)(NqE{^EqT&;E)5Sx|Uh$9SuRsT4T>cWyKW-Q1@u%hobkEe5r)(%yBN><9APt ztmekmt_hxOOcHf54;w6bf9b*HHpA-<6HW_P@i=Cj(qgVwZ7F!3n*=A1>Yu}uBV}7d zht*U&e&4ge?d|Te)QGQ56PbQrlm5*%^>=J{GJ_RqZx~)HlxU_!6 zKv3xncb>7IEp(Xq5Ieb;79<%TG}_%@Rcqu*C4h+kXLy-V#d5wPv#EJ#5EcU0~XcyflixX-m9Bnu3`+vHMP z34&13_yj@qY~U`NZ~Vugkh_P6H>gPNIHFZ4O&etWw>J}sDSmNK(29y-FUrEk1*h|9 zS-ik1?Xn$`;o6(6OJ+SjxbIBE_E=|OP3Ycv>QB7RszvK5fHBRBzk{0TJ-6sdG9+^pkbED97VhJFW{xq9TdQEPk%pO{_69W!c%%}G&@a}-^{}UthXa(jQhWX9p`w~Gbtu`D z(yOH-)8?t?`S~cYb={cDt&DicNJHHZJSWHjo~Ya-@`-eDd5nMeKsM%T5`Aj1(@@Z= zmSZ^hdGF-KzKq`Q`TVpED`+g?cFh!4pdleae4Z0?dbr?-+&1X>yq2J8Yb?=JdOMC& zQNV~3w!bhOW836u1qqb!TbUy#1J7pP#=d>2D7a9dwIKIutwU}#H+ds;$g{!+6|X3I zA7d{3^NqA@P7qitk{%PR^KHx`$x^29YL&xEfvxd!WqQT0t*IT2a4oUD*XHNY zf2xu*oU;XX{P$FeM&8e}`j~6YUhRERFu@zyohQCrUj1Cfsx|+*F77RFefYilafVVs zhZ}BMSlGdJ65fvPWY1jdPlLK<3QImUeoUW2en(6VO;&rGgz2XsIKfF%3rF}N6NdgN zHI~Wfeo7trV!Y4l>mf922566aO(;G`dLCxSZV>{TLy?wzhk70_O>0}mQxB8l(wv`m z_sivfz&9P&du`Znd>09`B%lK?QG0LZ# zXpK&5Fny^KctIoL@jX6$l2NbTX!ZK&X;6M?s>tHAaL+}ZuvGt+z?NNn&qoMT%x{3k zfQa1~sab!aP7qk_>tMsEahv>Q);*(q!9DqA?dl`Y>!%_+Qhc&x-@y`_Hf9n`0W?nzYDFUV{1v{sryNFD)@Om?4zz1q?K{56}aTP zN0i7cql+3dg3g)*v9$H_<6yQg)=rhN3(m}^w**-0jIJ%@F7uo_4DBK&3ICVphb+mcYBz?awcX3`X9HdhI_t~`6Z&a zNT9{GT%&s>!K#QLH0oXo=M($*(V z0L+#f@)_6MGu_2whPm=>W;O0+toXD?e4JS>PVma!Z{ebPA4^6i=qyN2`?RI?q5hsq zGS^L&^5j+Y%zI`So%a&ftHy0`pIXw+(mqA(av&}MaNd~aSJy9_ME@hJT%4ntoH$h%N%I}Pr&HbK|F!aeKm zTwYb@0+rqf^XlybhR^oeMeNkZ#2&E%i+o0oUn-`Yk_I>ofdAMjlXMdMiLH8DH8}(C zN*6VO)^2m4nP7c8*WUt*vmK)Fi!^D(Bimk$9c~$Dfv~Fvv3GmzZtzQdXpe)2ul5>} z8K~D2;f4zH9c_hZ5MH7(Lf8jsJb1H~`l>JBft=$r79NDPw;>0=zU^Ub8jk*e{v$GyHKr*E09A ziJWtwFWpnV*&!PO%%pNq7nO3RQ@7{8$Ad5Bf(kTX*DEqiA-wAPPSac6*y}O-r#wTb z^1Z6M{w4|9LLt`ourUvUhlb#yGq%(GE~xA!T!S3{7O#sf)wNF}KLptPj`;X*zYOd z`9@Z+#4>Vy2ium%;pMKp80|v4%lZEECBCiER-?muD+@1i=*~`wJi6eNRo0ltqqAP6 zzDI)eg4b&<&bAxsUeXSE^v9BtjBeGMKkb-Xw$CGNsGs!7&`H|?n(Z&@pt`K<?*-$d#^_6iNbSA#z7&pFf zncVDK1d_|%#>c57P8A65_`NVi%zUQqUxJynR%NRA*YA=kO1Z!kYW9=> z)_4C`i9+3gAYu^Qv!%GMNTL)kafD&!tAz`NUHA-Z=w4}E0sJi`@)9T4z$p6>mRDY? zCp3DR?BYdxvQ6{T>85rDp@p4EDen&eFIMQQ0AuoK)3+P}g~feMY=aT&f{Pkd@22{g zw%5Z^kV_!d+xRgj?73w2I`G19>SjKwJ(#M4SI1xt*QMPKTh8*<^odvP*@O6V)|rM6 z7c?1%5*OYX;~$u$!9mmEjn0T2~VQ<+bFm&?nmYF}`R;Ol zQuq2tW*_T=a;ceiZ2q=aA%m`tUF{GaivXIz2)|VUkXfRF*(?q!RE)u8EjuJ9(crZA zcCNp8Navor`u>uJ0k{o)a-vS5ECbjfe#Vql*{H~oC@#uT+FEF`*zXUI1x-s^= z;#pstxS76Cd+X8wi#JHgL(hJ}=CK!W;v4_!BmvXe>h!+3Z^9;i_g3(0ejA8s4$o>a zZNBEDgxd8U3!asIu3`@uQ7LDrm=Z#<$KeTMWC+Nfs0p#^#KznpDu@AuG-X;l5{2l?r%Rb9S8o#}}1gF+m&}$&laKU1o!)Ph2eawtH zct(9x_E{C(Tj{cfKrmEs5u?geL579Y6?@#&0U#5rf`T+Q{77;or^P{(@|OP8{E_v! z4u>c!3&X_{Daf|k-{N60s@W9N(^>zL+$9K1h{~NpQ_B&(0zbm!6{rnuH1KJ>0TdWX zhuklRe2sfO5BHNtI((^oyv8j4D@wte4%JJ2&bHqa>_PWN*nPh}N`uP{qxiQUH6Mk? zrd`*=(JDxws;Z9kcv?acrD@=xZj=L%^~6D8cpPk8JtIk88iWO@g*fG_Jq(FP<-%~H zImB9Tz*Jv>x;!Tb<{xP=*j?;h{mOs9eeHatzc>HZs>34{dgyQlNI&K5IXVO9HZ{C2 z14tKIeBp}fhtHs4_%8j8thHy33{DMl%=bw7(;x7Wie0pbe_gjufu1~syElJ%yr#o1 zjIp3KA=8})L&CBBx-cBWm_j!5Ia*f%lk*>Ou8oVdHr1f>;n_s%w73 z&k$XNtHbx)t{zj*jvHav!lTFsK>wz~uW8y_GZE^V-9N^7wqcQqpNt>AN}n5ns7aTo z!n(quSsFbCT4y>+`39}_--JLeb+#h?I!>~+#|Eoe!2owKd(0XQYGt}CMIAV_lpn~ z^wlqE<(6%J39FCXuQaH5dpVWK&*xiwpC+ZPOxI*D@64MMj7UlYl*Xn46;6nfDbL^{ zE4~jN&2!ylST((wB5-I{HA;~rCD0CUrzT0pNJE0U@|(!;i~(w-&kqz9$?Yl7t4dz& z!SXS(Os{&uOi8)SVTQOr9o)n3I&agTpqQwf^q{=L3mEKousmcQk*UXfd2Da zgMplwfDE=spoimSX!G#Wa;(d4_S7qamSu;L24ZOAf>)=oQnUoFYv`y}frh-`X|9Su zheRXim))_Ry=}tE&)55SoE2Vp?N&Qei{^FFMT<6#sy2djXN$qd+Wsi8)}k=Wo1|Le z^sz7xG2bA|lR`}l|60HJA?poP%I_(p!BNrocrULqD1v??kw zM)BX3Vcu&EpU6#ciDx1YWGG=yv!72w$}v{Dil zMo@XjZz{8P6l6I?C!81zf)0!of7~2-kJR6dUM+LDS{!LIdyRWu7tcVml!w$*v}Y;# zF&avdeB{*bFYQCfXr8QJXB>mGnYi%G9WR5IjE2{I#=I7it6T;Uj9gjj+FunF?UD^F zK1d_e_y2tejIDYRY0pi@JDW%iSnmFw#GucTM&)_Tb}4c)A?r;GwP4`rJ2|0GJ-CjE zea1B(q4qHY6L7PHjPk@*3DTxfMS3b3c{Hq%$^if&w^t}U1iMJ$5jbjIrx{6_W zdYK)87S#zOL z(RQ-Jbe$AQWQu{ueX`4pZprPyY@z&@UdWe8hPUXw!y5)(+DPE_6}Iwt{+&_{f6umSm({D8ITRXwX3n%uCAzHKuR`c6K;e7mHIYdv5&xiaCG?0l?_I;l73-;= z89mtR4eH*VLL3@nj-A19#L~9XOA~}B`%T}qaAkPr<%Co{Bmu%vmBx6iXY7L}SGuoJ ztOoAYQ`WOLV=$RP&+C}*B2(Jm4G63Lhvy$dpU}O%3boTAdxs(eoI3fRJTw}jD6?2@ zOGEeoxmnMY+5&c+*<36GHlr~c_wQrZ>Ws({#&E6vwkTvglTBF<#gPKLX(M>>HoVqG zJbWv8MuB=IfiDoIDWx|ODMxNr=F+?9t>kNYqg%p56T_1BvleJ$%TF1;>7%3eW#NVM ze!ZYKZv-qyUY%%P(Q*k)1Sb&sy0hT$wQFmUI_qG0(C(jo)zUGBorgUolEvxDd9~ZY3N~x(27T9=w-3}*&GZzNylpU+CHf4(Y%E;i?c7#4;)6k6~ODmdc$M7LqO!W)LFF|$s=dm*XL#s$IZ*eWM zZMBeQts%b^TY;mFAqTD?TYA|ynz3SGDa!{RmcZ;aAfgt*A<9X;<>mr)(uxWjecx}M z@VTG#>ACO$Uu~6Xd$U~_9Dh?p97SbR5LWYz7avyxX?;&jRnVn_WeY7Yq~;qtWxDGm zxyQEsqn-_mJvVKOl&@_n2<=i8(Ps78%}c?Zh{619ur>U?DT)fG!-&@b)&Go7--A?l zdG+RNu(ER*)UpG!tE|3U%f1GhT*H_VT~$SFGRqdVs{?<*J~$~Nz5K(9%V*88)Uh)7 za_ijL>*!G~%wkez_7*(or z!bm@H7yuh$Ppgv%t<&?B&aj3ge`;Ips6X4KcS$~zoHZyu$&rSBYPNK)C)@cAgOuJR zBKxcCJRUQMxiD(Z;3x7h+OJfOF64A86lX-*kY}FpB9S~ z*3S^#qr!CFQy>!3oDxYgFe_=_8!GaLIabPI571^Ur-p4xYfv#Br$V=n*J{SjUp5gY zq;#y(l~W{tL{Nsoch`1Dx1OUphs>D7#FVOyt9Dll;Ha80xb=01^c(PgZ$j$HEoPap!_h6gGo2IALwG8+)kSnFnUpSq3z!TkXft-Cg!sPK1wMf;}kJ6aMNzI&9E4J?5(sKv}JW0P0DU;#+-}mu#Av0jXMO~6;{FL`i8y|B`AL zufyVZI7SA_JlcgZXjioFZ3WWCf!XSrqj~ zNRroYc`tnW+q6PidxS!8Q4LguOG)NnQ}Cwdf(&g)qP4Jv6)8+FpdyH2$@Vcwz2~rE zB{Fm&nicE(hBWEbfv9a(EG>jhs!>K{0M->+wrYeZ1zGG893+ZN20%(1>vAxf*33bAddhfizmjJMCPoG?*dDkg}= zdqGy)sCJS{klTm}cx#-a*+wD>EZWf+{4i%)T>d7Nj*=cHP|+(|J{HHr9IPL>r9$Ii z3U4rr9Iu25mp;nOi5San6tQ8z_m~1?CHPf-DpUjDC#;Y(1vj1 zz)pX&o?x`Cl(thgx9@BcSlpnokgHEF(Wmw{>|Gmelq@Kmh_|BFO-ZkmUaVsctq>hs zs3X?u!Eaf8%B~B^Ri~+En1nNQ*T-<9jI&rz3Rz;Kv94!M?6*Q+RS_x^AXBm2#5WSr zC-QxwB8ln`G?~iKB8J_(M%^%By}ElI__~K)?Se*Nw_ot>y8?%*M@BfQC0EK5SK7dO z4R9)hVL>0K{E;Bsq(3Gb_pXCU8h!q*U`~q*OljMw)g0Wi-IRj$Hp_l(V2d=)9+yG6 zNFBu+xA#s&eN}kII-xP*5}N$>atacDf;Gh}UvONG%W$QNI=)lA=7?p1F{wPep0+Jf z@fbw>u=+J(&nfFrBFlIxHu{VeFvr61!pt7RXwbF5shmL#nn5ck$J%$kT+FUc35y%G ztq(~0Nj77bZU~FB{JlIX>6}UJ^`D$c@4;pV$4V1<%-4q!=76~)hL2eGATH}PbHAH@ z{K?~uWgX&Eq`?!P01n9jI}^&dV}g9Z`}ZI^g3uK_nnqv+Ow2#y9y3`ny7_ier5$xK z_(FLztY!i~HvOS;xducl~zTC?h$sg62H3#!3ZE{+L z4(SA6V4;SN&GqX3Jq+_fDB^8bg=tQ2X{oUoBA_VD1-ZJ<0dG?XDvN(GqLI0DeG%tw zK%wuW3u*ELbKuCvAIDda5xZiyd3?WKwyva(NqYaByq1Pa90OjiU4wM`o+jdAF*RiP z4=J6^2#N&9y6|cJlsF*E?lj2WQ=7PKGJC`&)p4vMUI@j`ffPUgOPP4c8A6Grx~j+q z<$|MiW&!oE;sTBvAN;Bk7&c;l8zKsO=!+8Ll=PM1WRl=YXdOPu0`dZ$8}ILxDOMP# z`O$CU@m35ajHb$JpTa* ztH$cjPp6D5zHp*8aq+Y_)uFabelxWi{=Qj`7fq=~Rz$rkeNHM;{%^aHN ztZ~Uq!(UKZph?ApK2??SH@6-6W|$nJ#z_mWJvLu@Kj{`4$`R`_Dwg4{!+_%NS!tJ4 zvys_tIb)7>#ijlL=KG>69?B7NHwu$sfA$S?`l6f&BFOO>d&k*Y$nae3NR4U}nnql+1 zB(jw?9x(*CJ{m)(jG9fua6cWTm7~>ZOF6dY3nIvQGYjhtGKa7XwVh2FXO}fgsblDF-!!kt<2S%@651qmqlrqpyELt&K@g=X2xeZs%!Y06oDR- z3p;Fbt5G6jvn#B8r>Io6u?hg+Mxu+Lx6pX@-uK1a=Q$*SKg}GSGtQj)DjvVb-%UJ} z23smaK~!3LA=<|)pC{`>R-6HKbbJ3zG@>1_&|3D_O+JwZNQPfTK?m23$zJG>dgP=6 z%3YKVUi<$c-A@(64!p2bxaGKDmj*?{{k08ql6%AMfRTBRhx!|5Z(uxf z>TjHM9F=I`%wupq>Py%dK88mQ2$feduRj?XV6Mg|dtz%i%6;*JAqZ#zI}1*SCvb1Z z`{EpaH=XhssRt4rk%5g?Fpl*RsoSuvkh2DjZ)I7yi{ML4c18H03ITNg-cLVFLt?7}vZsD2!tcW{)0LI1WP-`8?i9MbhdlIQRx*>M!Y8FnN=jT$)#GV^#^S z@9#Tl9mXG_yGFXqc2GNDyl~b+=8XzjQ;#$$iF*r{%w)`)MfQ&CHKHGUC-6qC^=(zb zq+z*{F!h&j7~y}k9^E{{RsvW3s9|F(yjOH8=+PYDJys0Buy7MkhcJYJaj4rYpbZj& zrAyDE+jTX~IL$v2@3UV}s#JxM{%s?;1^Xj$*#u*XLV2(QkTnyldZ;&kyCMoF7iwBj z>MO&nBy##Jz}FquHTv~Ye{_?UdoKwCLBY_3;dDzQtVNMiUVV$NN`$JvQ*WplLh%SK z-T%aLRFWxFDWV2nAST&^^GlZzgRx)#^Rc*GMex#Yt*fhlUTdg8=c)9U_6j==7E((jA=UtIWvv#omw7d( zG>UB~Jw>9rO1d?8in`&HV9^4pbu>zC2PEoz>cI@S6Ph;Wu3MNQr*G>u0n+1STe_pa zM*?IU%abFY;Fl%Pq65pAIzn?jr2u56YyZsCG@UkQX@mTrsfEKW_iO~8&nPA>(Srr` z*IcWGVd3L_SEpcsW*FZ5CiVi_GcohWtb%_yl_;FLpF{3eIvsz&*X|`SLG>4yUQtq; ztloxDWqy^hhQz_EiKj_}@&WcmkJBOrr`cQ7I%j{MfT9f=%1`j;9tiYlNi(K7`wlBj zbIk3%D1{H7fBGUUSp|Dzzb=V~@FXndq*l4;$TBPMX^IDVR8^b9Cr1uX#@TF@EW5Yg zbD#`+0s21Txp&QpLjab_8bj9@$f#EGBL;rJq$z}k#Qc@?h2WTI_tK+nMj z#aq*OazKR>jK0FZ!5BUztJ^M*hc-i&AYP$eu#>*Du9jg9{WapBn|p^dy5TmKiBgGa zZrx->6*-_kPbm`c{z8NGUC)rWm9h5e<@^B}Q^+IQ22K6HVT zUGv;?PF9_PpMzKSpbmDZx2OYL-wm(f-{psz#$zIZ`Q5Bg-OO;h4g@@ThBY*3W-Bsg z)73aom)dv^Bx`ZTbO{xc_(`aRj{OggIJHCH(&;^#mvj+X^!1Jvp+VRQ=pSSR{;ND! z#{RZ8$<{6gw2EoxbV>=yrziZ{Cd>|;hqL!=&>Ng1VX-4>W2!9B0y`m34yAdc0an^# zt>t(^Xk1IOm3h@lrLhP!#bSAP5jL&%-HWq)ZAw7Wnl_P2Bua()e74dG zm;8zekXse;Rpjsk*@vvv7`s7Nb>JilZ(a^K8`Z(R+U*8Y&o_a z@gCw$vf#!hA%1>SmjuzlkI=u7k?#%%7_AAj)`7AIdkS5Z>nZkK^x0AGk`Yp;D-#OJ zA+F)(IoNG{94iP<5U=A~!Kpzqr__@$9wUL9ztx9>`3^k`;Zu9#YF|YV%^f!_)#Id& z_mxnYK-n3h>)3-vR>a|#$Uu0dXM-j2O0BL#L>{$R7btMKZO-1N$chE)_PqX2!TgaR zw~+(4AQ`7Gu7x$ z;~}=U!j^!Wd(rh8svQ_L5=-<~SP)nXZ9>)uL1bCS)FV%8U-}{e7ZMbXoMpsUOS6hq z?~mb@2#aD2c8<>0XUS0i3+3(jdiq|gxOY0-4ZK%&sApRfblWL2bxhVSxP}(D$>Q28`HmfJ-Zsu3Th=OX7#-f8NhU(0qR3u7uUwx>icJ4?pE@gmEMGmq<(}~6MRLu=O>Y_wSUYn3t@3@f zs*!qTgb9Y<2s5`G z#`^pPNFAub15b=i6cFhxT5=!s${Z(hRle8Qno-m?&wxAMiXxZ83t0a}4%iVBa!~@)pTvNey-m3oKp;|8v<;{^QhCFRbvuC}UK=L4g znZ!ISPcCVk=N73u9;v!Z*@d|D)9>;jhIzI;T>0=7MFC-3{DNDOK&~+P+2R6((mlp1 zVhI&A&hJ@=P5Gw!+N1Dko;%-M`KDo<@v44AvDALo}%W{ z&rrc#!!D-vsc^+ARd~mKU#BBD#_u<&1I&Aw<@04%X`*3Wqh91XjOJ3;Xg7PfstDoW z+O?qE;cdIJC_P}eS{-V-3a_?ICm3&o+ALNo3*6ky*kTloI5%*BdLVkrpV!m}uu(h8aHh-}(__7dl z3~d+6XFfmyeXal+H4ddCySP3gKN_D^&53ckGymhCLU3WkXGz}Ppx=yojnU)?dNp<= zvwPVWStQas+A)(0Egyo0nX_qcIx9grH1!Tw=T}_S9C?*0iE}g0pY|Hvrn4o+uFBE+ ziatniPQ`c3-^!ui{k}Hq0Td-kF{HwMQxnz>C>>4gUys{l{76H$_Gxdgs@S@ZIJm5E_9Zo z=)WPgTY)62y{@h{3Vmi;JKIUX@SL{~TT*(9n>Zt|KEtZxJ9?IwCcT(m7rB-rw&Ie% zqTISZI#3ZIA+S}~vZMpK7_ClH8_Nub*7}m{M(5J)l6cv6la_ORwawkrK=`n3ma{3H z?>2RZ)us=zrUjlnBNkw+(M7eaWCp>)pOb3~38{*LLo*F!h#(s?kXQfF{ye1t9pBB} z3a>9>1;Mkt6={O$xNy#M^~&?<`e<4Bj;U+1e=_Az8^RcieVAJXw0D^zLKPD|>1Y>` z6vN|88(EeN*pr(}bPP4DBCqx6LM}*R1HLPwc%;fnfY&TUxKPt%09z&98Q22OOHjYIqY0b%!}aMMXL;< zq`~a9ijk-uV)!MZHv-*o{GVNw4E6I^3tLEL$(<|~kwJ5@b!kpo(Hc}u!Jh_yTlnku zEHd25rTbtl-4`b@RVm$t+y|2H%3qiYfIzHVgJwRPQiL?M_%Hkg`Zm%u4&bj_ohv`^ zbPSs5ZA$mjT+w@rkQS)D>&J1J+9{I%orUuCr7Xi)0P%XEvIi_#3831LxYkLM|LuhG z^`^8UT10-2(h5&D*C+RFx8Li?kJ4pd)oq+3_<4g2Q!5jA2ZhN6uf9&IHfV=yE2Bjx z0##oXwe2x*EJduVvg%P*Qt*JxHI`ziz(0H5v!qnZ8yiBx8q8;>=W<)R#d=yQ>#i1M zz(1#4(zugnoM3;XlONN`l643wl->qw67HOfP^IA6O^EkaK1A_854g`as`zln>Ehivd`v-fT{B_ ze1jOV&pWceD%D=;))!(I0C_^M4=F`lT55A}m^z)lDkWc?*A=?vgVh*z$V51b79=+c z+nxv&CGQ5?K63$YDrMAt+VJadKP@a>2Bo%lv%W;vUO)Ektzk;{VLzX-r!w#3iwch?A zJ1>EJFwN-mkQ3~7!tAU$r!){U`s@1;j; zf?vI!QWkAYf%k7VGMW1u`5MiUtq`6Dtr;%!O3LwX;|EfkVEu4_fomqBvU>A$gqd#tVjvIs%|Kp+}41Fy;Gpx!XWTt9*Xb@CaB4kVdI`i z@eDKyQjDgLOjVS(ir;Jq@5O2+q~GI)f?HPFus_AGsKoqER@5P#>=SH{aGsxHh-_^m zQ%ez1XrO`SfXPDU>fRE&nHq$(h5g!~8NvE}B1byh@XbCtGdvw#xvFxm_-39lIP#Lj zlD5|$15}8Zw?w7S_svq|o7d{4hm7G>wfy>zX2g}9Ii)m+f5tMZaJ5Our~CcA_X;L; zbyaN-U@jrO=SLvllq9BY-+%^V827iD!sP%BX5$@Vkpsuqa6|n4O+GD4_}|$V^+;jq0p*!!1#*G78^95>xj)(PdXSnJ#34|Nz9d?e}N znCAf9s$Zl-ckfpE<$=7xFtwvGh7y-$qu;t7gjjPjYXZ3c;LiA(p9MKKe7x!?!I1)@DAwmW(2L zQemnuE9ul)K}`$1C->rXKf2XS7g#1n^E647Ve9v6N^7%e~>BW zsNTZKjyDx@iY)#p(Yhjmlu4euk&S97j1gIMDDk@5fH?Y-Jm(|(TljN8a%}l2LR9=b z<$qrBw|f5%IObZkYBcJu@`G)&IfFMS(!^B-*HI3x&92Ztp`r-KVXLlS38wixaLtBR zzw^&>c%+?8X;O1?)IY%{`7l~Bw@CgpUN=EwSxH~>R z5P1aU$8BKlg%nMIRtJO~`}<96LB*tzk&u=plC=$V`@~~40sTqP($ZuKFND?<#D(DN zO=m$Bb`8s7N^6j&VALbtzYTM>g)3Rk!DXe3U*(9q^H6>v7;~z1n@-a)5pzGRD0icK zeHR4P7I~uBSALzMYQB(Nn4K=BW8D`=sVpg32%li>t_Atx>6KNO;f{RXn1(6;9{^22 zvcHQJ@;D6nt!9;dH6!2IQdKc47i0d1z^^VV?L!!O&z5R~S&cw%B<{=lL~U)r$P2dA z5X>40dYfRM*AZ6QL4}bew$vQV+5mcYVc$Gf+kV05Q?}Gr%(?-3oB{N;W5xXjCy(1w z=O9Z9LA?(!U#U3NbrUC#*iz3SD+QzeaL5;YlU4UooIGetg+f+&*jtJCymzwl-h`8T zZ7Fs~tZuM(6!1BIV&&bhaB`PDl?k!t!rpVdXJ5~i72^ja+AwBO^tjMRZ@_{{d46w2T-zlIMvY!=spJ4SHd+I)5l?T4p7R<}{ z7pw9bUjDGB0s*Tz@Np)TS1Fv8`Cq)`*;DL@SN)N%GRSM4!0P;cUg~VB)Oa-=`MN+n z`xsW~PqC5_v8f8<)q3QcjPSDWW2JsQD+^##*TJh3$oCV#`{)I$^;20n%&zK!R}YZy zJiPN;$f|u`R<5wCCgD{e^2OS~U9k(S+&5w6NxNz@TsboIXM=WDuXtAPV_5mzuDT3Y zsj#msvdg`V6?~guwcf7!1XqQyuOY7U8XU_izL=E-u&rItsy6m@gLM_pvzq^bm5c4G zY-rU9`$mDf;BVZl=wD~$efz2!T8+ZK1(42a`;?Xa{hTOk?5l2QwHW(;M05rBu)@EL zlWkyMPeZHS*moGuWm=HTD*tFs90?&?vlux8Hg+IbX_45+>42Qo_zElkkiv*+XR!xXc{BJIz;P81vj*58_}me;b}p>S zhJIC0T*Y5l3-mMcxxG~zt4gC^3kYX5ILMmdr+gd*d%G7_RYSkN0M4t!Mb-xY&Bpk8 zn`<^!)knWc@GYSKJ=O?sW#eJk+`F-=CHgG~Zy6`NWUcTVF18KXU3ajmBl>NJZh05R zv1WKM6Vt0) zap-p+v)NmmWG%5vuy_OP@cK|S9sOP+Hs2oCSyQZJ;j)-5)(@%{qF(}D3m*QIwZ&gK zxC8e1e5hK5eryM8MP^5_#`w1Jl&4{nAAqVY@E3^GeA^vhz3~ylWxNHu{1H^`g}*E~ zt;nnwtUF$1v^l@PHjgwQ)p7XC57L~P9bgUeID;M21{*ykQeA+*QV=c6%s|#6cQe$u z7htQGL8@Eu_dY&zYIcTEaswmnSPpx=9a24pzdGnF%ZxyV$#9x+lxv4zvrj-OKlu9? znmIK)%}CiG3_JUK*zTK=>I?j}17=xf1Tt3o4Rb^*?DuO(6$*c!qB5surx`9kHOSZ{ zu;af1m1c#%FECl=8G#I#&lzL464>+JIOW9fKN65RH9gIkd9x9wJRgHipC6|Z;NMqp zEbEK_M$I$jQ_gIGZQlr|lH=c8EauYc90TV8(rxuCZ2TcOmGQSCa?cN9=-f=QaSw%H z=P$#lT=+K|hPijR%IG;(t|RMU@1Mk}BKS8GgJ}hqhA@1#3z;|i9CrU3oGOcd(-D|g zx7&=MB@$)a)Cb$&;s{gK@oy^pa;Um4oH6tVdG;-Z6EHJOHNd~g*vn9AQ2@i}>$1#z z6VAY@Fx3qICIK%k|Ma(vr1!~@epL_-!R|2C82`S6U0KFFVKBW!iUUgE7@P%D4e)OQ z=1Mj27USuW5^VSZ9E5vesviE016+x^o@Yqik!TKXpgN7} z=oN4lhHEHQ1OG-rEytRh!WdQu6q+r+hvU!}rK;lJ2%Kdowd5-!>k2_R@v#t`hb2*} zGX4z%Sz7L?Zy8+wCQ!~?4JTq7l&XM#Lm*bh;SU&JKhSSy70$$QC{-5!2H~sJ18y+H zKB?D?7h`ZJZa}G0_%{Gt`S!ZVD0`hg=~r~Xv3MD!O5ooY$SP6i(^kgWQ*_y{2oAlY5Ky@` z+!4WeJE$a)c1A56kM%(+FaA|QQ%A?5M?Mn?;~TcwmxOyM-ezBUx8F6 z{Ieq{t>E;x48N;0g%D%T&4+{1?0`|};V*{ZvzBY(3x?nyu6+8tHaIIYU{o6TdyJm4 zjC#bsZmo<_DdF!bbV@hmHviJKD@G-UzZ1AAW#6m(tJmoml@$K=f~KV1&+#u{cVSc_ z`1=Vl`E)p8;a|dD$EXDGw*oGCwLTcbzl!~YQC{#j2P=8C+84#Yl68V8cla9*l)PH) zi{f9*riUmO_#23lf;;Yy;$P2}fhZIFb%RL3owg_OFKJsr6osK*1AG+RX?qf*+F=;= z0sY9RA4vF!njx(t4a7Ay+wx+|IC?OlvI0)BT06Nh%) zmCOkD8%AvfzoUpKL$}?@jB?!|Y8m+b0EaSk-;>NhH!DO<2fw*kD1E2hPR6>GAZj@H z4FN)aEq_j6xZ570dVt?2D9Ee9nrKG6qamsl_|)rDI2+6>o{kJeKzCest8pT_V(&ETlygB>cgEJ z_KggfTOm|-*ju2}C|3Gm^8>H|5mlLCKJZRblQM*9zPts-g?uP*f4{%=C zk27dK2T`*@Z-p+i)jAlmXLrZNP)5y{%t($>WoMqiJfLZRAa>ZMTglP48G%x z7{=InEktEOym30rTO5SuI3XO5!P7yAGK2!&M;eT>h6CK;g!q8L^HqqVi-4CygV`D^ z{^o>OjhYn+6F=pKD7uGs5jG9xXf9Sc9zJLMTppv=q1`W} z!QRFN#ycHO0H`Jy)d%ec=rHfGv9rUW5CiD07?l_8-qm4p=xXv;HfQHcaGJwm0)py` zQHB7ldu7*QTF5$L_@_p9yR*T%iJ*pH6m7%0Eu_JY1$hb9+er7Ev!NOz=+Pk68S4h> zFbn9k^+m&EA37VhK&Z(el>zI@=rFs}`PPSevOgUSg&9K60x9hc(!I9pF#n<}8zGOZ z(9y6MLoEa;T8VU9Nrye1u3-cpS*4SqOag{lfl|$pZjcspC0)k|8d<%AA?IVp&>K)H zVHD7n)ne|Xi;kd@wKx}oE-;ARj#9J@=OXP|%+qB3kfzJAkpC#7=zTC%1?RSr7W*by zvpmBN1;?N`hS5i1%J>fE2I(&HkxJ?Ia}pF;7|nqC6;RP+jLWCTY)q$(R6}=+W5C{cFJo$dq;h$N zaA!%6-JLFbq-MHv9Ri_)Z!@Y6L@FwSaAP%@qv=XV>ZQBZ5m0Jb9OLQ`sG{`%S3;Ay zh^~Gl#K`Wk|GZirVrU(MRq3MP?YgDO+)me(Ko{9Fc3-B^4;ft>po$j4+f>qImym^# zU=!J!_FnlliHxsZu&OS+mDgoHBwL$EHQCQLo==Ap46(hjDg(Mbu<0_($f^@5A?r}= zy0Ed&7-T2IDnlr^Eg(&Ha-6I%kz6utY0nj29M3R24OY=baI2}yECRVdku17%Z8)p? zRz}*H!D<1vy>RFGEy1^n>m*+D;Ev z8L_RQE~B*3l_b(Y7anh?<(d`6csnUr(LH2~^3`QF19@y95pvBUg(UuV=EL1?YL!nK4z zUFLMqXC+o3LGf7IEAy8x7K>j}Vl~rRGgYPGJMORSGnL&Tq@S;zGkg^y_24nyNi#Jv5D67#i zhTk^RG(oY6?5)o;06zy>hmT&mXue|Ocz3(b5c~*W(OOJw;~0w1f~=GlKD%!iH|3&8NhnRZCQo%Q*v($BwZFsAyclY^euc6IddJ=1GyuZ7 zFeB*N(nhfApJQ;I5w&Ovl0CF*9e04!t_}*QoS<;5ZBSu9!*dtdN^JqMiKKJ>3!KIZ zL7UGB{%sTF{gLtcH^S{5IOfiybIfdJMFnwRJtsKc4hWi($N>F5aFxcfy`*s}UxCwX zkT>>l5}arG*}FYuh<+Hjv^y}?O6Rx~oR$vKp>%)|ylC~+ILjb?1#nS&6no*Lam?=L zq;Lo_miI9dns4dl-^4I|EONO7g4k@*I3ES0xnamz+06%DvG9VXB{EL$h+H%o!<>0E zj;Y;@_+pT?x|xsgCRUyO(`Ss-t3y|6GlZ=tjq@(B*^>i_I}6yrixyqY^NiK=Ko@O? zu+kdGgmyM6)o4x0A#_Jcsi`wDWX-nhC=;9z0Xn;YxwIL3wEVkMg z7_qNJFXsUGYDF67elUKUx()^os%^%@R#sZUEsWU*q8Cj>uUmGV<6)x(1KD91JRB&G z8|@P7EMQs^gZA3+mC_7ey-DZHGg72Xtpbm2zQVAf-n7gb-DTLG8NO&Mc0KpgIWj_q zS?$a11E)Q?YDu4AaxJp#KQV5PcEGO!*fpAT&MjcTb;|;(zY!eA>jJ8pmA9<1D=t}= zC1&pz#lZa$0r)ZGiV4*^4w5Ods?ewAt{8wBM%UGq<^_D3s+RQ2fC&~@+0zW&cOzI? zz5T~w`u*@tuJwz0UZ zW)B&?*F`X@jazF-?|h6bUVs#^m~K03EB7|W?};JI@Bp?-Y99X~TZL4xKTfuTmE|=g zmht;j!tfTTwUac@=g9I>f*{Cuwy-LmX9T|z#7wV1tA_6J6j^ObP)xUzWflC5n<0FA z5TmY`b;8m;9wUplr2xyD={B>dthP@X#Aik^=TDH;oOI6z$k1ABYD3KhYVk1WXIWGE z_b`h8qM;ZKLaawd__&-7@%n5vzpS#hv86L&8lN9rI&<#Y!?$7lvpUHr?@{p#<98E` zdjL0{5`>;+I3SeOV4&P(VDJ&if2U>Pk#s#yf0-*Et-xXJ**tT=eAD2;4AUNNG-Phg%r45xl1 zLXnG^K+M-ETc8U}J~haqB1ak1FGe#Ohf^a6MQ25s06KkfnL<9W*@xt?)iaJkeM>m= z_<~ZO++ak!%LY(YEzg=Nuo<2}4u$=~us$7}Q8$$8MmV}noDU2JWU1-|pYury*L}jU z{sG~+!!wL}?f@j>V@3cqE=5HT_zd)sK|Zq>*pC7TB&kSoc8kA~q~$Ww2SN z`YEs}MRtl251urtr9|Ui1uMlZ5LF@*1HL(!sNGczOH=pUBm}GC` zNCx`p;Ek#P(&q#iBSgkYzl4kjUs3^secsZ-horB45+nUf1n`^is1*V2gnvDr8#P!U znZW_Wn4cY(*({w#5;(&ihWbqrE@==Noz{@%pW}AF`1x-NTjNG)N+uVwf4{S5-iXfi zOJ#N>SzP8WL;d#-2uB^jC^{Q~Zsz=LcG>6PqfWi##s~iWQ>#R1PePR^LKB`GlaCR70z%!m$FJ5J9);m ziDMM&!08DN?Itju^=h1MD6xP9zh(%l+8XL&xPO9F^u}jApWllSJAZrln3KR{A*@d0JH*y1`;3_(Um8(jP4a-RTsU= zfd8bXn6`Yh33`VKuzd{q z0}?2tjEAC-@}rGMb(B6FfA(vbB)Z6o6Vd4IwWJE&V9ei<)bSR%khSB;Q5_9}pMazU z%Ee?&iDc6qWkpqc$(aA9Q5~Jz2YKTUrjOXB8e2z&9_8 zS;+m{lu;bzjl!N)1j|dv(38M2vPNM!TjLn@Pa#!2=_kk@m^6x`e$eyU6PFg#tx2Gc z>`TkZXEme#!$ws!J0J3^6Gm}{W58oAMYw!CT}=WlWG`Axp$8cCKTkl4c%vE^@sIJN zH%cCeJ5vdm_oXXMppWcPiz)v#M*Sf9moW>CUo}+B+Hc?>4BSGhyIB#^{amMI+7`BQ_}^GIgXCbVplAeHr&}cOpf+S&xx$ z{V0z515R)n0_Qd|ZKN`~{VXGEG~@miMpDGeUodpbeWb=z+Q&^G0jLFZy7co z_885%K1Q0c1ki7j)sLi+#T6?kvEa0qBo#8mhMCK=m zzKtvGUhi=btC0`B)DuAk7C(aa~SZ) zOmZbveTxM3LHtvt8_pQ@k>_JnX+~Im1{VQg)moDo@L$M5>iR>vSOwPGLWgZef0X$L zrpzU<-i(VEESoWm_x)NJNMTpeX&AAZ?o z-KvpPx(BQP8V)E=lM`Hj&qI8MMbnSbzIj1nQrjDWyb&WWqx;+v(00N}VpYQH+j%Gv z)@%<(`+KvJ>fW19OQ1haR&EK9#{)EJOMtx@56@XMo!nsWW;s&dr_kjmfHh=mtpUmy zgeAjB%g$k87fYs%i=qCzRt8ew*U{A_l1~;(vj(WD6-mCa^sMVR@Cz%psgsd@$S@aD z;!n}_CZdvImPJ5AfMlD4^z7LK44h@fG)Q8gZ&{p_6!`~a{YcbLx1Cj>ZG(~10i0skJhAjFE-XvVYyoC806osp^<7YAoC}LUb0uM z1gaE+9z#g;P7fHVV~+JQjxqk@Mg~&vWn{j@8p*!26sQGwEV6Vjv3Z|?ju)2eCk*jl zw>6Q9UrweaR!){>El@w`*yflM_8^^5)s~qZjPkp>*dNNtFahp25_7VAPrpk z)+oPcS}p}%Fuo5Q;7Y1~4VjvlkF3*bFl>a4+nGrVpIvQ?n(4x74Y|hf{@XB5QupiR zQpqsJVj#z3pz*RWY2q8z8KHA4VYPbiXLSE2|XmDCtYUaJ3|je2X=*t=w8zt|J)R2aWI|#jk)|ORR+M zU)BR<`~ny54y2XO_sdl@S6Hqs8QKSp^djXyhAu0yI=Ztg2r3wg7F|g*&wN~_cP0zV zwPHN73mWM~063Y>pIDIYIxB)I$6!Sd($9}BCOZ+ml@W7ALUITxFKC$V(27$*_xmMK+%sh^-bT5QicD! zjj&+7_A;vfKH8gLa4WjPBpS#*wJ2yDObjG_eXBQv6g|&uB&=9#F@yT=qrC|T_oFLL zqMYnctAa*@#4ys@GynBkn?Ci=A2voEge2?z|Yq+6Xt7Fn}(LBjxIJZbV7b9Or5z=IAxWS=dj zD#FSw8_R(HMHd%>!~5t4lTgV*igiJQ;o&Pwm$QnqJZ3z9t)Ys$e9W4hgo=K$ie&QX_O>*r4=~KP z2rD*XEFUr>72#qBS$;AVbf;Py)DsrIwg@ZciGXq+2YVAR&H{N>GL3Y%TO8C46qeZt zEBc&bApiIeCxXTU=+tDo>E5t9s52ydYa_6@h;jU>+6Ds0%jkT`V3_PL%Y!-s!WgF)b7ae|9^qR`vDuAT&yU(l(^HPXFmjZmKV z5O6gY!A1K)hVBt-ifM$EX>D}5$wi`Mr4|XLybA!w!w4@9X5{|3j}O7+fuL_l4ozh7 zsTK)&9#eih0|+mcO2lo!%Zh0Pm@m?GLdPO99BGx1!z#tM#*6Tx&jm*9CtJD_WTv&y z<>}$0d&Dv!ZG_UBX(PZmoiY3C(P;@a4+6cahf4RibwboCTHOsM#8};p*uvM9GZ1cm zBtfMf1$2XBEEFpLUC}u^5@Jk$kMa7&4xWUZ^P_YF5j{HNWH`!7p&WinF0KJV##M~g z{U>K3@O%dJ#n82$?h{Led~YhamlX*z)?%z4wXK?o&~s*w1gbthy5Z@T3b`LtZdVEs zWVD}$YBzf%BmjLG5|--Yr^D6O3YnHDwk<(~8GABL515^sAas|Q1nXk@G)b^>oV7yQ zAf-0mMwl@;9Hp7})pH~a%?Ln3A9QY`!`&7OQIkkz7E+gf)i{$e`ojUq2u2@=j3s*I zNHDaI)k686E3)S~=vR#elMq_)(!v_y=+TgIT}&@c0<>>qwUF0#C3ZLg{jzZ@_qw^*5`lQ;Fyojv+5_SpoSN_<2~MYf?|Iu=O=Wp`UU5}u}?y8_b2 z8%Wxt@&IdZn`EhwVYo#-y)Q(+cHGX8{BT8$@N~k3%OP<-X!B(Pf)6aPQi#faR81F> z(XSmpWI!IZDklNz?yvMf>QbT{xc~&N-M~VjWT({93`hF4qt_KY_HCjs!D;%!N6H|1 zIf%nS*?{Qp_a8mWDk0-It2+8nk$&~qjnVjv?rsF8$|h$oT?gr7)l^XBTW`Z>cbs*= zW@D`nq6&VhXjek|)uZ=aF!rvck)U+uk@tPS7KZeJJltrE5!;KufA!vr7HyhtVUWin zB^1^{qhCJuU??6k+?7C7nYHk|2j0(F2g7y;=}=&(k;5x~e);B;cA98O5S4qSer{x@ zUqAZXM`D&`$p}7`x%-@P+si*J>xI!Z$XLL^$h99{zi^!OK<@Jtk9CqG{Q|NN1My?I z2sxDn$KLVZ-&YKS$-1Z%jbTRq8T##pEv*KkQqNV++cNYkNZ&^|>|r&HU~}3L5B%H- zR-?yY}Ez;RJr78Hj$0?>5VWGjJOrdkxtONChHd6HZmb3rx; ze>y77;TIHvzhVX&2rMG>`X~=b^I%FNEy69aqLZC^zE045HtM~aPEu1uq8ppFnENOK$Q8Q zs_?5xl6CVENc-a~hhAtT`g)v_5L0;>j7K#XdG!j$5IWvcAgcaUMVP0%kz_rl3lcYM z=ggbL;QMA`g3JfNcy|$uJ)p(-LgYxfK#l`~mBZycBwMe9+?@v@URX5vI_pJ{c^VjB z*$Bf|SMY_tTylXZ*($a0y^Dcl>m`sq)CBORGWI^;LXf#}H(zmIKRC?lWDC!|B?D2R z%L-vrGLo+6#ifI}2Hb_lV6V*%1ewaOV7svtOm=3)x#E2W*+7O?9~8lh$|PS;jKP3m zh0$&lL+_VOogYF4@4RF%0BD5bR=OX)xe7242DFMH4jxJkqU3WUCD_(XBzNC};SWMw&Scni#DReG4+eUR)!cP3^GLPN zS6JI^EKVyoh?38Zlw8LOkmQ{`Y&2x`U`~#2BN=tqB9S%`dRhe(@8e>jt5-V8D}2c?(;Z3{8iZ5P59-)I5FWCNrNkw@1DGRAtK zx`Cwg*)nK|YBIdjfXzP=as{O)0R4h2d$bT~ChH+#h_upMVkAqz$IVBshU*Vo%e7ixmF>4?ie5n<>JbU=fe+myPs=V;5Lkg}-MA#(3@S7t$7%@P{1z_zQ;Ift@5ItFjfA%fQQnj4Y`e14_)K*_nnC(+le-q?QBzcIm z5*wo>#hal?Npc@Sr;U^^L2p#c#DIG|q3CY1Bp7X9QRG*(+{)Hy%F$A!X?Z2pO~EjX z_(_vKM3i*cTlru;Skp+-`%1dPks@7m+fc17;JRdjkuig^B^qn1^s9v}sX@KZzq}vD zK1g!dROhH%2$+(NWcZVGjUyq8&R+wyukqIRw1lI-7-z(ua-UWaF|l~Hmb(rNj+I12 zlzF!NrGF;2uXM1;h#PETD`dpwe~!%x(1^iM@neG0C5s_A8hM#yn9Fd7+`%ZLfLX~tDCq%Q|LR;Ml zOt)qRKsc|IxdE*a+}D zL240^R%C{s-~_C1mXM_V&2%k^sdVE2Et?Z-S;`Qerd6{9G-vS>4pf2X(j>vPB2%wh z1jTxFbYTO@{3pnw@x%)0b^_XH#@b^EQLkYNsHkSf-vXw0ku3MFHS4xjG`?8c6|uLspvv zR+5<=;j9ir?V!|zsfV!zv}JR#ItP3YAnBf@@!}`CFXrNeims#x17s!1JzHI!55I#1&*Y$*6RV(JU+xYAq&Mow|lG ztXg5b(k3we!kgrMs@ALD=q~e>DOKD_F?Nw@$z;=wL9;IyY2SCj6>$t_7zh|_P>X}{ z!Q3SI)3sm!)T)=h9G+Oslhk7-$kk+4&}|1Z>k`rm&q2s~8)twPF;dwoa2`LKRG?eo z4)e}@(>EUvjH{DUBQ-gXPD>^fC3^^FMHp#!B5Zw>HI(%lCalH5dE_ji4z-lkhi-mo z)*qJVdq&mtC6&1|T~0D+Ad4|!Ss$deO#>I@CDu^cYmk9rus(d8upOQAwV8P$KqtgJ z+A+Ra9v4!chtZWJl}pB{uxu01dgD##nzocRRP-65vl6WLFA_GTmaOuCb$6m|nfd0t zX4xG{k)BCco0Ljd63T25ILlm&@HPGZIA^HnH^ACDu%5TIus0oZH5oW>_l?gsC+N_K za_*#37t^&T6`*SlWn~y=4<(3Qw3aiJ_e%GG( z#&)WaP9r7z3RxGD9-vzcWxHV37hgiz^gD(agCi^yRQ(1oaKe=&w|E6WL#t!V#vQC8}8C*#1eooeq96HGkBAGoJW>q7g-TW8E zkRz1_zT)5MjkM7;Nb?wr*Q>Ts{{%GH>UE4OTTeWK0qIt7s zjjEQ+lOc(#MygpQE7zf#>^_neW0XCEpjJ7mjIaKc64eZ|6?=a>@jvC2WVt%zkp*i& z))8f0cf=YY(#~JW73v418LS7>(Ra2I|6|@x=F>qVV{af^0J1_d65ggC{z-@@WJ{9; zmP0>YK1ckod0LE2)j_4p17ycRRtv)18J8?w#SwBPDF?&V|2=bx_#gCHAlG!rp{s>s zUI`d$4FT_hbDsLSg_|{la+EL=$$R#s4aEPb??4yOp@ObEj=jqe`>8wftatXJE1vw! zSKY}4R4IzVWN%aXpRe6~+%{vx|F9oQSExf9-83Bg6k(r>5yGFo1QNI7yeAT8XXWK+lpgLAyzL!WM#^B|GNBzKidp~ z0txbA%z@wUKXkhIpZn|R+Vp55yNY9H5!OBpl1AQO(aqm<831{7HAYzZ%mHJ>|KvYP z7LVvrLiQQP{E`8de%i_l{PXN{DJ8b^O~y74bj)*<_`< z`01*n*mwBqya}j;7&bF-DW&U%Vwd67w^Rhy>DP2ilT8xo zkf-tHY2ttX`^Yq1*3eA@u|PMxs!Mo1=Y3hCt10pzYwIN$!Ub>w$W>jUQL=3yR-MuH zLW1n`hva}}yy3?t3s=BR>3q6i4cRpi8-%XzxnP#qb(I_)G5tCsa{Rcda0%R>E?1vY zvH%d<0ItHa5o#}JlmbQiX^^q!B1O0bUO-o_kDrX)G3*Ao`h#$LL6a0&`Yo5CYQAs} ze2}hDAC)c#gatFU-l74Q*druES46LMaS8gj7A}HI>H78Y)75~mTG;B97eV*sGN3@O z03^IaxC#2nI-zeVU3Ul@3aw&_5OynH$>7(^FTvW0t^zG&RXR1(O^2`_pw(bP?@b3K z=n3i6BEfaSO>hFpvvrC^$aX;3ZDe)K0AJ!B2~eWXO3=5BbrslwPSpvk$!;K6C0^J(DRJxo1wh32VbHp(5G94=Pr~v(9mw_M2!ZE%4bhQBN zF02YpPawWf0#y%*1%%7w&=^fV@bz7P^`E zRS#CJBpiQ+t~;(nHQi$_14q*Nbc=_{cH-A)RQ1>m$o8bes^ntBbcebO+(oDAh9S|&**aXD<+F}fUgIjss};&7j$ZJ*>t}Mm%&=PM*XtL zI463AFs9z4VcGR`S;?t%x3~;U0eK(vSLvFgS6NK;E+Jw0u5{(e<r5xflec+q&tLO!ywgYg7e?V(2-0J$X5!N!KHM5f`K^M zd-Pg@sLmSk?0LGfWGd*saT!on)721Q6&WW4uU`Sx*OY|kSJC;B$)$VPWndeShawcY zbmhS77NDv_fPOHYn#{pB*8&VIrdtk5mF`AomzNVy%_BsgL+4B8z?&-s2EL)oBFUxu z#MzaP;q)zMNK?AVa*{dv#^UT)Aft!QN76*M+SyeHP6eeWOwT1NOXklvc9pQ;LeN)` zpq~tzIJ$bmshR}pUz0T?bNh|`4hHU}iw@FM(4FPznuew(5~{yW)}2iB8(R$qRJsb# z`siMCbo~gXP8ebBVKRghi{Upu6${LY(5XZkU9qF<43>JIgkXI!U29@@-rVO{;A+s9 z5>?X0COf#S*H9{^7~%REbjuSv`{tTsfdJh=m?%6%h9ewYp$w&a5wIUjSDM(dH&+n~ zY!3QDP?ypD+rd>5N^Q}QZ9BTc#16l?Tu|U%2~;wbu1;}oRY6jZ+zHt?q03L~;G6SC z0!p<6HDvj8*v7fl5J-h)BWRyPmy_6mH!IMMhS`>TZ=)Ir2^r5Kbe}?kvDb* z2;2(^H5ss)4!b(FHh`#M1n@z*j=iy?IAFp$2_hX*XcD~W)Y^%m_8SncMXnQX>^B^6 z4UT=8A;#i^ivTtYLdpOn&XWV>TR5!Bp{HI1^yksFB$DKQ zW5WQzhSiWVUds=Rwz3aOyV0(>*oK~)Vpy-P|^znxTv-&{@8Z@V>+v;=ZD%7dEy zoKrQCQ+R&D`U!N^i5z%y1q{D)`XOtPWGIvecy*dnst$5$OlaRi7U}+9TzPXoM&G#C zA#D|8uaXIC&s3aI9|EWG1owV2`2MFy{Kg-X$-h-8uYB zxpD!^&q{YZH9<{583^zXk>FP1e{r7UH$TSI+wNCLt-^@4G0A}CXQw-ynu8{5RYLqJ zaXLQ!g89lNPJvC$ydB?(Kg5Bf&&zNywZ%-^G|+olhLrlyGVU_ZDTed`7qG^hJk7bVDP4ztlSs?R@^by ziPQx!S*j53|G`C4dUI~08}nh%0F2yOYZM^*&G8wIqwaudFaiIKTe$uheSHiG0 zgNDMo2Ezd8`0%{h&Z3?=iTUZ?g$G~b+g1(v=Ea!kZop+MyiW_mz`->zcx#h^0L1ej zTA1z}>Z6T(?3>k*{?{%4#87B^?M~4D3lA~r4Yz5F?)bVJ#*P-l@M~HO_0NuvF4#W9 z8T7d(^1g=){oTaV!-1_jVgH#td`;&1qyumIXayKl!C+&X(f&DH{O(PM&362Jp@n?u z?nM7@vaPVToxneH4GYDMw6sl+xc=RW7u>$z<5wQP?efzO+hgH7MvXY#N8b1C&PU5p65RNY3U#stsdk7HQih!J=DE+)$+eTdiIvH z57~O6BW8g1VO^7w{^2E<)`Q@G)(`{D&9M#sb>pjlw}9Qcc5qqQ$y1VR{(9xI-N!gw z`syAh^3p$^AR5tb8Szg%VZ7506Kwn*7d`iT8+fh>g3mS8{H-@!^u`4nI#_z?9S^E$ z^e;vtT1korIH}oa&UeV{PTTCndq1m=f$yp=a5@w)hxJxIe|UyNrJK(2v4e^J%_nL8}xkj5z2KTsw?)jw$%$N6qSuJ830NP(U$bcxF^o_7F?)0yk9s%0@tnmXB)mYbj zjjcJCelr06O9#MiS-XhPwCnTGpx86oN&GO9B!YJ`anYhXX< zHyi$1A4RHb72$bF=_efvXWv-Hf!M7>zNcl$q)t0+In1Hy3!q=rm}&wD^fd41xW9fiE58O#s(l99N1 zo?QDgkU>57BbaJIBg}JUsmbtVF9a#0OEe!WWL^no=hBm*nE7~Ip7;SaGN;oQ_rOdW z3t+DPCUfCy2>_&|CZWy|GP?$2*`2IpE-qRl%kjcwP*3?DX4?p7>Ez zk9aXdNZp*FJiHK@j8py-mElECI~mklR>4H$fi{?NG^@!>_^1PXV$cQvg%>hQU59v;pG~7WY8fxHWG3tw?J7RG9P#Sp5*zkATp{? z4#70L)HaxT)W6C+xC46l=n$t1$-6R=udoaVA$E8GRY0o0xK^j6-}?BIS$$>9t^XBUg-}@gyc@4K=w2{nUu=heb4ONqwl~W{kk?RpWB}d>^=Hmia}pFQpYy6(G++k zcs$S__K>Xt<9Kov5_Pv|g9#1<3L;1`O9MkDzy+}5iuO>e$TkDWdXuO-aRt~9=VuQh z747j=^LHFOPHGSD3E3au*m6tOSpw!GCB81CuGQK)=C~qs?9(1@l`R#+_;DW+b@z&~ zwo=RML#kV)E{kz?Lqm?O+QX5ub%L=c*-6yRECt)a?5t*_$X(g~b!>47$T^8eMF>)-`9fFEvj=?C_on+lP{rrTgd|5S7q~u+>YF<;E zov)6-F-v#I5|Wo;5T8fF?!Uomq$bN(6-J8Rzs%<=@8g7P0mlU0;Un25gVwrmKa$JKeN=FcyyZ5?1>6L1XC9iEl#B8FWLAz^nhj6YbDqXw$_FnSGlG*%Yo zW&3@as;d6N+Ms-2jkwWUceq(H=O_q!U4(>PrP@$EWj?L28KdvenqoD(F)k4o!i^5P z!&#D*WCVA8NYd`%FleMUTg`9A=)Si^TPw`~+-Rgb93#5_;;=YXxvX|)fyB|rs8IZBn z7lq|nMOPd*4(Sa~$>q!7eJFrL-tjVM!>~Z(y<}x^<7d6$2Duu7*B^l-^G=aKg(W&1 zC98}ZOZA4+<(h+CJJONRJCiOC)@T*TOX0?3y1r$zA6;(T7^F9B zCY3`Ja*eb|?43^+v_^t!>C)gvC%s{uR5=;9lRJ>$yD?pfHIfHJyS{6TA;4{5QM(_P-kAfPr{EcxK|GN=6e%Be!mZ}e8&G9C!HF(@j{?A_+o*RcOmP^OjK(Kjb zb$L;KwommJHx7?3VpUr1IxxALkn=m8;Si}-;nk}qq_w6W_nP1E=AtNMXM-glT%MIb9Up<}Ynu#HqV;Oh50q_g^ue#LLzwLAv~N$cWe#7Iq+uPU6nbp-;Q z;MJNII{N4gQ)Dtlf>n42i*(j{cQ|FnPjDa*@g7U-4K4R&RYy~=TD3e1cJ~o+3TJK=KuRVntD)2Rf<%-x*R%56aGB<&$>orMht#Ff5 z^7Fa?L|M)ofOw@^n-WC|0{vk6k%k?aH3q*_lYlB|qAzK!EfP3oyGL8_L)^mNKby3Y zXo^$n3xe;?gq^k|ec^Md4&c<*Vx+eYaC3@ZW``d$y37DTmv2StvRrKe=XXrtL3j0q z7o~cOQaj6$<~ol5j!P``A#OGa0Hrz6RHRXD1n<_Ogq}zAg}Y_)VT|7UKIyJK`R_T? zLLX!6Oao|9gDFNNrv&UTCHVYaU$|JNG7z=DDrv7xTz?ZDgFeKmCISrkt5b+(wGI4# z_Q8)?`od8%^~5LVFEvSjEfUXv+kWUn`Nd2Cl$WFi@q+vq%&zPQEMFud z1P#;{nuWw!7?ZCqX`h?)S?eJGy^~6!k8wq&1Jo9oRAoNQGbjsT=u=&xNTx=36!rV$ zCYeaDt;v7)V;1-jb85^7$Zs>D(qfosOgRG4Bf7#5GK~kLLtTp5NWb0wx8us_Lu|a# zgs`sAY>LWZrlGY6MOW$yZ_2a}icVK2Ew>2&{eOTTVnL$`A+AQvq$wY!`Zf>2=qO#` z5t-f~(PAIca~J#bLx}TxO$g9JCQw%lbG==H zpftU%uu!7zIP@iHy9@tc;K~R@Y@TICsBYsftVUp}-?|zJOrL8C=S#E{hL&6U&XxZk zhC>jMc4fB-p=mX5tEynCq0VkhYtR|9{+xL4=t1UetWhx{|X% zAI$XoghT|W-)Rc?P#Qxh2p#h$efN|9CnD`2geb%EXq$tMYPP~Iur0mP&Xpi_jIMy= zWJ$po{K$uZ_cFaq$CEtPff`C8yAW0RlAVVLM$kJ#V~Fh|!yEj0laRpo4!ZpHz)yx*ZYKYyQ{oJH z%T#O7s#Bj~<7X~fv+cmOk8uRbLwsC~;FVd}E zpGvobYsx?Bl)lV)cfb0>^0>j^XXApI<3j6|k@*b-!8vpWJVckC;kPBh??+_)Sbr|v zVQwyeJ?X%kKCCtpR3^?^Irjh9)+PzTuoa;*M0O^t06(({e}5sX9HEx(bT^pwPCexI zKchy8<+E^xWBs!k18qFo_X-f#4 z79&$f8YH{Jg+>Um&j(@I;^oGQe9W=-QfpU&Wj~kRfU9UeLeBz%;Hecb~_o6EpX%*Rj+-ro`J0MlG$~ccAV?*@1Vj977FWmv>5mX1B znBoM&$I?}dR7>`rn~hM;Y?TNrB1ZY<&p&^}+*E|nCA5d&9R0cjPai_zOXwO#sv!H; z)kcU7evt^OQO@yCA2KB=VYKa)?toAAn}IvqG#I*zu6v|HGR@^iq}?8sCtPccx6wb_ zx6wWX(>t{XoTS@M*m>$nF#J4O2qR~cmATyrv2UX^C^g1K{<$MYdk|2M)E`=>|5LY6 zCoCu7@JD1V3HZoXx!;KCe@la6BRsYKTszYeR9Dv@@YX-|4Ru-)5dTTGJOP!g*9Aw6 z|52I>BV6a7`EexzYyV{Z0hRt4F9zMkgv1qO1ri~{1xJkeOcoT#cb9+S=)sNz*T*%8 zH}3yPUeLMW3`Ol^{seq#5Z2=VL_f@_K~OvwTk5Io@@IDj}OjbL;) zU2y^pbRW6ph%JJ0tc*$JlVyVw!S*yQA|~(u_YiRWlMonxM^~Fb2VK2ujyNPD1^hBa zIwhHBAl$C3Met42|D%QBO-u;_W2KF*BY`j-X1nHytL3PXsQ_}0(FnNh;aUU_{(q7( z-u5Ll-VEf?cmimmJI*~v`ArH`N)(Wz7h(5zdPHQF|Dy=rY}erEEV>m)G(h*Ldyd$& zPl_yghWe!#K;Yd%kKpr$ztp;u6n9>LxJ z-cYm&&qZ*otf0$EsFDtQxag$$WPneaTp1pD5Pa{^Btp{stz~G_n(+7l(AOmt9;W-s zMMrEkBtw-fy~7ejW+nXYt4Z)-?e821o27)u%CB_2@q|!8ha+8d#N9HWS`t4boJjzl zTbJP0f9D0(+;qkxx|dT~2wr4AKUZz&a@atorhW`>s0ekR9h{ zy4#3rWT=orlVCbwxYuiK;$wn8tBo{I+yF?KxBmqXeURM-shf~&zATA$udXi6`r+kU zPuVRP(TrUs0oKZpEkS+|qaU>iUiD{PfhH<1A@B5qu3hp;*2)l!7)Z(|U&0T598}&0 zX2fMO_+)6Id#a&W7kz>&{mBHN=}oAcx$ur3*1*WwNp*#c^k1K@_&5N{WUiJVUxHT9 zk0BJ#s!_ag|DV-3b3ntKiKl(i2ZQ$yCWTz1F>}J2Vn9YbD8b+mU5*462x#00p?311At_yHuJ#{e^LDuZJ-iP0gSPNTa;I6g(Au$CaA2Pk8$NO-FpUknxt zbqIH|z`QLNiV;6b;G?Ug8w_M^H41+0OcaDNQweX6Dgck|Q5~Q%!I2#-5ObmgYsrSg zbcKMdv`)bj$dy6%nj_pyeivM-&@(u|1oNga43p79hvih|AP;u{GJm&DVa}ToWI{3! z+U{KkE_-`*fyM?usSIJnqY|i8D&09k@;f>O->LvIEeUMX?u>v<75cUfv%yJSAOyC=&+V(4P6~5o26G^>VU?q8WVeKRhj5ZGIhFnhgOAEhjVYC$>e?d@Q4vq{W ztexZor^O)5!b*Rd@r0)sRR^aDMnbk7>vd>$T(2U2+u+gU#jOb07z@hI07eIq2nOIv;^bR~pbNU=VX5 z!rF{FK33F2VlB)@LT2!CK3z3IE?rVU^G${z69{b20h_*INL|s)1@{CmThT=a^{XTM zp#d75MId(_(WZcl#(c=$72skgcu|66Sf*QinCu!M`Z53#lb67DE_m$HA~0ZiH4lk_ z*hTzE=hv+Xx{F;m)i?Q2lKS@ zLE|(6FCUU&jb8a=9|+TPVUNhHgtu=QA+)kU(;C2Vw8p3U^DCQr=ttOb*#J3{=rp8)>Kl8EUj8PF z59^anR~FW~pbkr3g4~I83v|pT%pFgclM>{=v6txOX1W4> zG&&bpqkWje-ve~U(P^oGFZ{+Xp_gOnvh>Lz`%1X}8Rlq2pgWc>sz%MNzgR5-{ciNK z6`iWjGO~NXmd%Ygj%cW(gvo%0EUk1+d(W_`gc^%{YF6H|RlSgN8G6Z#kZJlflN|y! zIs!N%GZO4pNHB7IFxXUGnitU2{IzU_8VY@C_OiA(Bp-`j#A>o^UE&e4<;XSwaEv0{ z{ZyjFI||k8Hg@XOk}wR~6226YmFUt2^3lka3vWDg0-pN~^3Qmg*3QRZzHbc-To}Im zNLHsy6A_4D71_BT(@dY_pr4Y@lJ^N-)?9zuZWMHbW$UHadqj zdJ^_N#sE;26E{e9D_AUyU(Tkh)+3iJT*J08P~)Z%eD+`gXz&}Mt^_>Zu<*+wxg1EQty`!R87AuxSx8O@|t~Vd(Y_&gf16{23oW z_K+-U9BjHvBN)+47SJJ&t~t6{H&Mn5M+Dl16D)6$Lj|MD3BneW73rYS6-PIkhBBHE z1b@c~)|Jbkg3(tQG0e|oD~ymF-pYfF8wL#8A;bwnIdm#G{hToDyJYKh$Rmq1z?*d+ zV^kmzzMT~ems4ef(~}Mm^AuSumRt?lQ^NN-5aTGJaC$Z?2(F^39pL0yP6DwvlVM$Q zJ!D4#t_sA+2SZ}3K~~VvMbW(qj3!qj6nie+iln+hz7*hWj{(L;0^(C*te`YbkONNp z5{x~Zt|+N$x}g9^i||EsdL$B;^MWk>3c%?Y3&-qC7f33Xt{KAB!WZ)hi|^)TU7ub< zO`P;6AUlUnO-iFHig1o_bP~x*NVHv#;2;<7sXl~ees+a5t#?fME@yY>`MA~gbY(UjR1(Ua{5T5;jY;7`K zAYTk})uBaCLgmfBW1~igfi50ijSnP1yM!!~Of}tLkh47m7T?`rNr>ZDGJ%@p0^soZ zupol8caWhoiCnrSAV)KR#WX_ZaTgcype0veJmT-sneci1VUOiR4Ti>Kd@yeNk)>UW+6db2 zPNyZ2PZqAB9F2h$g#qfHlMlG|4S!?UIR0cxrHiljfA-P?*POm^h5)!5(PbskO!kOi zz5uGYKv12w!EQ$!fBN~C-*E3UuYaO;$>d8SIwVh@|NTcUJ#33H!c8}mZdqa-AU}k2 zbP`lFz^Y`G&944CE=z3^HITIO*+pZ78*e*VPhy31i-7J6O!3-?T>qSRe}^2g-o&z` zs9wB4xbC}W;>9QkTS41(BKzvq!&Yw^yO!u8|^*w~HHj#B84>clP za3Y{ci(=vgy3&LyLBFfJ&uluhBv3$ifuMdfp149VJ|#}JCLtePws84vr}HJC(#^rT zmUvU#1Nv-O zN8h1|#{}e8)0HGJ9HpD;_H(KPb+Ppo(S;hp?n5*&8Oe^K%SxaXkxb z3;Oc}_KxRZq6m_0OQ$AKNB5!Y554>e_~Y4gH~43qjdrvUOMEaO*?2l%0?X)ba{pQU zGyJqI;I0yuSVU0XN|v2KA>D!QKegkKG;n9#ffALmEQhQxfo!@>-G4ly<;7>5C?pA1wGn{A{L|B?sUZ?)semK4n)1cb0U?6L~mkNTEc?)RFqg?sf~JRKc;UDgnQB8AFWK3C3jkYjhmF zblZVbSMI&|@I9LU-{{bdja5qs`Uhi($+j(F z-s&AjF5Y|ny_tXRz=tO{&Fow{i3SOcWLYDGLB7~M$Z#eWz4E}m%uWa~!$uj?SMRs% zd;rJxuNPMisF}eC2IXX>Bdnmi*gc36KZ`Y?g$Vq&K!~|E$2D)$v0Lv(a9kRBZ`-)0 z88jI9o2+ew0Nv3pLX`eV{Jfzu`Fh%ibYn+#ujXG7PLwrQ5|# zhzf+p3Ts1xf3IfO~&{eIkkZuE4A*vW03#=U}9^fnl zu_f+vE!l7LOSYw-_Y5xWr1BQi1=f=b@<^Jy5aoFJ(|xZ-@c~O9h;wn8A$P~+cg<{z z!}fJetL%=TLje#e@1m;_Zo`Sr{A7Pp~(Dh%&@QTE>o>Kd~MR+utpndP@&UAn8NyLqs*d{4`IiQoO;C z1pL5B;+)iN*1lyu`E*VdXSF7ladDa(5&0~z{1hRB94Q{*dH_)%PD$2m`3u&eKh{=r zP-*XQvAHV|QJ(YvUii#p6tA!@fT$B^n99s}#QOE?(sD*+b`=w=6qh2>THO10K2Jg!g6*{CYvMVp~J`bC@8~tyB zGQB3uS-yGyfn6JB5B;Nd294q^9)J(u4Y4WHgmpKlPrq&dnHg);y;EP+}K-!bqzg)8h)=%MXTPi|L2qiImfn zwZ>W`JST2P4-0-0pW&ZbTqBlbRZQYELoF5F6C0z4D?de=IiW1Lhbn4HNh~!`!&DCn zj)&kO+U4I%u_T5C_+C3_l{9sgVIunpFN)*A1DE@^5>1a_LH;~Ci8>O8#Eo*f@T8c- z1s=xyJ07EhS)e0VWK%}sL4!OeJSuJl4`==jmwo{(*w*cNRgtp95I?4SRcHtvBAx#+ zhmP-A#19lxLqd7k0KO^0tD^lLcHq+gRK>?E>L<%-DoCXLSGv!~3Ga$yz{ALYCe?Np z`AgL`1tiiRjm!0)bm3t!n-e>n_$Qo(hOyw^YG6=5LLA;E(a?2@@U*xYJA^y_f0duI z1bo=iq<+NQKc(?)B|I+H!wy{h|4F@*CE?4CPRd85FJ3D}|Kc>^b%%_Ob2PZgdRvm`@@P=~*A zo@!<3c&ECidW1;ZcS)m8wa+ce6kZvBf(~5dFA6+hDS5u6@)1HPd*1zXOA-wqU%!na zJT%tD4uk&0VRQ`3$^*I8&w3|redu|YTzdHxm!GuHCS!!xM%yjuaPWU}++>Mq-H=`h zd3+oW9ex$lt_M zQ3=F_jw8Jk(LTh5_^Dosnn5lEH}p{CxDUDTi{hP#Mqn;n>!B!P0_Gar);kfc!d&>& zLs41K)j!TV5go%^INn21ThR56ha#^>m@Am+orp$aE*$EiC@daxo$aBBmSL`Qy%d$i zT<3WyqTe9b1)hpNf?OAQDze@Dw{z4FaqZx(C@>Ol)hXVJXb#?5B0Lu7bHlBJycW?f zXsaW`b5RYn^_uWpY@NeetuwtBQ5URrwD4f;`y6S#COjEW1X}fzycuOpf?1vW2#>~V zP*&;Y!mDvvkoDmN;obNM!irw62oJ|C@KxuFI|)z6j(gZD{_C0JgxBLCz-sj;*KaC3 zAcw?4D)Y_%oHbu~MgAI2eLp;+ygPYGE&`=qtnHlBK);>53q(a-nqDs{{ho3?0QF_h zz!J{%`^vVf*y-W2wwW~g&E?+6X-!i49p->nz=^+1&F~vcGz~d1^YI1_zrB>piJh1~ zPE7loOY{SJVzwTxYx=FFiuj3{{l}&LjU_sapqMQOY8if8sWFCP=0~kezo}%qilUfb zb$0qKCF+KwnEm^@{f?5)6ClMLFx=aF5{-aT%s(EV?KvqV21_vppWoPH63szVK=_R< zyd~v!!YLs3_J&@PXdR#e1|FQ~A*nQ^0$MIk_l!h)F%?jHm}jKAs0#RgTaQTASy+YW z%dbH=(&BI7bLIyzzS$QOYwe01E3XFtP?<14hErFc3j3b26h=X*J#tq2Rf*Q9wkstU1yz;@n^tUoXoLidmHYDCRI z7ErN|S0nokl!fR$>E4W}7t8|w-pHGg-*cRWj^jNU(O94b{AZjOqYSY~3#Hq7Frqn7 z3j>!b-ixv)LM`Bvsh*2y3D`ozLeE9{U0@5*yV5)s(OR?x{4vjCQE|M5)_uGc(a(4b z!xwlfDhs%PXVbkD(Qd?rpQm{#stUPKvyF!$It01UeUyiydYB8bTNKYk)@jTIyf@A( z5j8_y$erhv$aWcZp?NQlMARO3VfY-6M2=gq3%J!A5%mCGcuMg^rj3d{K7RJhiEPS!sQ-^iUY9tMc#(!XZ(fux!#7#0x-nR z@H9lb0T`kudm5^Szz{y(%Mcw#UD;(dfh3wFqh=Tk{ReoIa4Xnu=Fzt?{LcnmSv#yjPDlA=yNtomx2NX_ zL*72SURe3xk2pQ9qPyIp_8<%^CU}W3X#l4k|`h@g+1@p zA^GGWj&q;Y)ua*)M`0@!PY$`)Msu8ezOYmZiU48Q!GlAxO&oV`PA8FQ916SAbAzGt zcaF=W#yiNPkSGv#wbzDRmpHD!mQfnfS0L<4Zw+o!V>s>)X`{)auow(>g{OvUuh}+k z@s>n11A|@Wp&|J;wwZnP86v~kZ z!1~8|WT^6;ZSK`1GKdxfu+M}iMy;=fZSc#?GAK_n{yNJOgVQRu&0kAMAo>=5g{F96 zNP3ZNbz}nxRKSJ4ei9xS^Sx!;ZS5<6Xf61P?&E=>VHn%=IY#MI%#FS77oHalW7yVj zFv=eK5qjlhcwTVZ!M1;+N%p*hy!y5fUKf+yVLiCXBzb5H@VZ!dT}*I~_2Fid;Gt*ICC-)ufH>GJ?YU>E0kFvaTH?ZKxfD z!bcfiAj(+v4G)zz_UjM|pJjS~Nbr{RZeLkLoiP-?9OLyt+sgX4o2>D=gQ4)v7>^Ha zSP!?CG}Hq`;rp?k9x{aJB5MmtmqX2JJZO z@XuN03rz-6_#@L>LuJ$#AX(@GD23BJGGwsm zI=<#9S!frO>e|E`!xYx}7d<6QaTk(;FWn1+!$-Zxm%Zc)ErC+F&kI8x*8kTM$d$~| zP%65g2ZntDz&8_06&eMl&^X2OLUOYR@SQ|b#r54UrSO91g`q5l_mapIYV~WW*dAUN z?g;`vOe#|hcYY~_!gOy7X;~N_`$`n5_G>9z=WU^1{$OW`vw3!c#;!C#BZ6WaT0DID!#p%IH^L>+mO)B0 z^{((zG?>R}vLtQvucewNcvVQu0=m*9Noek`rEsTLg_*Bc6CLK)z1-!7cJ z!@_*1ye2IQ{8}nJ*K0y*abmvJ+(A;%)?Z5DL9YopM2h)sKTkQ4+5AhXwy_=)iij2S zk7>R#f|mVK3Kw`xs32O*kqgsGh?J2~sz~vcP)EF&W7cGq4m1Nw;Q((5Ek%skx;>w4 zNE8L7KKGW;R?L|BU~#!XW1&=RzNdu7!p3~2k|q-Z!;uuO_mogm;Fzz~HOK=R0Hyx& zlu%yim>;w-Ndw>eNGdwROF~h>V}8+8lLXWaN#Q&%3Au%j`C=hS;P?wkeeNY8qXb}H zn_LP|OC;4l&O?Ha6yUHCE;7J&21wx;4+&bZ6kzsiqKO}Uh@@U09atHP6wI45YglkP zUxy`k(OpTve4(KDS^I!gYg#I2%ko~6zEjuizY)*>Yv$&xEc{AQmYTRpaAMp0|FMBvcjE`mo3fYcKym{p+bsym!w|D0OeSIp`q z6PVvL*2FIK1&*ppwYKiF@iC|LxZMLwS|V00xxjp(fY4D-9EGh@sfm|lsZcCYzJzwjEp%Xcw($ zzkFcpY*%rkwJ<8aK?4|MNaVR3Zmw714aFs+Dv$05DYu#2Fp}*S5h!9^%OH| zh@v`_6h=9_2~0ohViT$6NDF>jMa*aih{A%@Wv^^7e!QGSRaRawuk;qO{0SiHv{Yq- z_rSY*c_w+v6d*C!zov-M1Q7LPin7;gu>ZQBLzHFSP=Z=?ml+WjO?!=By6bX)I6a+eoHqn>{UuP6GWkgl=B2KJ<$DVTyg4wrMII-LTYZYP zoxJN|lmPveT4X#2)4g0kkfNaygdb!VD{24$zMP!fv8X4--KGrH`z|^CL1?VINe~YILlY|6Z1h>KOst%QY@}zw7UQ3}N2v zDO7pljJt?s&I5q zO@#cPn0CE%4-sV=c1J(GZ+eFyb*wK#k#B%hVcy~)K-9;yo3Dq6DC@}kI_WR7%cw{p z9tX)#R9mv}FI+Y+sBFwJN)kA38iUg#Nb`1!dj7G{t=jns0J5Jh>TWD9?Pbb9lgq3x>|Na-jj z)YPowton+G5)XW?Yj!Zw)>pk(1LDq)&YMlLML5Oa8bTEHlP-Vz>!Z_K<_v9LqhLx$ zp|DPxb+vRCk)iz7X#H}8VK%;{2y(`A?%N|xw@;92{%HwOhGP=vZ-0Ayddu8l?Q0ZF z<*5Itj#<}FhY?YdK`->m#m1O$W_P57pw$2^)N6mt% zob;Nt&!}6h&xi~aw#Dd@+YF!_^?d}=hu+&=j~`QzeNq%vFiWPt{q51IO|u5IuADoG zL08fHM%@X$Mnp*mztSOhOP6+H0SuaTT1GMlAQdgAr6_JZ)I(nVU9bLRor*9WfJUfhrPVG zbK$5?wF{?q(l4dl1|7TTIU=pf8J+Q{R2dgk!{D9wO-%#ZN~VLV!C=<|)7Ce&E;Zq)I?43K zs? zD5tLlpON40xo-th)*70~p*|%2rsBFobrr(jj`X{>fBA%-jmu>5)@)7o1aoe=5D+CE zAEXanlVp?cF=}3+h3v0F&j;OiXt zmK4f0{TMw*`sp7TkH(?z74AFQX2tQhquyOVuyRtbhQ(4l#${nq#@q*CAad&X+V(A# zVf_0TRd>`rHq@YZY?12@IiFT0fBW;T14~DDsFo*@7WdFNV~*X$fyk-n7hCtS1e+FP zbRCgf^B7wM{Z~$O-67AL3g&M{y}qz*_JF3@jB!ZN3u7*?NDz693bAcp&>gxCqwP!e zEUjY+D3`eJP?Dz#=dVN0&uNvz7&p*hW3G`{5G9@+Wy`*$Qf`c56#i%Xtop_tAdT(q zzC)?ssh>Z6KP6k7kS@ZM8zve=Y1UY6*H1*_-^XbE_L^ShGFUps{WMrM#(jsf1*#!t zzSi9__R$1WZnbz2<=kVley5r6Cr0;C=YN^~`3Ko8twJNheJ(syC`1)8-^d*M!r~0M z3nD^P{G5%_NGARTYw*>PCH-n=)-XZ|QH8g6;h_pq%80|-Yq5>y8FHV+gtY2+ZBjPH zL=~1|;Mql8)8m5}ph9-KRbue)&6Kxf)rM`l4<0{z*`|G`u0Hg8|Nh;(w|;(a-n@SG z>gCH9&!0bgbmi#QWz$FWXjQvRp0o*!@s+5)MHw*;FGOP;~wLe?!NRQm}6(i`#XDP_o|)CJ-!jOv#KL=iP%Qp8FB?h zg~+kTXPXnyZ;}rd<@<$|9g$%rUH<~B!IUz8wKfB1tUG%9Lk!2Af!DW9?OHX9bG#vH zWl=`#=M&qC8*A@I(%&vIHWJvZtR*fugx5%`yUut z#V1}6HHuM2ygs(k7Bg;?$PlI6WLrMcEB$9!oZ+)mfWp%fV82nGQ>Ka?CagdI*21>) z`G#Iao#Xvbtte$=P8HieFyq#X4NJYtSck-wDF0zL&d%;B5ohs3LA0kAwEPoPuhs9b(y;c7V7@2Xz-{ExMcL>maplU zEw{s2pPEnhNEuIu@&_uSld+AC8F6_;h_w1|Y{J=kj19n2jT~gcg3YB^Gkz4~S+d)_ zV}7iAuMN!_FNd zqjUTla$KQ=cE&c^V8SgDCZaNTiE-PESM{N;@MEb{mSRN zQ*4`Tz;Rl^BCY8st~y1suff7yqrt#pDeyxorloDM_@0$T^m7v}ehq2O0~HTfk8L$f zxC)|0fu%bV2Br^7frY77x=vgDSzr&QiC;sMWV7N~9ou|j47iTsMPlD_ zmP(TtTW9TX3ltC^$?UI#*0oXt~A@oR_@4!#+wY_4mutepXOQ_zHRS_?OiCw4Qe--j+3AnuU^ z2cqV)1e6rQwbn9!mV+^^rI41X&d_z&1Fih0ACJvw zlivvz*0Q>d+100vcWl{ez=aqEPfUJ3#=X8FSqzWm!9Ef0v*iHHv ziqCk>|6usho=G901gqXxyGGVZsr`Jr3^>jwdP3|`$id4ADAm>xKXXR_@q#3|N(x{o zJnlA2#+XuaC&es({>RNnT6@BQ@wW2$(}3gCn-{aEsNYSvh2kgDukSP7EeRX|D|saZ zY%2+lF*~=qD?hP>{OBujlFyIge>Y-gLNMsZ3OVk?;$9W}Q_5izF5E}_gxL5?!=0SK zyOt6+MgZ}LBn66@rS5h(k|pIi2U(MNZY2NvKZd#@K^==q`fpuV)(@#0_U7zQO*kGZ z1B5t!mBDr%Y1*K*6qpDBPLTx1o0YNX#JemrXUQ4mvQEg^a+Y(TQamf_hubGL&u@DZ zRoICCQA`Su`#fu`^7yN+ESc9JK*nlGiWM{RsK5FP%g)9!X5NpG_qA~jfua$tw4)9$ z=vLPEKQ!BnJM91Dch`{P$Q7jxGJu>XnPbi6zsk?j`MC=K>?%uXnyF+Rd6#AB6Hm#a z+y%*dj&lG6Uto3qVB43?^V|NX;6rTCzgjt}P1$q~f=879c5K=BR}1A6PXGX=RT7+P z8d{Dq4_KxykSiIlLHgJgwx92QR_#%T7j!G@OSUrS*u1yAJHKIK$8xC*!bT3mqGHFm zztH?8R7?)_C*G4~S(+(mxyLg#UksE|+*$q2ipx@!&YM zfFJf@!c=b2Czi!KWXP0egRPou!xf8R=`dn5I)%!t35xGzV*mvwDDo-$zVel&bf8go zC!=U7_2)IY755EXgw?|jTke_ftsc2o|vcb8Fs($mu9+Twv)Sk}d5$G+h3 z6k9FFdX^Y(3{5X2lN|%MP=G za#v{9cgpG`ZWbD?2df(dgecebf9zFe`n&h!7Du*GcoRcL0vfKJAj6oGz6`{J z_I)J?>pQL7m=a|HhuKX5#Xsslck9Ji7AGTCBau zpzoOt3Fh5f3roDa+&C6IK4>5Lz1MBtfu}54UZ}1K2a)^0Cx7d~)S~qNZk1rn51Sd4 zTonUA=*_M5Q4abf0*m~B+*rcIUv`j{y}|GeS6nP$-l(k!2N5NxJo3QB6Z^NV?vjT7 zf73T9#{8s}gL<0}CgZ=HJ~pXISCzv$|Ds6^jfufAHjh*~8HwoAAG(DD3E32pnI9$4zBWrh-D0=wEdK~0z3V{!9AQ=>?2kY~&szNbw>mG%iE z016iGG43CePx%8@dwrQP4_uyM+W?C|bideJ?N4Tz^Fd{C@(h=1%xpf@JFP0q=;Q@J zwBmzzE90=Y?#cGU$gxAwYy1x(y_$9 z)6}H6iftoKFEKu+Yg-TM$r@~zd|xN94EnIR_>7Wy%r84TtE_pjj~h#jT(VBAKaXrs zu~*(%?_x33I@48fv=?%Z`Qso@b#<|gG)-dEl9i62l3|LCVpZG8vgq?tVpD?U|M$Tr zRrR@zbV_1$koiW)qFZ2}R6WAtsCBNJ$ShL;%vZ9ismyL0>AOK%q{Yb85sK)xwM&X` zWs&q{8F6v>t^_!upHW2}1UosCw3rHVZGk}`#RyGw$J!nCPWM?nHTTm5WS>&t11@UmGJ7e+C@W5TWn|4xW#hnhrD9p9jYAoSsHb8C~W4rvf^d3K8zeCyU)G|{F+78 z;7UR1=eAjFf?K{dB~`S=EHW*CMi~s;>-qm;Oft|0-9 z4Ex#&*^aUR+gtCgVzv1#vEmuiZ$oifDe(nePXYnD8|{RuPgsJr*3x(B%!-58D4qjh zM^}^*Ysf-Kuz>Dk`@rg(%rb0fUR}40m1h~X^N=moSw?IO@|HxF(KXlxJ}X#^ednv= z(nPC|M~+G-U`tJt5vS5sCDKWUIW|GI<1EPDaMN##Sb+{vIa8vx)CL*x44pp_G}4`5 z54`_`McJJOy_O!*-TcX zhn3BSu%~a!hw-cEN|A76h^}{Ii?7lH7HN+ebXbybRl)^S&0?GCnRLi}8}w>oSVo8Z zsa9U#d^ZcVgLT+cR;D{uO}UuTvm|Ybu2`24w>I*fHwHZmS zkpqa9zVpP*7aX(qW|I}m%--=O%eL!vR+2E4VotAO(pK43>>zf^y!|hD=!2X#NS%`m z+9gSJxMf9Y;I~hoyz+=G$5>fKraWNrwvxV@!AfG}HSL1$hG6h2Bpq)t8i2M(W?4dxQXS{)f5non zIgfry7NJ_cI4YOhU|**Zsm$|#3d8tXa!?rn2A0gTc5H>)&pQ^+^6oXGZko-?wVrC( zF=S&U6{n1gSAxS>yAE|M0K?BsuxPAY&8PnUnx)=eddW9Jz3f*kGfH7&dy7)qd9~oN z2zr+A0MzVbz1XXb+y0D2-;iWFX)dc*OLC>MWex0XXF=NN4{%w?8KMtoS}HybmOW?r zcac6y5vgE!utIUay%V;!L5xo90GHL!x10+ge`CueUCRwoECQP|>!NwAVxMZN#C=cz z?CmHL;c>5m&m5f!_`uqQmPlBaZ3!#}Z`VU9B9#o6P$O;w16Xwji_qL$@YxX836*@H z{}9U~__KX37KMxGparaESE>-#{$E&qe-WVhbzn3XdRIsJz|fJ_Mxg~UEDfL4bWf@% zMHA|-Je+%dWEK8Sd^V{EqxsNZWdxxUtP88|4wi^(>YhccYTGIf$FA>LjXxKjO;&=@ zfo6h%HH;u~j78z!<1)*{4~#k|d9<=Qqcj{k_^~Q~AUd0@1g9z}3*(F+INyrMG36zT z#SL`MWLCD;r0T-d<|V818-g>Y5S$jqNT70ptPIPc+(su0#@94m;~u1LxRa_dHh;=0 z{i5JJ4o(|;K%B=39@B>6-&KB|;aU$d7iD1Ud=DH*J)XOlX}xQms2UV(WUTt-yL?H}X< zyN&V!zhzmuwN6>UsyEn45hR!y&FbD)T*mitQBeiiYb&{UaCGqHz`{Cp2*&JJ zhdIEeqqhRbSzw-~M@%0SP@qZrWjoG_|DnLtFtFSof^pZW>lnChv}SdSVTt*(qYkOa zD!82FbDtW+s{gdOY!0KZ%_@h%L;h;ReKktU@G}d|4Rpv-CA2{5X${}8`rjfh*BENJ zNUa5njcP^QP_4?Stmqw464jSeTH79xZ?{ocyF?b8yX%gn zifEdo)9U&$2sRRz0javGvem58mN?k&Zmh`ntE%er=hVieIC0cvjbPFFwB9fUD595| zl*@RCVKAStjDwUNMSj(nU)>E;?kmof;JVS4V=2qdmLxi(0;}RY63%5@6yu$_m`Y(sL)Bu|ye-m2b6PGZ20xVcn9hupj0h>nD49bd~Kai_gdOh3Shj zx+>e!?`J4HFD^UNRUj2CAzL!4Dt@2kXLCYbQJ$5t#Yd_+kBVR{TqQ0$lEnv8fdM)u+;D*GK4p%3YbI|^x;JWIQq@vxD& zY)uzP0b(l19~?bpxXdzil#7mVj!{T&G)d+(Je&bBuefYU*Og;(J%wr|=&v3%1)g9j zx~z^U#40(H9IO0{A<;uz=FkCUs=ubaH)JlM#-c3Mms{S@6^E3kjFxHrj+)` zFUP@Q42tIjMl{Hic&s5?EgEEJxw+G z>mA6+9pa-m$KM9y_NI-BteGrI7t;^7l@gbbTPe0OFb)-&#|;FaKRYkPL9JREfuVm9 zqvFq4mL9AhKB%R!GRt8=C?jKSkvYa#0D85x28;Mt!0>mBipnGkQwMc}!K{`pORTaF z85>iG%+#pSfKXP0fqK=!i^io14UCRhrS8PNs7wFTXQdO-6mzB!s zC*TST)Pb5#FsdfbAe#)`elSF)5}XU!0BXx5@(qK{#bWfkf<@{aI)S28Qy0mU^#WsL zfF?MZ4SXP`$`Z>0pIuW&KkZqj?y47FsHR;~$#Fy!gX9qs-76RY0z*gu6=VaZcV$>V(P_R0y6X>RDigA%YDfTj2P7)&VBri}2!gXLYdZ-xvPGG_Mqh6S- znz)$kF?9LCa9L4|l(%?^)##^!(a0HM^qVVQvSLr2P+mFpkvv(>GG0cxijm0pmKBuf zwtA3}p<~1-cms>p1$2T_xN6!gbsUC8GGLw|Q6B#@E6CNW0DSfyBu2p%S+;Jj6X>vN zdL?rT-eJfbB}^jySyq}t`V@fARlADO@DIY(7`@O>HF08T<2v2Km{~%cgt%as5vcl< z^YP0DVlqiPx} zXA-Pr*gQ;PJ>?xfP_9P-7kOKY(Xsav=_*9i3AA1{9g#6w!%vKx0|ZNmgDcp`(xDbS zuD&iqjFMZkcyCPXW~-)_k|om_#?Ds&Cm_mRnvT_;xHtV!Lz%d2_fd3`FgFP@C_S^S4yQ zC_6Y-z}lx9=z?OpEK~B|X86n|ZvWi)xO+Y=>o5-LlZbXn7hmoF;?;*1pSkxmF)HuL z0`?RA&_*$Rl_#!K%?zJ@n%J#>{1yitf5rt@Tz}`|FTMX=X;`MLBx)dUXV&|VTzc3R zW5nowlY|vx(haWP)e`&2l5#H?KxdGA9J|Lu)dtCurRb4;(}fjie6ani zomiCwedjB}Ivgcq_03j)Qf&L+3B?ctPSjTjPN2pS|COeFSqZvj0G`S%jqf-;|(REL; z6zZ{zeM?8sa-|fc$&EB;8AoT3G=*B`V*)C`3hkv78bLT>Iv4#PL%!1XgY=D=Ye!t321Z&S_@;v ztU9`ahAE_AO-6K#VK~hpLFUtG38-Z8N!G%0F{_=v@Cj2#oK!xT<}jY#A_T%+2w^dMyWS|Gj#^0t<9No@ z7INfgva$p!$iB7~a>`jJ^#+|+Mm=RhqN9wdp>A^IW3m+q)RVnyEriHfcXUS`Wi&z} z6!K$GT}yKG6|&|8){?zoEqKdWZ*_;^nJStt4YW>CjH(qS$>U^w2@H@uY%Nfdm=&r! zsEaCEE(=^%Gpza>CCTk%@sZ*nztv)RC1$ac?r`~{h_*?B^tTyTr;{YTk_`PL4ANb0 zF+7m7e02wnP(;V%K=mL7*4(n>Y`V20bkm({F#ISq!J;tkU{}=BS~k62RvmL+b!(av)vh2+efISqul{ta>^mjadnO62H8!7+NjK zrOB>z#UnJ*9d0pfm$TaH5Za)GB89JY1Y_$llBS!}<&IEGcaX&}N6z{~kK|E8>?C%M z3mIKo$&*=hStC@??PD>tma{tQ5jvuT9EC2$Wk%O9cX=|FP94Edx0A(?Th8jNOUkRD z7!vuipBY})lSJ(!%Ns$Zn`JRrPsFUQxaI(uu>$fJH;-)$uj|W`CJe)(-g+o zYa~@qrvp}1h9TiymIIxYvij>38l`-W3R&Wl46;4s%C0g?iR7e>lp2((n%sxWq&Zz-qa+_HZ1I5QG6_7P1G^eMCE)Yfo>GVP}`7X zYz_l|35(KkU&5NAXH0Jt&PjplWo4*+WRx<;GH|L@L2V_hX?li+DV#aNWSGieTZ5#m zQee3H49mjWDPYaeHOXSsO$SkOSjS*{LX$J%Si>DHtjpXn^3^O|L)+9%5kc}e%3y2F zOOjT}GT4{G%KSpUnxk*Zs~Zn7N_vCQb`43}-QtGopKoc{Pl;Fa^o{mZ)x07h&iIni zHX^0G5ziUw7GZ6^;}rdyLTETe4wyCYYA6<^iEMV6X+m3 z+7JfZdnU<~{)<5_5?1IJvQ-DYquo(5OGtFJ84S4Aq9l7KsD^k-SfjHw(mkdKtJG(&U{y}{ z&=v(VhXi*lLvBNo!qdN%D}IfzP`y{lRfY9WPX$v{a7HoY?$_jyP;LxMwC)69sahk% zs_gnFzj}G2iOq0^+|bk{jrXjV=9AgNYHb-WRHfEGrf}slg@kr6V{TuP$WxymmSXM6 z!h$V-OQuSwf9RWX$tyJd8FO!%WRei`UyG2n-ab`WvH=T3DkmKju3GMEBGa2O*HVCF z^A_(6kgT|ToUm+b-jS!wn*Q;!s+LwHuss-bN0Ee{aLR|hI+Z`ZZ@RE@1HTcczLWm# z$%^%jgTQoU&^>3AQ9_8Z$1cemNTPP>)eD66TlJ(c^Nd64#Clx?I7hiwFH776JWQ?$gZEfy|Qcv|yX;!SCUBsn5qizGjYo9@{1gV?)XR~SkKHhfDD${yLewGMGn*CbWD?b;76#p@JcQdm zozII<+w_iagkp(ICMu20jJmxEx~-a9-15_Ey)%au>mm}=`esJmgBo@bQNj5!($jpM z;~Sw^%;^NBVhp41rz8a6!GrF}PE&Nw99FCwNKAA6VA!op8186sL~cBna_$fETM2zw*b!?4e^L5_sFXvQ4772 zB1*N)Ceh3n%D8*MiD2J)Z{BlxsG?q(&x$oVrD&x3z_|M+2|>SCqwf=hpLTnv1gzjNfo799vXzg<0<3rV2amhtNXK4=4q`*7P4w> zMIxzfV%%M;#cv>@?BgCuJXLf^swma+#z81LGwxn;rg)H!rhDa`ygFndtJWSQlKCQW z$1f?xlPoafrLg0xKT<`hmM^X%;rX0lH#9fJpY-i;RMN4U^v6P0t;0wlwQUT$mf94* z@||y@#hd`rovET!OR%>{bZ6XcOYtrPdz}|@9_WuntXij#Ko*F?o!%55)2g&4LBcty zJ5oifmdIoR;r)tXcQnP<3>$n?zS*QZ7O`qwMB=FJV%VLn#oHjV)!!@K%+VXEqE(9} zy+Cwk*j;U)_?;z|Cx|w~^u}UVts6-klmCF7ohFL+nQ81Z!KQ=GNFA+QatXu^M%`mh z6d%;5^LeqRmd;qrs&zk!V{Jy=OKucT)T+H*s41c^Qb#M7;^N@(4t4G(pm?N(zmAh= z(&>xEtXj{KIL=|veV&x!mxd01B+t0%iqz4{rK&g-vVzWo++>`3wL2xugpsaX%&PUa zCJYXD7<0pmkcnzlUY8`vyw(-Dlaxyn62^gyxsheaRLwN}t{ihkS1e}L`rIfC>B2E5 zzA724_J%v$Ld-rrkvmDbbR<#KjxgjVHz30`@04&6W`&+u%&OJjNf??j7yf-uxkD0Aq*=Sa)*#H8`%2~*`>LD$epZQ z#*!$OWyt-CjM@U@ZU`}2Q$cG%+*SF9$zkSAHWtRhj|!hk!^B7-+e z*B^rZTP1+>;!;+v)-0lsj{$dzMTW1b*w`!9|4wq1ooW?J7b}n}Yxv`yb<*ZzLkRYbBK+TIXgd>C7tbc{`A~YS4FIl;KaTSHxjI>XR6PEO< zIr}Ea^CKO&oR#Zv5=5_Hq`6;^@T66A*bz6tyRHNBB`cR;Z&B#SKzl0>fy#`XHpQU3 zgF0Y2E7z$ch}tWlxtg7TrBA~xE@-#P_T_Ubm&jzIP>gZ*d?o^yxh6dYc2jKM3RbR* zNfhVf%&Abqm)@=R!@7PpFP~GnSkjAv=`+k6NlzHF*!)OL*UILtVCA}zM6nX1?A8E+ znE~DZM08bbT|TFJ$t4V{P-dQ;kfv2(RRWyLZ|lBg<$8cb(fJ3)xcU*?3>$V2%%!q% z`JL*egfP@$h>iV-5U2g)U!k0{jr*3B>p2p}IS})>ETPV#3!-3L2-)`hPW4hl9Bv@Y zg`5OD1N&S7aj$ILx2#-W7=*#Y0x-LS2zy$!es&?atF|q_Q@!*fVXVmby2Oj{XRgWN z`0apgTgl2bJb^fj#g`!#K~UdTJDlKcl}#()R4>a(98aQ4d<(*%Sq7emZc}aAN>;CV z#la8?F3&3x7JX;-Gu$@7mKAiWmkT70sTo_Z<|8=jX!v~sXlrB3*06eQDiD>iWq$}k z(vTiUAzO7DR@ALtzBvj+FKGGBn_y|diFff@AshBRtJl>ekc*IItVPJwR{h&(u$I<# zm0|^3QzTB|N@9D0r~aSpaU(T1+qGH21Ureu2Uv-yMi4do(919_oNRVkR zZxtq#YL%V;0;9dLUE5VmLy;(qDn~OCQuY3LQxZVCZnG+~f(>^QiPoU9!I#i#hR;sG zvqLs(uZr14A~_LKlKNSMSc^`1h|Sj8s;aDFTL{HUM7dv)P^U>JRTxUYI0Uh=MuywYm4XfFmB$$36a=j#>+q~oNqOZv| z$nlq=87vxiG33V}0=TxCtK*WLZIDsI^{VW2xLEVo6ullyf>!zyl5{)?k z^5A_!x`n4aLS02{Pd8SyPe?S|;m7VU0=xEl-zP$@ls3ovuBzD}9Qn}0J;p|WH}td9 zc*|n4IlWoc_7)DO80>gimr!rX+0Vh&2eP%k50y=E;h?+FaXCK$U#}*=BqOcsHm4t} zTC=NotV52iK?Hs?4?Ksn_ShQ#r^@CoiDxh1m}VycTyok&khRd(3}$7!N^{RD)r!iI}*uz#B!PI%SE#*AQfTUA5`z(z6P8v~3^cgpJR}d&7BJajOCj5<@t?7TxI{F0wnqo`lvdRt7M1^Vt2Vckui-;(= z`@z3UQd9b02M&J$e|p-G>v{WDzUf z)dJ)F4mdcrF*G8w%Fcb3(&9d#|6sadFQ88m+mSm-?F$WTTO}X3C z8_qZ<5%FBN8HHVn=Q;^)?l9=!NAE%;A`0oeJ?V|~rZ=B^r3>%uuo>%F@vapeYKR>i z(+DIHQTq13B>bmhTTzNE{tMihX)8*&RnH*dp$XW*u6`CuBBH?7ORxVY?v|#M`u;(% z)7w`3!m78X_!yRB2lFBaPWrFe+bzBEf7p|eQm$419Mq|6BPz1;O)oxVSO*@wCm1UI z*BqZLKX~DOG&A2=Re@5rt=%o4lixO2`; zZK%u2_a+Ik!!GdP@G6+;Z<}sZB*qETY=U)4{j3usay$SYoY(O>EBU zcZeX7^HK0%o@7H>L^)Tu@XZmM;JB)QYKaoLo<1CljvhID=-`20^y}NFSC8&pJ9li~u0`W|wW^daU93Q^ zETMitiYWcGL^!i4?pwl2_zsD9t7!B{!SRnS9^AZq`tTktYUOvJC!)L?(ahvHub@*6 zZ4@#p^9^PG_es~b&+c6(w+%QERXm1ey2g1MSPc&sGs^N3aQ?@H%is4dYM~~wTHQu6 z)#JL-tcdFg8YR92JOAyo?|T)oAQO?#;MX`NXI!^K6=f1Na@htw|Mls{UYQURQP=_} zit&u&s<0vsHwqgWCZi8CC;wE>4w#4vZ^1A@6t`Q4Rnc`4`Hp7zVdi_|aseiyntvjg zJ8@hCR>iA@jw<~CAZGWjW?p!SY@HtBmwj=Y?VK{|Cw7$64}qBZNlyn{BJv*-gI?yx zZEaZ@R}wr*d>Dh6FVw+HL|K=+vCF_X&Ecjx@)bQY3^HR7^VULmiKx`C(4}#l)`gXE zpeB4o#ovMuC(jANOJp^;0$hs5Wv(C8(HW9~K6^ok`DI@RTq1JxdJbEBlz6{%p1}nCbG4=4_D^IWlt26AIZeL&rpaj7e-7( zexqW*O1C&H6)WTeQh_`+q7X+lhfGA1z)Gb!ter}lEEh;?9}YsyUtp%SU?oi)wn-(m zkPJjQ9-IaZk8Z7_2mQL^n2v28lsb_X7a2s}5B95v%kmoZV+1g&Cvf)9zN@jk>+8Q4#Bi@h71UqO z5M|jI137%kfsTe_mF0r{ZCM4Ek~KsrCw)a69*K??W0i0C`;Q7TyUH6P*UpatC#fVj z+JjY|WN-I~3c5{l*HHB+-0+`X*yuV|xtzQ4RZzi8B@YqhSQ-H~JQf<+60yq3+&vFl zxV`)#N;K>P)+CokMmZR(9?aa~u!nQXAR@;WH;{(Udjq4YU}b0Cw(GQqLX2{VNGrL+ zf-^iC7sZSF*W83*vVyO77-=tc@tP%r6Exhu<|fZW50!+Z1s>_M3i~pMJuTAAqx@p z2P=zN8ut@y<@+SXlm4+k5>mJ=BANwOPUmRCZ?KnF$}u8xufO&ypgc?mh<*es2QxJ3 zZ`jP8q!|$z^Yqx_4<`H-AUXk7w&Q0?7VPFcGVOo0^lg?u5rdD{cFq|%X!OhrF>)_4 zKOTAtR%Ws@EeCdUm`S$%X_9)guABAS>%HSzmQLqLf9hX_o$d|EdB#oOH1+bmZ zk+gTOIQC??-U_|Ev|C9p`Uhu7JuZue3V@Xs!R)LO*v~WM-Tyii8L%f%C%xP~wthwf z{nK7SQg06$sts03nb}|&Z0JUE?|%(xs`X!a>AQ{zxwyD%aZmb}FH1eH0fxGOm0x*T zumW~;YWer)TJj1#7oYi}H(u-_& z_FfMJUs%++m^=Nv%jF(_jD@}hD@!;zxCgfM9#WECMcPd}@G9Ciy+6BpWb>klHTuaX zO1?{WDD)dxxr32YhG9?lQxkt~NSwENhtW&--}+>=1rZPT&FNVqn={4Iev*$nK%whk z|sz`^y$+2v8_uc*C=*RkbSqkfsic$tQ^XPazE_q zV3Qj8yRI2aH|;-h?y7Bv&)<3RIV@IKgI?c0y=&F1Q9WDLDw#8-E5(g#WgmYAgfcT& z-HeI!S72KoBE@U;Op&Ev*;>sy^&357$;Q2>&Yn4S^2G6@M-Cr6uz%m4-Me;f-?nx0 zri~lct(iBjU%Ptc@}>1QQk;BB`aKQ6LFK?oER%5hN#Xv-hkbkd2V9-FYqOmZq6gGC4BZwqs1vmu;%>^qcIx2v_MnPx5$|c4-ybtzvX%y+91d{WBpm4CV$Z%(cVQ)t|qsUYx zzzZ>uKZDiHjdlaWfcRLfF><9n1)F^dkPJ`_t^&yC2v%tpQcw62c6&1*nWY+d3G%s) zRkEd;R{+~R1(0k}4vE=-Pb60PSf*W8!*+i`KzdF&@J!s}%UJa(iH_@s{k{rGUZ{td z46r8`Sh+?T<%Sq+_%=YIg{uc12YbqamE&Z|cp0{QS|IUe1sM^DdK!Y2oh6z23vBtf z8jxgH5j+U>bOkF@HJCPLE8tSt8co)TCkl1`EKG;IDqkR;VBb-`uvPHn8xAPA>?4ZA-b zkfeCd3iBtt(+;a>1UuHi?teu%n(PUyOm`8u^95FUU%zt)VEZq}kt7dUZLWknQ?SaT zdX0Sv4!~wGlIR{Q&g5KZX9ZTdLZ|t;Z~`Vp5ue+vJZGSt?O5d~eGcz{6Yv3nsP}bN zpRdz_os(E)J3Z3wjl&T*7eqX-unO%9cJ5)7i8{>q5e~uH7~+0`mFNu{)Uo2BN{2{( zj|MmdE#45~c7_#cW2h6vP_-f{<+5Qo1+NlLyA1l;VNT&l<`EOz;cEdp!<$@oEwX9MTvjCmpSmmiiGM<2= z@HD}vHj~xr1fVk?tK5;moWI~K9DyH0Sg}6$#W_D=mCHujzZnk0;_%Udm1{Sg^9NQr zd3_U~jlp3U;(#9YE$Zcx1#|9Um1EX(#7a004-j}3i(&;^2j$q3u*w-ih*?YFKV@aJ<|S%oD=RfvkF6dGJk6mS7j+ zM$RbZ!?W;B1req`Y-s0L{YD1Do2C+s)dx13vkLAHZ$6V?ZW3y``zxVac62jYf|(8* zQ&9PfE zE(kCMFrx@7<_~d=Jwbj6GGfLW)wB!O!^-O+g0vQEaQM>a2np`ZuCWegh zs*2|Tn&$G$U4qOz$|@>7o|z-R%!Uk4tFqz&c;=G)QWY}Fv%0=xM>E-2d<}PojG+pP zo1>Xd^2>gL%sndW2%1?dzjT2N?W4-#a#+TCD!-(LjMS{O*JGJdEWX|+$gHWhl5;?r z!Sc&g%;=%Ecq)|HBfpf!j2Vh6A^^w)#)~iCO_;G=ad96Y^9hTuTM09-tFG%d9J5t^ zX$=|ys*4-JnBXMw#hL^&Tv>Vl4r4mA_)@9*EWZwfjTCBZ2Yxvqz+{IFH#NpZ&`U9vUtepmp+&2) zZ_vv;0cIiL<`*>--@3s7LF#qqJp=n^CDGRXWk)ydPJ8S7q&iUKUI{AhFepq7=uucdt zeg@>otID{hzNDq7K4jBFGykJgj1W+R1=#U~oGw;XwoOZNDbRk-sjvKR9!-b;^*5JId^dkDjBYtvz!4Gb_iH1C(3V1PLY=d`wqn+)=x6FWPzG`5zp| zwo+}=S%BS5@L5@%owM{AL;AX-cRsgrTpUwPt%XEOFa`0$;j=R1T3U=p@h*!m26G(# zBdJmw!vgGK0??W2Y`f-S$WVXG?w1_*uTp6NFC>_f2$EBsaS?6BvqZNgmqOT97FK9q zumF38AoQ9#TdbiNGBy~y=M~%5s|ID}{ZN207simT>MTA?D{(K@W$~pDw#Ds~SvMA7 zM-zs6M<_EMrHN>%YmM0c%*r#3jJ>lqVb#~s;G8}TYn0E9N>&X^MtUimc z5iJNveIt|^*VZi(l}9j`c@R{_je|xl_N=5S?Wv}TV9!6uvDKRbE66S-?8qmrL^X-sOsO8V0NV{<2nk@ zt5o-8SHf5f52&oLSV<*fygT8n&u3M(Q^^@JH5|A1HH)M*jl!D9a%#x(v;?({RT&pk zY+j|hFS`=P;yFQG1-%keF0^weu+{dfvZcz*n5p5oy>D1lKRPI@AuOYQo|csWH&uut zOUS6eyh?Rnb|s9(wt=eheIlVm?yl=VnA<~<@mQs0%+zrF-Zw15$5d4>7Ei;sH}N3Y zHC|F=&wLe?SE=sHu7t5TXH-*;+tSJRc5Vc{Iil4VcU4ZtOby5HeZ!(XMNNIe!s+F- zgudex*+oksd6nwE>`E95xxZ0G*)9nuQyd6>o!_c4uB(KMnHrAY`-Vlnl8S1~qN%wi zA#fW-woCPRmFm9iN*D|O1_fmumreQ+1RL+EF)prbjF}sb-}{CoVYsJys>!11b`685 zx*}VqV!TRqUv@Q&rD8kv^s8v{#DhRsJFUhNGAR{f=7!_Ha0axmp+ zIqP%~OV(vdX(0=y2Q@IFDQax3N^maNaq*RKma*Sm6jJ7R!6Ym#p>fg(C6<&`4S1I7 zz2H| zzIQBzEy%FXmehIGkoSGTqyd3)K{dASA22OdjiH+# znptMgRzsaxF5RP{5*<@w$=UwVaxKzv{@Eaw-(MV5kaa;W2~I(vG)pNy- zNS5$XKI*3si>1-o37NIKO6;`tkC2wO&gfmQSl)M0KMUoOF@()cl^DPOPmfdnc5}~u zXUTtA`GmxZC6AoI$@D^rZTJT#amD`Y@5itb%%XaRuvl7x(7B@$OV0jJj4^ZLi3j~z zAx=;{j{9QCB7$cpe-*}G{=?!{w9DelVXPYc3~HwpOQr9<@QL~=vFE=3lxQic58L+C z%4)K_+Bqhc)FFU&4^&~?=^qe>TrFpu2w;`DM(I>wsdNWH^e83vr}dA9cbQ(RZbh;3 z40TsI8>Eu&z8K{bqQbcHKNSfo4BqyNRcUL5lhY}cbR>|Tro_Jg2g1Ajz^yM>sh&_c zQ&}iIYd|UANEPPH@sEOM*#VoMvwF3rQZ@k*LP=y6g6Tynj3@n5;8CXErl+iGhbx=j zER+r>oK9y}Vd1{1Klf67Haxbn%6)Dr8rxq&$xTO~qV+0_yQJ#eO7vd$$ja)ss-mgS zGHG~LLh2k!?09O;wRq394=k*RS1OvlLP>YR>fI`g-${MB6z#G4zJ*nCOgbe~oMqC( z8dw!pVvAB$&V{?Jx@%_j+(pSOkx2r539XN-u*iT^l2f5BD{jZIqCTu*(#Oao^$4!Z zsW9%E`f)7KdD*RKR@#LWOdl3WR}x-pS5#Q;sKzmW$E7!-S$$7ZFV;not3My-~STW{K2Nj1b#!UxmFi`Y`2gzvyZtEA^_%WsOKOjxf8q3hS9T znlR;RyWnyJtM_fHB`iTAx#j>`rk4r~*NhU3Ia<%V6wZo1%15ybW`Q&;Gof~86;_*` z@)@(WnsXtHm3|MkV!I)cbSBt#{-D5cVk&3I(qh)R5LW-U)Jh!|NcU^FMg3KnmZBN5 zG@EfIm{BmVQu#?B`I3}?+x?pY!=5RZDOa1h=R+6@=c<%!PJyHrLHB4C_F*dJRG{PH zs}YQcfsP7g49lZS3A=ql6Yv!1F(!11-3Fh#+W$Zs3>sHF^{xs$&F z%Tr9I_;-fWbE;#bC^DGvyq^NY{^n9HlmRu3+6YdNMD`m1&Hbwa`_fd(MlhldQ5&DK zAo|9K5PggSi*0Hm&H$)DSHG_qQtzvc!-7aeL4x#zq3R1yn#d>y)!Zs0KMSJG2-IgP zu;6&p@QFZ`sVZZFATp6qJw>$o!lkBBk5TolL0#DH$RT?Sutv)jSgB$b^I(O`sEcYW zhTiiaT+d`tUpUY#ZsN)^b+KFw2}n!GzEy#JW)_ceCCEutc)yZDVoDRV=T%_Q`A#pc zt|~rcDYOHD`yus(#~fd)RK+qWWGR*6 z3s+GSwO9symVofRh5{>6OraaHELRhYWRUM!3E;K6>I?gsLP=x^G^q&N6A{Exg&@AE z0{hq$TqA&mE2xMnEQ0nWj5obdUy%*XfDR(d5*0C51X-nl9CcD(c-R!WBFh(}dayl^ zKrT2D$~%2fUu|Q}AT_dZS@lqX1yH{vg!BE?7tS*Sx{WLg)WZw`BrGohy~j88m2V2Y zkmaLMHCVUh4|5HI`myQ@`Mz6X0jIDleRE28pAA<*lX^_6i>_k`Ux?Q(rk|KwE)@3#$bG1lc1XBVm3%^@Tmn zpaQZ?QwjB1^b9XTsDD^}y=Ml7m%#E`QwZ}!4@*sg{nF|yw1ElG5Mbc~3gMdQ(UEXp zyQsWyuNfqX0+z|@ATLXv;|Td{s;}lu6QB*i@?28}y(EuS8uV#*l^4!1gW|x#d6mI0 zg2!P8!v1FJE6WVDyTCF*6?jL>9XH$w{7o;F7j`oN>I^JTHAPUJ#m-kr3I02)ueZ&> zJs4Lwry`glb_8Z7{CEDKyoP6)0L=%MF^b@<)Dcpc;sf?mUbxE)vIEOQHIRjc&hXL{ zFVO9a@@mR30XmH+iiemaPIX1*v6tX0 zuyAMD_q(*Q-;v@a&Qe`?fV-3@8Cj1=KCf6|vWR#aqmgsJi}QO3OO(xwlEwc$kRdF@CMO zusu6k%DUqZeP)aeb&+}2GfCr-FU4ofovgZE;zUbXXZ)ehjI_06UO^T#?(5ABP}5_q_B!+INQMp^`4pEnsYSpm>#A6&JQoX6{jUd{h~QGy0mO ztHkobVd6ra;Nfl9tR%C}2m#hc6&zq27K;VRP27B5xYPfqbXtuv@(iFC`!Lgo1c6z{VM zD*0%TZ8e38eJ&LL({>(|aB110U{niVfjgx8>Wq#Z!X4!IV_(qvF^icjjeiAjDqNVSsE z#QU5SzqBhR;nGs=17YHJ3W{gybr+LNmTH}3i9;R~@3bc-xg*tvixP`X6#vxc0VLt% zGHsqDF+ijEsGnhy_A>1UIU=GV#ZUEp3`w@jw8LUVKrxE1`UNEUZjxv>rHH2)DBi07 zGep9rwr4`&xLIZzofB3LqIT%hrkx?*3%XW{kxn{w7JbMRJI1LMH7DJi@gl z*?z&{SvfLlv&7?(-IDCI+;Ali8MbrrNOVFuc3o`vJ(x_}Yzc6LyU4L8Qo|~5GH(~+ zk&ALHMrasok%5~t365|E8D?jZF{uNYxl8cKC>fSsWO!AB4Bb4*aOAlr!E#Crw~Lao z`z;*dq7tmAz;HMdnY#sCaActbD@Dm@BV1X2l@=DB zRw2`O0~}c_zuuJJq|@Vq_RsLnA#TR}BGSR|px$2f@fm$yG}}m}V#QxHcFu`^c>- z(m{M%GLfxgScF^4tun&Fy)tAb*9RlJ2N})HvB+1G%(BFSSzcr|+b%&7E-$kvRwjsSM#gg+ zEHYneg^L8|3Xu71{|kz|)@0T_iD0=Ona~}e2xpgBSLK1Y&SXYAZbFfPGV6>uaJ?j% z(p{m*S($ZM8u%fIjA^gCNW`2#V(pOylKNR>Q1?P2+(cq+6b0^WM3G%9m6!2>%GO+`m1CiVE zs*()g9&0Bvdl(Sm6w<1I2=J^nnc9Ibfk;Pb6(j*1%tq$+XdtpjT3HGBZ;FEq?)0y5 zNSL#%qWkDCq9K{x6L1JukyfX{-|^gJc!x&gka^N-C-$4;O~&^W9P(0=RjZI+WOFjW zGseOY&MK>>1HZEc$OxZ_Lk7sI&vD;kUoyk9#KVx&vZ^8Ki}{31*hzbw`^XphjRSSx z-7=Xkng>H-yd+go(092gnXSs)L#}$^*Cr&|d1o41Ih`nk8%ZiZ%=e8y8LJsv9e?}V zIbG26%L!c3dKj`sQqd#8_p%8YsS|fU_mM9uLLkhW&((V{C#lAQy&L7o2vz1B zbj6Y%>SB<;bPty7H&Dn;N!1?ftqdg7bIkT9-}P>87Ytl}$9#V1GZezUa;g&2i)=#% zr?SC;mp}VMZ4?Z~)yt-F6A*zy{*Y5yfZoMCWM+=p?&Le)$>{{Ep)XHPV}&N8klk|1 zibZ*rF(xuCmDvYe_RRMyqTuW6mZIe_0)cnXaD2 z2>lL1UT9LPHNd-`iQ+dpWNSEj`=c1P!M~(2(g1{T7AaK}-dU$OQoKb|mii;N+>d74 zeJ>-nt01JGlyby_yAKs8UZOEm-Qk<=MzO7rE#iZEfRK|?icVp>y@@Ekp&>)T zuug2-kPp9b3=-ohrIsVRh^`vNBQ&I|Ie6X8aMqV<8XGhVgKz^W)eqR+%uewHO_}Qq zS$i#%b?Fs68njN|)mr9h3Q7uL^57{JQi zXvBugPR68Tq|`P<_o4(LzC*V9!#CUrWxZWLUM4GZ?f%RDh+<@VT}n*?bPL=F?A;5t znzZY24D0mABuQVn=Ysv=3{aEFsFrZn~>bLM1<^G@`}BdoC{>}j31wfmE*&KD^A2PaIGMt2H?1_O$gPs z)K&Yfxb&Tcb?CD*=5D@QjVAq<9(~R5b(V}O591E_5}+H>)flwqS_uC_{h5sNiou-khuh%n4DB1_%PNg9rPh7s&vdh8ztM)!J>Pr9@-qS!B?q1}NIVV2z zuicN!s1Nbm$xMXb-X)q%-1mxq1t0App@LlC?Mq9Ipxc(O@u&@#lKI#1^(7RY!*0ub z2)8rW{$lxw7)J9eWz<6C_Mik|wqLnV=k0sWnEs;dsmlGIiOttq zqN_0NWEHyF%c4HtTSl!g=CfpEeRx4U5vIj6``=LV9T#| zJYvDHv4rx81!&>z2!j2}eY)sqG)sr8B@}JNXHRkx{D##Ty!MilMZ}K=3Dp~(ZFDE( zwH0VFY5PN#6!S@_Y;J58*-0bR^)1_d;gM(-7l%nGIsna{=OxGut37DVMJLOQ=VerH zWVX$d(AJi}`Ip-su;gg=mQa~ouq?WVMo8;hrt5;kQ7k_;l2G(3Dtl9iU^YXofvYbh zvk}GPH5^?qTSRT4_MA@D5KgUF~16gq*nHNL)V;7VqtToj7k@a!Q_dS1UHx z?7EW3B58IRMc-ks4-GUzNL$8|b=wb~x%StKFIYJ3E~P3XFYA0S!bjhl6OSaZn7Um` zS(jm#b!!g7#|+~mSX2!#$SCTMy0+&ee6$9~u(X;}N`)psu0y#AAZ_zmUhOHRXbs?c zTUR55oW=s{PB~Q-ZpBP>BZ$nAAhCQkNGasx(-NqdsavPVwQAe?nJ72#xVmRY|UOTrS~#DaqNy0`O>u(C z0I8*~q{^0ru(o;=SiTlo_DL#Qfv-Z_X@r)Sg_iF|NmUeH-ONmQxlU-|0+Nchf~)C{ z1el$rmI1P=3bguEnIJQ})N(*pS$_bl14#%puLvza43dhPz^c%m8lmPqp@p-^D(e)e z+LVHD^L?qMm8_z=km_C;LQdP?9rwJ^bjNA>=prKjVsCZ^#JKn zK0?|;$(!pW`7B;P*=M;|H^+J?LoO=dfhgtwo8M%xn--hM8a z1lA%sU*3(2f?I0@xL$vWC1+BQtp5fUol8rY+ej#}j53n6&lHP^q6ZrZbgf@Sl6R#^ z=Ffyg5AzZ3mUM|DA-9$%B&nYz9uUoTCgfcrhlJkVHnCmFq#8;8xp3%xMZ(_9QSwLV zo$Zs`mrACQD$JGyhPL_;`VJF3!tU;z@<-|98mY!>Q0PZXjo{b!j=&Ldf7jFwWm9OR zC=Vi`vuOx{TS^)c4|Y%MST3c3l&0+{4vHRRBnY++OBj(4_e}3xK9zwKsBIa;;79og zh0D4ni>OC?XLhNO+Ca)Q(-wxo^IZsszZNN?AMc;lwPIQ$Db|mkF$(@zjgUArT$G4; zdSG_9%IS=xV$&^U7`!_%VR083BIen_Io+$IH<7x1|0bj0&_6T+W7{#=!TkKt+@4i4 zm`L$@ed%Hpd?h2{@w+aW!SeFR{9e^EI*<}Be2P)9d9;ZTd4aHCeRF(K-NUrNy@w0T}HtX-8I7IxpKg_s~bi) zFXT;X+-I7bQShy7gwE-o;J+U?HjisnG$E<<66Y8NTPHgbK7RuILT_)I*tSFxja0jB zKmwy+ztV)z)${#xQuj7h!Y>C(w(0uzOm^vMDrD`E6e@3Jz*Y zFg*+5ML*s*vrC2422%bZ>lp^0N<}yw_z2m>JUck2d*yUS0>N4j7zIc5HxN>{!E}B{ z=l80X(SeZAcQ(V|2YCpqt$kQ7;?9;aP4l`D9=>ylVeo8cLhEA5AnxPo#eJ%#H4rA+ zhbA!!_OC>MJsrKpJl;LIO%ZQG#ykfZ25<8r#7_4Rwgp{UHK=wL2SP`yV-%y{AFVXP z?3Q@V`tsn+j-``m1d(AsF$_MNhEUu36{v;Y*f63|Zf63@22U6TM-Mg} zF$(stMDX1Uj72}(HK}!BF9OeEXBY-=_aXeYpGRRq7gr9bmB~Z^YU`iCC^)#aMhIR5 ze|&yL9+ZOgD zJheVgVif!+9|8G9s1@^Q=lEuMT?kYIH!=*K>q1B#@Bm=E-ZrRQ;;ePA30>r!%_AFR zcO+yjew9(MeSse&_Gaq9R{JIoDdwsMJZ}{bfZWU5$ z6f1X#LGabp18b%?5&)OJ&mefECxQD8s8DeI2ExcWtUB+0Ao5nH*{!)kF zy-+evcx78*^i8@guUy9PoohW`>kUP z9NIx6girSXBxETJ&}uyueskU6n&}LL$yrY@2ELeqK)wY?xI=K5E=Ctc-q|>;b_OE> zbD4(>fun~Q3FWQ57~y6XXo=2=y0>{mz05{J=N54cfgk20pf8jJ5w7PURg_+dd9Za< zgRCY(Xlp2A;91TD^`wSyKZXJD)yxF@ez)<#GA^i}O?kO%blr3sVR$zu<6rYA69Ioie1MI) zaCvVV!mcdsR>G5T+#1gS_(>tc{w?_6RxYSRocHd)#73Eogymk}G60_MO5mR-9v&=H zxX=#9T~W7I_bHc<06pC<#=riR3I9jK1MJ2HCHWW^eLOO)MNS6-_0W@yf4BHhJV2it z@ZjHUP(^E;5`AY~|B6Wn*)v~Y_#4!W;s@4)2kn_`(3H61^RekIb2}2c=eWl3_joFb zFSrRE;5a@^Bcfwl%)RvkD<>t0&wq!}Z*T{V;tl4CMF*epL6q}a>{@zVvejzu((y6tB?xFK~b>IUyp)l4H+@ z|15zgXSL1eM0j7}6@%Y{DJi~TZFCS|=6_8&+b*~g`LCECZW>Z8xki{@J({s^P-~6i zA8rH(*ovP&b;#Fo>CKq`@8gwi)7#~DCg86Z&)9cga*B_bJsKQ5%+#Mc7wWol|FN@I zZa+A+dTjd&nVbp!nz{nkRG_=$tS!Cl+`W9H!}PKLglvyiFibrBnIb#vxZHzVH{ zZ7ebutAK+Q#>^KCeOG5Dqj4oRi1IgMtWk`7FI6MMF=GTY;C4ogHHCq1^Z+k19(!Vg z-6o7}J_FzN*~oyjpMVCgZ03u71>@dJwaAPt>p})R#CY-Az_9nPHg+;47eIqcmf_;N zg;DQ~_I5HSgP$S;e%EXX*vp{zc4r3}mF=Lx45KCJFk{|(-Mz@LwDuu`2R4Hx^b|v0 z*F%r6 zje!9-GE?$CV8}a|49=~FRIDTN%+szE23T{+z#u%}!X4D%K zV2IFCRKP_H5o-y<-o0dsW_XDTCYT}CJjT7_jS>1372L2)5Niqp-w(_Y+i_69^s^hp-!M+x#7{) z4SpJ$CAK4&;I)s@(ag! z|3)V2bVR_PnivHhGXfrBq@;U@2(}p*Iqoq8E@h-pOGFSKU|a-WWC;A&GE=O*fPkBr z7C!qK1Fs@OwQv$3_|~wnZDbJK&QzhV0Ks#+Suu-Ia3*6V;2s{}%0|TiM!{#vTx|jf zrkND283s=?Sgaj*;Hp7UBM}GHF<2;n0vzB%#ze{241?o+O%@uD2L_uGIUh0(-a|&K z-z_+B$dCxW#6WnE*+LEBfGf<1@IAmtxRl{Szn}r`WJ1_}U?lw1GF|e-p@B^%#2kjg zYsh#Vg$81L&4+=Eg*%upKG(qjH!vRBFc{8izR(9~V6o{?D+vr;AQSdSF!0D`Hk68F zFg(|Y$sUabxSY|D>k*^jkBt}_2nN118A2{I8g_@8F^=9W?IcJv*Px-2P9(6_M0h8PA#oFvhGrmv`vyYp#|(+xp+-%*2l_<#nHiH0sx8iK z9E4tBOni>a+Gcvh+bz8cIuH?6{i?J&mkxur@F-&S2tXd9D6JPmHbPNk45Bb>$8<28J^*K2JvOXKL@m zw%!Fy^mFf0R{W7E(8!I9=9@Y8)5`9_l2++7`uQ`MmDLPGplB2$<7Q?Kz2{bN5$7h= z@S)hcOj#W_143>vG!8R#Xpv%D_w|6Hjue|yN2#jhbS8jfFGJ%CWaVqi=+&nrHxYW^mlh+_Cm4u+UB81)noi)dBtQ z{X}#WX6{gtB=z;B;52clqDt`5{jxt{aD0JG-mlcx1+35nMa5M#KfhCqj^`Ua{&$qu zb-X0_uBb-pebzdL$4!kMYNWj0;Dtsisy%w&P=?3JL1qtaR$V#Fcm}GeaILR_8y_7Y z!#7u~;u_54g$Ai9&ZzMfiDY~{-1MO#iVJ%(n*s6@%lM%xitA%;oZl&_dD`AgM#wA40A8uK8o8m)N@}mJ*O4J|D-$SVgwn!1 zZX92!sE3+fg#?J?3N?Y~Q?+#pH`G=|agdgm?J+~-qhtizjw!8Y*fBj-QDt;IzmtrS z$CyF%j!R|v*`cN?s;-W=oI&z?h7iqBS;J%4G2B;Btu?$(43fibrcmG`g@t|Cp?V6c zw|-Y54kfK6W4NWtx{)94rg|Es-KD$ADESj}$l9&2KIMmMtEbtzowbfpvOA+WLfO<(GD>2ESl ze%~mfy6WmMhN!V>>Z{dRKQT^@u$x7+QCVHik@1mYYNgfnW1zf|4C8E3s_F@jsD)yx zq0?1JLQ0)YBkHfJUSi4gOf41D=tA!?QqF1|InFAoS6QMqYAH;gvu$Ife3s1P3T`#^ zCQlA8Rnk*SpBu_hd5VEVi`CTIJkcL2X)9^-<&v>dH4`c1xsrN^DaSVoX^b|P?m9!| zNG}tKIx4CAnW8QVsh&2sjIr`2GLo%dRn*B`IlWg$8Fabk43@i@Nt8cML2bnq^-xB) zNR!VI0hSU%3?&+>o`%xda{i!-HfVCT-x)0bK&GA*$yo;k&h)@p5cB^NEHkAM5%IWFsf3o=E`Lg#q(wGN2vq zDW07<$Yv%ARXo}7E&FT4RL6v(B36ZCJy?)z=`^)-gW%oT#(?>e-H4*O%I4+B)MQ(e zhAW*u`1T1S=ILZcd%RULVQb46$mY&gIp(DBmgOa4a^*9msHS@HTh-iyY;p2Pg>#7D z{U=7uKaerKRjGX4-Z`U2wtBw8X$EgC88VkMr;-J!lCUEK^Bc⪻JiIA+G3_@i}BV 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Ȋ`XGh@NW^=E#$Rm{>0%_ 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�_?a5^~^QVyy1q}ycEYep+VjMtj3**H z&F`)dzWr4opl&pXZD4;6mQPG>NV`vpGi^4r#>!S&l8@M&NZ{s6dLJxOZ1S{h92sqk zTJN2ftCWyUw0TU1xUhVv7vLjUulCUl&rz}-soS&W442%$B%n64+wmgMZSy6tD}8d* z2nXrzSvn{%rg{zu>!GYUK9Rz-&t;1d09Fy89*FfYdNG;GQsDeePRdj-niUBL> zhd)%-1XvmLr#Fi;(;aG~zT_n3+iyxIWwLp~r>XrSp3hTA;Cm}huQbz+37tOb zEY0hIvCtG1B=aRRsbw&G!l^3 z4g-+O2uy_=MBckZHF2JBvTh=`R2;M#VQ&y@&-R99rYSnahRI1}F>Jj8)NW78%bW5x zVRD{GCREFiHi2kcaDH|^juUjaV*I9R)@3-Ge2@8x-A!pr5R0i0?TjMN1aLSBedq@3 zw<$K8%wsk=Qi@NB_TCBVX1(%bFvuHcEAJrkRJ``#KW(O>dLO0`9x5k7|Mpypjs(a4 zq!*f&k&<>12@4d9_6>J8qc7^jW9q6%v)voeEZcJruw-mN16pejZ^b3*1cyNAwamj`%se<<-fImEUFDdn_&nkXjDaRM)w?=iq`1ar$%a2m z+02b)$g|JQ3i6X^p_Xm9cB{nY3QhPSWF>k7Y{X_H z_LCS1YmX{!hx+PT9~9*O2X{b-zqUZI>dTyX2rg)^Y=6(NN~+211P$j_@so&o-NT5P zh_s*=RXlNW%+5ROj?l?dHft^)skb-J0cd0wnSK*@cl50rbg#W;%^NeD=+`jSZ&tWD zef?AZa7xRe?^&}(%;akzY7Y(Z>tR9F0000M6av@5nhgUHB1o?)W)T9Hra|Y!6=z=z zecSqlVxq~>Y>m|-3Pk7MGSr7QxDbAt`AnCS<2V!pZm_kiCJ+C4XFK?$p~R6m+*xD8 z;UV$nB6~T3CHO&_AwyIkP&BZdoDbFTr{GdC0{1`jYVKNwJSvdz^*=fPiZ0$u*4;rI zCc|exsT<9nr!l_jmJlLrzmv<*^i+YxnYOCw8r(R4YhRj3ij4f23*VqotY#5u`Pyqz z$7%;{m~j9i^#ptgBvL?IM~b{-K9?q0lkaj%uV3!%p$A-m0TF_w1qD$#B&DVHl4UQ$3> z|BE_WV`L*Qr^y$D0trjaSp+PqPLKpY!M>;_c*aD(qIJ+ZpLtgOPoi+tf2@+x3W8!I zPregGkwJGDr{XYC?gJ9t=-;ZR9NWhEH4%n7W4*;UGW;Pt)+p#y*B?#IXYI1A?og<* zNFe3w5p3fB3*SONapuxp^F!lS_jRvo{+Y7R)^{#juGp9`KJDevp%=hCys4eIG!W8&V6pwyut(krHc-l=EyE_kz5CUa#kX%gNP zj~9o)03i{^sXO9!K&*OQ_okHe2?-Sf%JM{Qw+UUK(yw}GR^h^A%K>u1kD5OL$QQMr zL@ELJ=-3F=RMhzpD1W+RzmL8GzXFJ!UK~Wwcog6)4jAA|p_|6M#v`(bP`W5y>Q+!R zYkNdk_c#gP)#xWs*)E%yyIG@DQ|Ph@TgpVr2S*}hzdy}aOon#wY_LcUFB@!ksV!>kw-RECl_@A> zjUi#BtP#OIaUEp1oKh3__&1OpOkD%bI*hk@=^z{)VJA_y?vyf%0f%|IRR+jH({p*W zE(#CUy@X#-#P$Q7!}ms%$~CQDombJ$*H$5!l+Dt~9x8Hw(6RgHE~~;p;~47>ofpK3 zebT|oY_;z$>U-8~(X%-k$Qpyge0tb=G=FrdlOO;94AQeGterMUG`nAu!CE409ys>g zqGtOF1WHjd6geCO0er04$zTS(q8u2D+=j1H*b08>ybW{YD!6z{U?YPM!DzGxvQQi} z_Areoo>b5V#@dtT_7cURgGH9$SUoi1=2)%OgW3_`dpVREg28VVA`M=PfXM|pSKh#s zJURkk+i*cK+$*dv$Xk0uybGFpH}yPd6d!Oa_E5?aARj6CaRUGQEQD3WOFISO7G^*Lz{6k4RksIG(&o z0IzDU)2xy&v%y3!u|liQvjgZ~sGFdYNd*1@Q*cEYiEq8qH?OE`W7z@9e|ad${LX8@ z9>dlwy=m!z`86Rt9{|cr#@&TPd@3RDc~+D5QNZr8t+2kn3o=eA-bGoN;d3JVL<`3O zQqy^p{_=bAIz$5zk!IKHi)^04wf6^o8SllhRP0Mo7H#rw;pPM~rsC!7G)$+@+vCXr ztxEeB@m)V=k4Tv*ZbyS>aIMwmpH=ca8~t=m4?M$7_v@FtJ)U|?TT`gvBEc?-e)D!@ zpeVt3@)Sn>3BNe+_SiNKMG0xDe&{=vf(64@E8Ri@zImX+!YSR@pT7W*oAYP~x>;FA zIPi(A%I$|X*})&9nAdjIfh_>v&|AL;?7n^JV!>O{S7Fe~WuJfcx_6^us5Ge2tuL$b zS{HzRJv5`h(trQ}HzkarG}W$sjJ)PNWuCCf^SW61;-EK_ULxwLyPirN+J+FQ=~5?g z_=nB67q=^(0B1=5h_;aYv49aAXXExh+)`WKQ8quIjR|0cv+zXhvhSG^jQcSYx@zi1 zlf%jG6!)2Q2jui3(!CEa<@rwUX8kXIRtMh2N@EkNkjw_Kc$R1ypFx;nhB)RQBrEnf zd;qH&LpH@0-JNl)OXub3F3#ku#gc?dKt-QL{k`lGL|fE4oF3x4cGtsyykMqEiPH9Gue2GCDlaA1*fbE>&&qxmbd!I-W`Ke zdhwFi4TP56OP@c0jkiX zbGAO3Zdw8+TUkU7(OAu{RKgFf9F$a3+--w`^29fZSMZFnk%tCP5H#=ln|R;DR~PA! zR}SFI{QYslQ!e}fX;7NXfn7^vB9b!)OV0;P6$4`mWdQtR*g&fc|6Z4*1L8WkW^)7$ zuhE2jCCGMIYMh-KgIvy6Eje||YALiF6|PATNAY7El2-@7A4;orvOK}c*DpL=?IavP z00DI6?G3Y6Hu->Vg=vZpVaG>3CL~_ zl+>7CEa3;;3)3Lc=4Ih^_Wf>}P{Le+#2HBOu^}Qhi>1TmM0(3;vFMmPE)8rAk26!J+Se)srz3ZGRGF zCpTbxf-%oVS(uHs+CLDHF-|3bPq`_(r%hlVW(nrdT6L}_3PMqm_P$V}>Q=~Cns5~)Nd{I*ppB$a> zUN%gp6v;nH}zVCCOFb<6ygGMFsal9$?SMILRmm(r8>aF8C)o?@~wDfz>52!asrfGy&A!7Mp9%|2d{X1kt4_MU6 z%Qjc<u#}p-mlI*O$t(?8rK}%Qk|%Ko=bkCe0YGG= z>S@7?P}Y!pEvDdIO$Sk0%+&$NCv23Ol8aQ%PSs!bT0JqjXOZU8Bq_#;*KsdR>pG*~ zwrt^qt$t}sSMl^c5O*xG0Q<=13$z;1lOn3m-1TVeZ-0glBWF zSuEglXm?G>-Ih|_lhibqc}*G8$-(MzeEv%!KMAf!oRrlDUh)wicmIhC38EfzBF~pM z$iUG7PTbV*aI;P+_&Soly?_o@-*=~+$fqLhWUPYo1EdxX8qE?}0OnlqESG$PRfkaD zU+XEwWUZ?al^N0`N0l&Uyw0sfCY+j*sGTc}+3X<+Y{9zU*=TQtZv>nuSB;~-ZL~(=1x^4ixHtDN%O-lwbBn+r4vwlX z{hQp|3xlmC>)nhr4$Gvba1^djgjbEe9T7Aq_XD6KJRjraM!CB`pa3jfy`kVVcV-bJ zmgz8S(CGRh7sLaL6#DCZ9e~|+0sFnH1CY)kg}7o4l30vN1Fa~JAyx;Unorbux2Ubg zP@_{Wp_*j3+f{Ki;_L$-VuJ!1r_o+om z0MaE?Jrf8*1aV9b-O+;)T~xorjw}{4=AC0!!#~(TDA`y1IOh93+*Sq*V;En!IhlRZ zm3H-FE+n%zE@n1s$Qzni0=N0~)*-Jh)zYJtTdbyXcaLCw7%rvk#&v4EF?7{n((;~9 z@sXSj2B7kV#!7C%KW{cowaMGNnijUT+sAGfh~s>I2hKo+Th-D;rFpS2Q90$yXN*p< zI0l-hX*I$@3mNzXOe)#gDD6)~=yzO<`yad^ zfwoyWWSa_R7pe3jKmY&%rn^q!V9`ed-8(}Y_NMQd?j8v&RnyhI>ghUR&1 zT;a7W2X}%=#9d(xn!wXfuDJ;sbak;)_Ocvqn}f+^>kWIzO#P-`yw(1Y5tJTOMe^c0 z+HeepN_s(j5sgX|*mI|1lu{r(ftA6|h$haKfujQmSo@ih3T9v4KL_jArQ5G%|0r|N z`kcw{;#)SpU5LZz2HdQU?!wweCupPZ(jb%4sCtdR<7fBS<8_c|T)!%l+ zj{gSHDuzr3pJ_|B~6NzZ)BMu=KmqW8rmds9rr_t z25_76q_T$9T#im~%qkzqSvqdtgW#Q3G1gkK zCVyX^C1JRO1Bo-XsZ#dXZH{c!4~NyKfG*bAl6nlW$8xfL%rJm?GK*>=xzXnxXPyJ6`zTLZ4N4@F87 z?SdUZPKY~km!9diZuWq*2s301#xtuk7D$DVqL;ZIGUhwh(rV39{R!0pbt^2**;KA( z+lk~+LCabTx!?u?=PRrZwzU`cUPyIw(}5zrpyUoG!;^4bX}$%*?u=5{pFAP97fn^} zCp*A`vKFX*3bVPPi0{orkguK;DP+2_U5#kT0Slu9JaBEnI{B07ke4>*B=HmeHUPNd*i2##x&g4Zo2N;M=oO*e5-LQ~>x9`z zaMzj8fO(hm-@E7NW8=foC)EIz|GZAh)77@fD?;Cz5cDOD#C2ty1W)2>+?{o6nfn(& zjdJq2Py#Af!(vJz9=yTBShe~zX=c46TAVFrsT|AJC1=|ra|Ws|xC1wLk<^{97-!po z-{!IXgLdKKvzAQr5zC~;`#oS+(Q6|CME2JMx|9V1u-`TUqNZuMGBvD)BFv7Rr&5c4 za2dJ8oFQFq&9zdikDn70TPfh?L%wYKSF~t5{DNozAwQKA9@-0DFNn-`{5xy)= zg9q6K6{^c?5h0DhzyJUUJ?&H}_K=e}ICr9Zv1%XT*YSEfiFQHhad3h3{O!-HQ2=EC zAgUf7#1g8r=#s^Nw=u{=BW{I}Fn$%!{3`{KRzS249!oLutfKXxgtT&5MEkp*T7Yj) z%Gb9Xz(O{l&Rz2;=pt3mm#wsJ121={g09pweT4uZ$rwMke#NefUh9H^={?#C*l9CrBus)OfSm)ZCKk z=_yeHy1=TNoNgOelr&=*``+D;&tV&XG|bCRA3QClIxHFwH<-$X{t*=rICT^Cm={cQ z=UYI~xAJ0^-!v{wtBSYs50oT(71)#H87cw!$UC->$}TI@%hS*i%_8G=6u7JEW_-`svveF!J~XTZMn zc6>q(oytSj(Pb55IV1&ekFD+>3uq)`=P?~ zo?+l~RNsclbFSc_MPRcZtPK8`&a1aS3mCCij!>$2(jwq?2C*o!-ZXqAq24UJ0}0x?c1*>JtvqqkHej{+{Q_?hfPXbo)#h`~`T^dt zb9{I<^h@0cCsDl~`+P(vJ;z!s0Xd!a3tS)^Qsee3m5TLgghVKAroFq3F&P^-ADaf`a{>SxF8$u^#{x671TCY1N!5CpJ{ddrsJGa>AamZ&BLPIG9;mO&^PJ$d_^o~l ziy)}_rK#MGTOXPx?Xa-hKmY&2lYumN>TvM&3!P(2hdvP zB2Ipc?;vgH2TBW5ul$)9^FODZI+;Ns zm3lQ6T9d2yy4Jw?{re9o?yErF&U|92ed2UYcuY#^cgo9pCI7RnJGGoCsOqCav5a@9 z&zS*CK)efOyQ6N%-D0a9BV{gYKoATFm@4JD(!=#UJIA~13~+IoNDd_pg$Mu%v8&yXT-xo$ zEW`NIc4tP`wjTnYeJzsuEes0C08!yUl15v|+ETA;=v{t`0nS{&Rvs_#Q z%iqUO&M#C-__W;*_hlm3Nk;RRDxKNE?ob)Y-jA-L+OU*@bInde6XLwLML9VBYzQ(Q zxVxKsqV$f1b&*uL#^xr4wCD-lT3)uWZuR5+n2?r(81q?;6+Yb{dH2@7h2>V}7fj9S zuzZ#W>qD0GN1TtJWbkiKCaq0003n z3rI66%*X)c@boO6vso#iyrQV)v%k)g@_Ar?h8a_+!IdgEw2_|;WHODXS4=|NwA$9Wx0yb-%6HY zG&KgyI-FuC9HP6J>C|L)^lr0{<7L>^6!JRwuSn5KD^q7ej7w0f+EXPtNU5V+zy*o! zqoBJgtbd_=`uo{K^O4ah@2x)F{^@eOMye2q$I~Zt%K#5Ft~t>jp2j03?t)N-5Y%%Y zDr=!Um^4_U%!Yns!axLB&Ld!^fJEPumvHnJdGjiaMQnI^SI3kwgCu~Is%L1g44SJ^}s_U`qc!EI#20&r~*V}p{?m>sTkDah$kfu z3HOGM_K%fYJpFW9xSMir{Hzy%AOMiFht#^RgKMsR7&>cqI#%@{a=M40B1d$!` zRm(zHVA#DRMn=P5rh*wiKcnjMm)0jaHlQA7z-cZuO(y$%E^Uu5HSJ=jLsD&(J*1Bj zpmJxle}?*za6jQ;5{j|5Frjj!xgh8r&KR3yj@riK&JoifQdg7JD>2{sIEo!r@Yt(K z`vt*mu))owtdZ-;p&evQt=`E@(H5ddU+a^o4Q&lMh&`T#+nBtfnO2vQi9URILt4;U z-a>K*=l(mAe`?FaD|iR&hp>d=)LuN-%vgLae`!H1qCYo;fwJ{?o9DsNS*t+dFU+fODJ8gC`a}@jPldK~o;{H3aFebWKFx zfbD4Z+3BqkE=cx2@FnMtd2 z60)*r(h)j;ucjJ0lA|GB2Kj>wxCI57O@agYrq(HS#;JB5WoyedPyoZ~`LdpneV`Z^ z{vGo6l-PyjMVmNaBS#{AOaSzxMR^7^bV#5wUVLr}Ks8;`Jb%SWCuh=UWnCt_z+AoF zFIwOORu`A!y@lE&K@YY&g)e%F}DQzyNZ?>%w2`EzF@<_ zKC0Mx2v?;vO}bR>%EK9xWn8wv0VgCYF1i3v$PKH)z zCYh`nu07ptl_ScgO~^oo)B#bzO9!>_&b-`|nY=bW+LqY*r9eG6r4SRGb;cIQ(ts

    Ph|Qmip1lfq+;q2s9p99FWXJ+HnP*@tchW&Tyz#hV}TAR5W><~pt3e1KIz zEHTg*ui0lK^3zO)BufGL*=J&9d~pfUTp#00R({rAuUVqbwgF|Q+_J49Ap(F>b`D^bHHB{G!w zqd0dW3qI!%ptp6$Y)^Te>j1Z#9T9_vlt@T>I%7!V`l9H*VtnHsTq#f79+x;?PcHVB zb|UcB%yFE(6^<}-@DL(`Mo@M+C4ZG-wWeO-yNv~pfh2K2-YnbDk#bHr=5;7FXu3NO zsD4MkEj}%tdt&-ICAwN@{2Ti>^Fl%3NFk~e;jNO3(GBgtE`gyuR--yf^ZABFb6b$3 zgs-=9StnYgQ$@+t$Gpu_FTMm>;bzI=R~FqL2;Q!~&~`CO_{OaDv(0rvrh>rhC2Qp0|TGGTmpf-ajS z!cg2%?Z_Ls$ze)_>7`Yo!g}(88>`b~6BVN?P1KW!qlm%hF36;|hGKqH}q^qgdc6988S<}5o9~6c_MiP{fVbYCMXYCzRygy*Z z;g;joJR(t+Kuy|FE#Kfj+4OC~wQd{*O{{ljFY7nf{CF*12hfIW0!POnq3htoMhxGV zQ{7ZQ?6`s?U$rC+lxkT$KVa5@012I?rV9mlrB&X^)6%Mf9zTW_kHU00-0Pnlw1%hh zEM&*zViE0s28c;4uL`ahPM!3SkOzZQwL$!IL#afyho&eBo0j_8!9jb5bz2$HOk<{D zT=bOznAcOP)qCv(=rWK^pLX=%<|*M6S#}TTervCklCG!{Rx|7Zw-nMKd>98915Bct z<=nT!0!m`F3W}%mQ%^53!1Gni3M_227dLC~b1Rc5L+#lS$u!zNShB?Bo`g-itU+$! z7eiaRUMm-I7Sy*aSNPm*dFs(n*I6fqpAqpazkYQvN{B}h zlpOR_p55uDCch(|$DfkBv1a?pZw2_N8~ezJ{lFypg^uj@;G%WDaO`!LDVATOsd~Kw)IEs14Z-0#>u;642lE17exDea=EKyPZuenTt2LgXhH2j-I&* z{TQpN;M`bCt57gU>pfai^N@X9vt|iB8}&}|bRH5*SXU}%=POrgL=(-NU;qF{@CtYA zeD#X82j-m$tsj3om)izinSVUTnyygmz&bnoHP{9xFZDb5TkjRQ32{KI z!?cjURym5-SyCqC*8P!K7J|%ygCcQXs_efYnLVg||F;?G6eGvahjw%S-^v&OO7N-- z(6DMm=XC6bf&Lc8TI<2SINFJRxoR9j3gZm z=c4d)jkeD%XAJ$J*|`sss2BSBzSTUmVm*S++YyAi92+~sAf%VLJGYLCrI}?!1Ku^? zHeC=@;9!}F+}g;Dm0000^6+?hJv}{}NgB#e%6SrQydsYktuj-|c{T{t7=sy!y zi1t`5M9I%8(a6T|Lq46|V}KU>zbJQK7PU=@@Q!PM(?phW71RJacr4}5(8a-=hIrYK(^+s*pmUz7AyW`s-NEIpZMw%CM015l7 z=f->s>d&SqCQa&j9PnOkt33>yQ&xa1aSoBNGW;g4 z{3fUx=l5yA`mbSKdV$>iC=KQv<2sh{Q5dOnRI6SMPD zS#mkR!rV{bB1Cs|aBWs{dr+nAXQ*acEiM0%fG?ABXCBE^s|vTqk^Hfk2j8ba@6+2G zWQxXwd|TAq^_9(Y+{to@Yuwt|hSvgnb%^noP#QQAd=S3PwGvGX0002lzZ_fmp8y^v zvQ-7DWZK+5tS8E1&|d?^)phrSDvOOLPC9npqf8UPPHNPxj6{h&;?YHCh~5%%3J#+( z8juJK8Gz^tj45rV5~v7+Uq4*p9tSL$FoHshKnwIydIx+GI+0gTFvEQK(aiu3Wbz{1 ze4YBAnb!(me^5J5@XjXpvmS)d+6u=?G}gIAdrSq4YVH{%{AhSVm=;|f z>4p=)9*f+#T<`lf|J0}a5Ijn89;^|sS2G}aR1qEvb?D84X?4^VieW6lmgWhk{GK|c zbs!@O;^pj$)1`#K!}TPG*j-4dRPmz>Os*9V8@35^#fJ0p+(@aWET#xb*v-NO7gh=+ zp+P`;wo>duQ4t!?Nmutm%phpzmZ>mZR!PyeHx3&C00000D23uwL7@R^X)3YJb%Jex zovo5+L=INsK|cT(9Zvqf|8CI=8V-mbSq!*xL}G$uzW&d*)D#XHa!>)=BDo~uNR zHr_~fwXeCdK^|Cf1s~eQ?q;m?_wXm76#Na90MS~VjV@YocY5@_&XU)`3Po<2HdV!M ztItIhb0XY;D9i~t5`((edkWZQ*V6^Z-*5|X9PH!!yZ|8`&A^5ML-BR!=CfFy0009# zkoTL)WE(qb7JH=+tE3%~@NtI}YkzlJmt0_l@s6@ZR zS`G6=d7#-sjZ6G4&oC4)h`}PGgCL_`*MAZQ1`|;RAU`2P#z@!^BuGqYD;b{BuwQs# z(uBMt_YC4y+xATr4T6k9POk2>_oXlZQiMcjHyk|OL6e=tGEd4CO(U}bNt)5pt7Q!8 zoN-wxlYm80Opx-00??f$F|qaXO&cG4JNGc;z#n6>wl-Y^SicK0`nej}&8gIie*?nx zKs^~y69-VaTS;i3G$h^c*V?Zz6U?qB+Q`^oEi`etw&jZftabps^y;^@B{*7^H+OIk zUx0b0-0b24W?_H;00xFbPCRDMl8MM`^XVXxsy!L%a0lrp*8AdYzYWo@9;Qz7`8+6j zaPh(S!|$cEIydhmb)dbBh9+X2!ue82(p7I-E5w#W2hV^xuwnAD_V}FnUQScvwf&8ao>W$IF0@TC7+cO=LzKFa&+$)#rnPoJR~WuQho zA3H1u552a7hOwn#t_0@~J}8zI`=C!R4J==!PAvtd2Z(Cy9hrkIx-edLwtGm1R&LXJ z@OZXfgE6r@unl!Uw-^Cht@A(t0019I2h5j}6)G*HY6WG&5JKS!k4Eah8at5#4%N^+ zz)!H`Mou(X0pdCFeE=1bAH(iA9dun{`2*yif!(4LJ@XtV)tohgWRxUIt4-z@8I04H znFS8Bo|6Q+NIgrfPHcNzI2T}8;i#I8ZK!?_1QY1~1ak3lUtLEdI+3ew4$Is-diAbS zW2FH%r`HlQqt0!=dW8Xr7N@mXfbAZpulXYqQ(l$m!wtKd)~~Cv2BtE|025J(6pV=N zm@ej!TwzFmsp>)Lfqh6LB(VcYoOprRM#qQ|6RixlAbw{46?uTRaHrDh2#OY~k^N=R zb%M0O7(zcoQ$~%gaWq`yJT2wlV69Cv-I~Y+DLQKo>k# zrIoMbYBxVfc-)O@p$t>hHTo5NauT8Hpke~qDJX^q$_7VbgeHJ+A{7cR(2hvX(2OTC zLzJ{LU?jR7fB*mj;8bUBd7@J+J;8TI;yUMN%i@vA7#3typlQp3DdHzV$fGwf0=*** zUC?4qfw*pw#5yEom_%~p%YB@Vi4VGkd+aapng}64H)od@meFY|95A~w>oAxhx>DGX!NWOK1gID?*uGxj)Ky8a~4??mk{=^p?uS6cqfAGZ-6%`vsYz3E~LJz-d+)KwVdI4WqvbY#?Ee-T+k{)G+QCvGiU07ozx z0cCPY?wKgXCb=r`{G}(v&lRFjR12mRVjkzQ4c-Ckl5$$mXRfnLUqmfQP9CKKcFMah zBxleb13*-9lLiZQ*SHjJQ1*8zxniE;W=(O@C0+e`m?q|M8K#gQO8WN_Fx=lA8rErR-f*T(Q3f5lQu3wjIjO35+m3 zLoTFX0003Fc|R|SV?fL;eCEK)n$?cT}iTQ&20N04Q-0o1g93dc2_z>rYAWTH&Aq1(ySqt##WD3w6 zVg@1=R~dD47h>riaa6A)H|COk<9@k5*q;0c>RC%iCGNcoE@m+C9x4R3JHX~-Z2#_r z0v8rCYq83vC~qv9ueU9Zw%9{*G7W%)RhPjLd%nVpi=+vSANT=Gn)^Ia04A&E|MS$k zLPj(a=9I2Y$3OAVh@jPN%=mTBIY%t_ksTH1UeNIl?_O0hB*%2bXlFD|51cPUFJgV} zqLifgnP+f^1KE(o1i`=>f58u3zr51pgM|!wEARr@*BjkCT_}WIrb>!g{}|H*7Mj_L zW*#_V2?t%>db@{+c@(Y$VeIczH*ZfHmfvMYpC|;>1G`s9>`ouZMDH)-$`5Wpyz6U< zQ;3tb=C1uljffA`VPY;TJncbQVTNYf>!zEon(J9qWVR@r9Z`;Fg8vGZV$3Rs`RLIdEe3N zrH?mn6Z(0Mx^hpOn{mtl000+2CubY$d2spM7UPKYp&knG%5S*Fy^}WS^P}Q$ZuS^- zqXlVZjzh*m*Rx$=0|Ws5q+k(;M(f9D)euQA=p_xBld+1ggxsQg2Won*l%3tK2gmiT z%wV`&&M$c%A+@I9HaLT16`ID#AtFL#G0GwPwgJQ!k3CNS`TZ}dYYy4?FdNeE?v;B1 zX~Lb7h{=@n{aY>tv%_6Od%|K;ppKrRa^aQaT#cx$`RYBCZ7sGuoY@f6AXpjTvIViQ zxk=;RmM)`LCThDQMRLHKD$oEjqMD_jnv7YKunFh&oNvu8seN{kx89JOp(sw zVRItcF;?7ahR3qq%u#G}VBLB;}gx#Mq}R zVOXa208K#mgUPl>BWUJ<9dn7jo23|;bkOIxct@HyxF0<5mYC^^$vAQ~Zrv^t94B44 z*EifU;1|4WMN(4#IZrTRG~TKIC(Gn7>~ zH`r9Q2Dar*?UaAx)q7kWTA%T$78!zNhe{FNOiTJhJpkgV5sD#S+EhY;-pHveSvAZg z3G&JW18*X2WV&fvoi<{RpnQ~r9X0c;c376yX4$ooF?E!dLWF~=Lo#4NzB_HBrM=bl zhfK7A5SuXkjbFDx^QP#FFl^MoIl4rH0xZfX$dAJ|!2xOrh*WZ?FH$Ot=dCN^+GSa= z4`9PxSvXqydN9U0E4T)z&{&h0ri3?!AUnhm`STkICGcDTff0Td!OE_@yjQT{f)bXA z1Z4KcL1M5@%eG{j(bB1FLLi|$Bmv!VEln1%vY-Ux;r`P?<=W3`9{P@|ENN>+0x{cR z09~+sMimKdi$@PFfCZ!p4K}3(%jPEgqtI#tQYGvvPS^CMAE8kX2;WX$D25QlCnbssB5&=z#?xEM_uZbXsl5};cQPf#L&Er>}R=FfUQ2a7jhm2VO@1M4UD?) z*1$y4K?~fa%NH12kWz#2jGih)-xr1&s3;&w8OWWOd=rz$pt4^rGtiVM9+5#AdHOs8 zac^@y3q#Dz+RZ~$ph+&m?hdB-ZNL!W8%Y?yYC!zbV4F?q8esx_pr$|J@V6%KY7o6G}o5xfEhCbXOWA0Pk+ zt6J>o6q4GTtO#GjN&Il0|NOWvP`DMT^;e^V}o2(deRnW%QLE#g#LkuVT&htz`O zJ=|HF8o0gHJ<-lC{XkQkGzsyEQwX)cJd6(3$9O)Q0jug~6tmV9Rh#zTAT!36QPsLl z*|2%yiWPZ)x|V|Z%6>nmN+h;@=H(w-8PJ{15k^bUx|eycvqF##8xSISt6LaEn<(o7 zS~L4q_0Ut<68PqJj7`v#F+;kr*G~x38>rd<000cwmaB-ccp0c{gO5iUjbn?431~&- z8w9HhCd6=1(mEFNH9D)-kypoXF=r-9G_@cn-oA0=Jzx+_b28nFuRh86?X|a-dG_m& z%n3}(YiZihM@9W%#uxqWi}6ro_m1f(r(&&$U9W(Vw|1RQpNdZ#v0cQf)JgQFh$fkc zdMu<4b*X$*F#5J6C@%y8CZ{odwI&u+e zQigy{P}#ScmJT+bxw(N#50ZtBWK+4~ELAUbSw`Y|LBpC+OAZ-dTo}EnVv=yVuvAty zQ_ZE)8^WC7*b&eDc&^?`gZER^5WoPKwY{ku44|W;MHYm2br}P&HsSCZ)>@CHt3gi> zWZ7XBoe}~D=4MxyKYN%jf}71;veI-14o&z3MVz(0I6L97uMSuo72T9s#;OMgLZrs& zw*UDof1|3U?DIXO@nEqCh)@6k009m@>T|1$*{x;dHm;LZ6Icp~R$f5{Qh&DVm}gOi3dabiFH8tcmXeFMG329Y*$!P@wv9}32lp;t)=V$g8&^ySd^h)hXIa5 z&{R78@KVm?s&Dn3SQow6Bt(Ex1$nvPSodOwAp}lP;lZ3tuhrDqzj5wr*%F67qaaUOi zb9QoRE_V=f&+(kY<$&FH{OD~3b`up^pqVSlDVX<-*&*P$vA;%u|v!Jk(JmN=_ zK?V}H{?mgr_!S&2h)<{_6HwqBVSiY%t%`cGTt+!cz5Fkt3s!01Ph4q(0=oiL}_Zxgt~fOH^y^a^hH5xxnuK29 z1Z#I#J7BW^7l&gzp@nd200019nNInC+dtlAR5huFpo$WPwuV*h5%55U>P#1d2kwDS z6dyfZvM3vxCOCYdXzoO}Dg(-sd3=7gW^DLRBrU)I!c3G|Kj$2L?dMG@_zS*(3g94l z=)Ehnq*raj6Ta|F_~#}vmewb^MHxS@NfZ%44x&h5{vGWpRJq!uy;CeUlH_EYgCL!R zjM1?%3g84(dtT>c-Ic@ST@BQ!ZSdlB-)%tQ;_CJ`@GK=E{18}K=q=DTHd^Zs-BqR{ z_gE!iU1x*VYHm{_$_GQ7ut#wzsVi#}^CL{!q>i%uV%Ub$V!sgF_`MK%XK=_Kic{J6 z#{ghYgV?|n{*|>0Qcb{uaE!nJCzMkm35$4Ureg`GKpT@=Qeco>88+aUu4&`UBMv!D z;AV9XJ(YGDBB=lcZ)LR&awtV%a;{c~6b!7Cyj2)_yka~=RPZWqNr$FwZOrGJ0=u%!Y z0&6(5K(P16{k}<4KpSfjDeMtNXsAaVvkNv#6UM}Fr=`6UubKn@S zfJfETpFF_xG5F;Y7$N8cXUd>DTO%}Mvqipt9FHq%P0g)F2lDnvrj!wYQO?IV*{w88 zN08slsWg#`RN+T6a+9m&>Gk7n`H(H${+*8RfIUM0)lnnMA z?$DSr{0O?&5!WZ{TqN}kJ){VyhzE@0bs}Nnv%daMl~RX;kdJWu*324;FDz{?+#n=H zbf`N*)-Lru7-9(Y;nw!Y-XQY9LtZ15DQr{%1y5P;wc2PUk{_~k@MpCm?h-<-@USKE z%`5V!!dY3CeQlZ39i5h=rMX(5r!6xXA^nId7MvS$G5^2ahiGik-=8fOs?Ho9Pe~FlFf?B;J z!42vIc?Iqn?q*5c#Z%_ieUSvKQQZ*m<M82lh=fhU zYhxy)y6eo~q>)CJnH0)0lctQ{PqQ|Eu`_%|wbmRR%?trSr<-rsZGz^;K6DT2<3@zu zos@Vc#|O;4mvJm%8`zGwL_!mH7Bq-ufns2zq}kP4Qpmx#BDCNq4z9^@WZs7pJRwS( zZBRT-1SymZC%3A~iw^FG;l&wOcd69i6WDhcM_AXhhcKjKYOu@1nw*F*M@T|zg!G3l zIu8O?etUz9-es?)5t=YA@6T@5ZT|uv=JUv)fb{XH1C4rOfG+2H0Czx$zXSbAh{sG) zDVTF}Qy5t|oQXH;mw$1GuR}HVu{YTzMk@02M+N-9=yr5+F=|G#bx}ENS<*)k@;mZE zfaCKmK2fyhREa+ZQ&y zb>(*RDXp;s)x!C`1vpGaHsIHnz}WrAq^UWe1C4dH+!Lw#g)C%zgHEh)60||P>RH)u zELsnt?F)2aX)8}ikGHKjhD|MxKQA=B8IiSxM=B+>)yGB-yE;mVK#EoTgi;WA@T`~+ zZdzQ7t2hqM~*^Tn7A}q z|8&iol zy+C)RQ(j6zh9_Z$ERRI6*q*i{0d!x-0vX0!iQN-XZCf%z8&s1nlaSL@nAE1K0AN6t zB8-0j^OdnJ#ZE8400U5V2O&3ohZ>Qr&##W%bb3n0ON6_?pVgawK>P0r>Gs5q$EmwN zB8zlg26bnm#M+Y%je5q9XY<2ahQY<40F$+Ok^l>wgpY$C7_|?o_xqCDUCLz)T7{#T z2Q6mZj?Q4az70-1`2&A$QCl)jt#&C=-5!dA+t0`iH&|bK;{kqG<$kn`gYZ`2GVye1(uUqZks*dA zk1wOl=^HGMEZrgMh+u^+SvqC=iJD2sBb>-;fDJnmiLMCd=k#oqb#6Poj$-0 zr;Spy6cd&Y_9uEj^rwydn>*Bj_A7DIlojMb6OuGy&x@ta!r99xfESwZ{2Y)lw8*M1AiwQ!s z9wwcko9|Eq^fW)527aI!Ore4;w)^wetFmB^qP|%}gVY!)Y$)GVHzn(>*iU>LA0yH* z001ZhaC}{`_)Dsg?t2~(SnSLH*8}=RV_F*;-_}3uMxNgl!eqp_fhNOm1~5vDwqR^r zrMevfo#$YuoN-7~NDR^D_as|s#M(56J?6tmSwQiC8o>xJ#n~FeB7FoyS&C?`pcab+ ziaxQfSbIi&6MJ%iO;e)hV~;wv9C>F6Ooq(1cmKHUXE9Jk;Mo7FYJlNP9L93>hd`@r zg`3*fU2J8z)=(Z`{h_b~5-#rS!QrN|d_Le0ZGEgSkK`Q0fVzE*hsV`y#57hjMdvO0 znPtA4&MZtZ#I7xWQGJ*A1VPVIWt#07Vv>5gXaB=_=op_DRIuQMZu11`$8u#irT_(n zB(fgN7IMd~g^fmwV!=d8c%~b~_rekQB%#!;M&A(#-huRc>YUcXcY`*EkJ>n^%ZE3Po9K-q&U(t zR;LODdW<8*hSsR1wdsT3q1I9hZk12$fPMI$s$7X3(V_T+8ubc0l(?M9Z}`CnZA#lj z&cciz7u2SVJRR$a^`FpAiqmk!sv%ndV)s$WyI(GeRvd{Z^6EQa?=HGaqi}TeoU&o( z)}%x7{(4vX+j2@_%SOF0hSb||G))1m;6PSF@$aZPMiDz>Hmr75`Zb}qfgIHnQJ=vA zrj?N5v+h){4{$wXp+M~c^DkxGNXQspQh8T7XEyG!RrR)ojAO@y)^C0LM12Zn2L7Hq?JY|#Y(qvBE#7yIoeX9?! z6=WCfQJ&1rZ(dWv%v#l01z-S&S&3MsjtMJ__RMG;GR*`!g&?%&jy-7fHrZYEML^K5 zPg0Q7Le8@)OFfWg#4B_BvAuUo-4@03$E=#J+;vkV-4&{PjV!+eV62`cKfoCefRfb7 zVcVcas12@izEW_6{y7tf-YGr7=FmaT76Z3=Ks@J06uwjx6zI*Vtg9E`Z`~wF?t^wv zXCX{h%)}J-Och|3A^WvA>(EDb0)%uEvQ^**=&u;bl23syK|m(9l^jf~r!J_KSr%lg z>N*1oW7vMVA+me*Y2=D`j@Ku~5fXYE5wx?6O>OO>mRA$Mrt07gBwpky3Qo>timtQLa(dy9D}CHgvl4q4*ibt~aFm%I_(d&oVr5)tktQ9=hNyEga@_^)=`P2f5pbW2O+>;~ZUA!E z8z`R@*;xykmP%S1hgNJmbTn|33Pg*WQ+fd@TIy`r_IxsRvqdaD!b94`P>=i?(!C_D zzGjdq$JChIEXg~P2~RZZKfFWMhwYP?1lWqmHB@$~7^1mzOeP-`TK4UnhR3S;+|a)m zCZ!VGbD^i$x#U;*)nLn8_;A$wHUIr8&@N(dwuOH9WI&&9DezI6&LtUT{omQejuK{^ zDt0DB6IXJfXkWN|*g2&%@Bp-zF^l=Zg?IG#mJGX2srB1p_Iv)4f*~LRxDH*)YQm~> z8n>gd(x*UQSEh+}Gb4n?8;Hoq+q^B4YQQM;3)1iooR!vNLK$2Q6B#>Jytg^rZNxfR z4-qvS`;@@6CM)=5%H^&eQqKDTGvpdJb{1ABX?5&%-id!rXLfTmk3Z(IKBTlV2ORN9 z*pzIU0%PvNLh4>XFcOiWzgmA@+*PI|( zS9g`D7b{#Mr#t*qv0OF7&~ z)FDX#?XL(AK%~g0^KZ8x0hf#Vs4k08e6jT&whb#)TmUB+~hb`sNFBi+v1qzlG3pLTVm9Ma#LV%VsQ)GB%-C%7vNMLlU(v4?l(y%0U#s~ z-FHoa&&Ocn#!*!hJUB6_@lc)Djk1cO*mkoyu12&TKGot#&jRd17>It~svbo$>KZzw zsDISD+JscsAN=|0f-BvffaEnY>KZzwsE0)bntbk?-rGL2m*kY%lwdraEF~ZA2hn_w ziTg{A&>kl6%jq6qr_1NvTj)8oSO-2MK_uh3n>DPnL?(Zt2QHH}rVr$0$2aNZO)Jeq zXIa8B0nlc|^|cjqp5jAVvz~_nC`~NY$H@Nh(%WwVGJlQ-1{^&Evc4N|V0Fu3R@D$nEmUkaPdHUZSPfCH6zuhe^HB#&HhwjK?v zl~y@PX44EAW8m>m0ALFPruu7y0&Aa*@ZDUY+lt-6WfN+dERPsvvXlP6 zD6S;#d=M*?DJ4Q!9`MVw4CA=>zGL3V<_gEsR{oZ1SU?=0d(vcX&}Pi4``#@3)$^36I`Y`EQ2zwEn8;@Lh@piH z@T1{9HJ08-l`Gi_sYF>(xQoqo!lA`B9LC;S-UYv<(UH-DmCSAIOMN>Sp6{FhH53mI zQ(e=q%8dDN_aVI!z7Dy7Zy`!-CPw?W&Bkr>Su|GQBa9D`6m~rF4$3d}@DH%8z#xf> zVqJDg{Tgydv2?zjN|>GkfQq~Z;1ZT3uO{fO?(+l-x*Fd7!C%C z$^kzCz4mGfq(|K~M+(r=^a;(9m?{W^>s$4DaX&h>^vVzA{(;~HFn^z~Te@cP*V(6o z?Y`}6WS##!8kXAgUW9Kw3V@gkjZy1ekJzIJCD^f?gJW2vn+@I4> z9v)8HP22${krwBG+|PACWL7NB4{0a5)|1HZ%t^*GE=5bl#FS5H{BmHma_e+vPYgOe zMF~5g^EwRH?A-RrG{)Ab^Oag!UcCq98m=fVtU z{j3HDAm0Pa;`LBjPJ|UKR(#kDYxVa`?qf@NH}n;8DJ|k~(7?yt@cZg`lx? z74Pf(a>C3uAx88mgw#N*etQ4`OlDNLC2WFRVu;Qd@bG`($auEF{&;FC&wzb^FN&}F z{W~JlZznZzQ&Q_KLmr@Hb7kdB%pRttXK3+hK5QZx(pmW9gD(R!Y|-6Skqz_&snkCCM<)5y?4#m{ z|8bdZjp7=|HxeL%li)0aZuBH(8VH99Z}~VtV6M<;%zxLY8<;3##YdG@FI^ohs@%xA z;BCY-R8#NWnR}!>1cF{cp(4`?g{_%i7UEw(%2m;;2OX57CH!pPRS%wlYjGalf7Avt z_+!kCC{QRwVU?RvoWh_i^{+dq2d)+WZbDp2L_1T+qA&3SgN+Rya8SWo+8~+@*jVx= z4fx(PpAWvJz{f}J<5*s0P&uV>CLbCHM&-iihb4~}uMEe|EPJsD4K^C~f$aM+r>QiC zIExXc_`Z$&27)VY&WqGt@R#vZC=IO`@l~8w3g6*Nwek_}l*X0XoC~F!W*NB&#+H}+ z6Y|NhmpM#3>Fuj?j;HQEqzGBP3Tc2?V^QS+Pk_mJz-qY!cl*(7@h5BX78TInyYv zUPWd?l~3H#I)GwLd(#wY)#s>Q5oMmRtWJk|XRk8cEL|hY&6|oJ zP5!1@aFs18lnWT5PFp~UeV&rTCGye@Vbhe5IK9ea@-93yzW;aLQRk-R` zCdW+)Fb9;Q(B=FwAd$xFy-P2^5F~UBYb^(_py>FTf(#jhs{7#5Rol{6c-u z2JLST$=pI}f^!w9Z&dUooq&h=&AN+NQ-C-dMAPsV~>mB)E-i z-t)5-uRiB!)$}Tgn_|YUk5P{;bZ@%Pg>M9Ci6E#Dt?lfOR`CarE&j?2 zifQFkshF@O;`FWeRM#-^1>Hse8bSSa4?m7EihV)@Rl$N6+r3kFb+>3^p^mg`fySp+ zhDj}9TItuAB6&_9R9EeH@At|H=oUE!3e^Rxh|C|$XD#5j%#scj|TH#=*XywbYcYvE~nv?X2X%CTbq+#3(=7Su+>il~WDG%5SGDk=_qRnzA@PVl935o{!muHX5nC2`EV&zcMi~fLf+imK(4&Y4Z8y(!94^a zZxH{)I-M?U9&MZ2OV)ECUZyTX`$8fMBy~n)#3p3s+kpZkY5VykG8=eMWNVO}n8&{7 zg+DX`Q=K-E29ebf%rce9=kV^fg4BK^`H|h6bOVp7(cE$+{GPqdi)$ASM2la+_;-}P z8-#$X^v+zTm=|5jN}xOoZsY2-d1bK3Uu%NzyJ)brm3`dkZCMNJZ3W55+0sJ5>%zvj z%t2p^&J^~p-AcP)%#IB%-sQ_391YwR%x;Dp3W6S2VB*l`vFU-NC`oUhwSCczO|V;8 zHIb~S=e$f-U=?m6XnCmt%n(|}{li&Wm|N#Mt&Ci``U8TBgskltc`8ZRArPc>#wRmvW($*BqIN*MW>Gy)1l zI>87SEj~So>DilqQWp8bX<2jE)t31|KWxF&g>3#p@YdPSC012o6+$Cu09Umzu;b3w z(@-IBudP?2$UUes0JQ`XxEs^P zQN9|uJc{&u3;+>VcJjezUn)^>Te7^=eb6ZHO}HKrh!N=RU{(xhmYQ@8iRa;`0Ajbb z{z3>TfjrLH9^GWOW&NFe9*DV_0Jiv1~pI$m2@{4+bB!ORKwtXr?m3hgwVtPA6! zkUG?&L@`hY6y0oA8?g_mfl5+R3A>E^(6nW2l^W+P5B+L!+8o;MY}Y=`@%eRyOzGYH z77yz8EE_ha$2eFGFE}Vbvx4shp&wIUeQgn*@H!wn)st!_Me7NL)lf8GI%7rp$}}-V z>S9soUZc!aqaqze6lPee9{JkSIM;a^%+1 zE_T;Ax7={5RWER6h0%W&!>Rhx(sW(RdrCt>@F|uU&V)$tkgg9j81_p80#BCF2Do$Z zNtDbu?fmDbR=vXnb{-!~4Ax$Y4z=cg>Cv<8H&r_piqwSPXej{_N*nGrtIXl{bJGQ2 z$LJk7Dk+zmdvjw3UV319=!KrSk;y%$@rfGLrcKqP0gLjuXqQ6D<(^^;4Fq|>UF8G2S;OmHKxXwjlMkG$4fT^E%%S{gPFB zv};XG(w74AhNYPMK<^$(M?(-n%Hg@0RO#GVd-m4>8ow2#ICnUfA(9i9OC^Sz!PkA(fk758tBV&{Cu zhSD4$d3W}LKd3WHY!5YKd8Vwns=EKdQ!-G*ojvRGgA?LoWs)gp1N+U#mGc|>%)NK< z%R!A?8;OZhn5F9n%wZo9sH3^-Ur-MC5%g9sFY0-C|#c5(#S%w)EDrNA_^7kJo_lblgO+)4=pd%w8aZma3t zn2%tyyxeqDjkD97@L{y#kXnEU+>3it=_%2PJV=Q$zzD|isg;_U;S3|yWc6}V#{5mL zXQBmQI7V2H+cnke3*beioFILyj1ikRZ~NUeVrl3q*QN{FocN_v{R>V$R4~r-nzOsX zKAvW7A*-D^N|)bDw>2aBj?WEqL1Eip(+lW~IR8xEzzjQSmFP4n)a!Ppu?q23`2))} zFAJtD(VoP+Qzf&$3~`QrB{KypO+x)?e?ykzFGEewvVEkv;q6bdoi$zA7@aO#FsH1r zgBl<@Ro6M*ENqGnBvfY}LRi4;5Eo(UlS(kK=T_*eTA<0$rYWahmEq~xTJct>b56?e zm|Tzox=~8C^i$(mMnItioKb$ENE{`97P(wYoHbZ8(pSZlq*0Xp0S+7+T(Kj#%X7kJ z2vE@?qDKvkOb}QJdu_0R#3p#1Lh1=3b6OS@Q@>+2!aB$1zS;ESF%_LSBOvk%jbW?) z7oF`I#2vCE7WIy{SLd5>!{-d9y&m&|{rG@WWLHo!I@btFY`N(SXB;05q#&E$=Ie^) zMiVp%Yq$^He63F~q(x_$weVZP8bpoH;T=fU>z3=XA{($gV84evM?B(rzr&I!@aB`& zqWn;$)uZ9!PnuR_>7EulfCs7AeRO`yxCm-pnf0nT0DG+dXaHGBK{h7vc?;d3;VmVj zTO;e?>!kqZ`E^Mh)0?s{QVn`lub}=icPm8QJMgpCNPcw&apK;@tGtNQ%}*srS4p-= zAsPvHRSmq9^eRQG^IBoB*5{$I3J~X=o}W90qyPdQ3-s8x0QjtRt`W}3y5bM{sg0qQ z3jsgO=^W^-po+%44%@JWvC=_~C#Pk?BB}uoQ5^p8cO9EN}ATyAXh*e6|b_CD!@DIA*Xnt0IT6MkvehjqN6$-4^EVe=JWHX zOzRHFARsT3FXU-%8I$L~YHgJ3Gjf`(iwm1-FylJKCbPH!Y-$V%bh)>G{66a90toei z8*6;m<#qDB!{*(#-Zf3=T!L7?PuXP@6vx4|t+WZ~p;h6s%*fnVKGFY!!ephe)Go|8 z=FTzks?LJGd%X#XXGb?dK;&8*MrsF*g~BUYO#*=O<58>R0jAwPX0AlRoaXt~*oVCH zjT>buF00N&YQ4edplL%C=tx@QJ_5m#1(4UNkX4XjWO?oZ;ZGdg6(0EYd{Z^o3``Jc zFWh;LvWucO>4?p){tO2F_5=D|`xE8;b4cN2bs(om>T@Qjnu;^KW|`^;{Rdt0ax2~W z+6PdgkP8Mr@#^#6HA#oHy|=%}_-|2VYM#lzy7RygKm<0(R$_mn0$$chzTOvWtSrgJ zX(Ku4aBda^zBXmZsd(Ki?9I1Dx_mpWptedgTimZ$OlO}pH?lTDHhl#20`a#(A5lEfphsf(8H2rH!D^ zDDhudnHORXx_~AjJpW?#5-9%@I{@o1p9!GkxAnPQH96_F&>q13!eAg&kaH~eCUcO; zpkQ|gi@R+NNu%{Qe-~Utjuuu@ja}*cJQQsf$?9v?VYEuxZeonhl0+%2Fy-dZt3-g~ zERsq4Af+$dHtMoueO5%cjWI6zN6TkOT^oz+_EK*Q zImcrxF0IzVbnYy00q5p{axLdR6JSf+d!z|U=F;K_tfHfdM)yi)5dS#6toas0U-=K2 zAqqC)1{Co400O8cUB8bK3=(9Cz0O={AdQP6DF@*0xHalAnzR;8-F$FH_ z1V{C;4j`o0!J6%T_*^6<3Y<94d0WU30p^|yeeMBqp)SjUd?r)BAl^kdTT-ys8nw?> zHNr1NH22Gy|79zrg_d;Dncy>?@)jt2gA!2|xaXlsr>w<~8Fs-~?L_!g?RPDa!$dV)VHnRq) z;i${Zx%F|BB#WMm-kB(wy6?e`j;DGALg$2%pVjG!v|od28;dR%4u!sj!!xR0=e3Qh ziu-HUEFGm%H3iX##Y_=tf>oz-zI?p1AE&&@&XE-Me-qpABMikoLeaY=?wTRFmM%7mEPJs<&H%-h4gyqY_O#bNQ* zIy*YX7-gYAP(J#t$1(9J00$%1nX!NH_1_J=XWfcG)O>Su0@j|eebIT|`~AK2^`Fs43g+_Y2YO4+STd^>Uy2<_P}=t<$FP_NiT*#wkX@FLBsSreShO@z4pkV&cgY;nOKv#9N__4ML| z#ibfK+|CKUB#ID)MVzx6N5#JI{G1u698UJluuKTKw)OueE^Cti)QwA5?1XhVh`R=~ znzVz;(#~eQF;tQj@1Z5FrDYDqA1P4xY2<^`RdXmPmjM)Y@q>}&QE$p76Ip(6BG}fz z9&FE(8;VJxt^$zy^~UbCF^!CUyk-4$OYKp~y$ zfOrSQIs8F+63Lyb%fXGzOok`PBZ^>yv|`e&q|FEJ)IE^ze|#aItwHU4ql;NTTriGn zoj}77XjQCikiBc_aHr(4Hz7m6zNfqFZPUF{5K(EQv?f7xH+*T3g~{(wlR1a?>Cegr z1MY6~#pLsm*+E+xwDx6Mg82h-L9&%tqYtxa0Dgc}EW6kkr`7sdT8sTTD4hYnNs&WJ zvQ7DR-nH~{gvJ;E&dwt^?c0eASRM@-pNy;nBq&huQ4~vbf-BvffaAe3`KFz8yiAgt zxLJq0d^+O)veS&Qb{0T$n) zxx=s|nv=_3lOku&U3m%&Dpu@QX&*B8G$pxC!EUHRvFG2Y>|*Dc+$sYmaT_ew$u6v< zSs{!4JPl)}&jnc4VQf&#e6Sq)tA$EpjXjod4`$zZOze4^O~l|EwU@{RBuH zzMi>_0HXAfTgov8_T5O#?7PTPeA#Oli4u}d)KHVI0FzJ#?3p8q>>-Qo>k4JE2Xr5b zXD#cv8pKEqG`x}E5A%6MqGkDj!9ue{3-6Y~Ma*L@CbvTVgAJD)-ryXFPNvt9+5`_MSieJfLPZ;{EN2l=6 zc)Db6{;5kMF#sJAdDCZF0&jc+kQ2D_x%Ydz(vUx3XoPfFi^|`u4sT8l&)bDol>1=O zlWO~tu5MrLdq_#Y@}L5%FAcM~E^7|?sh1GPj)Criv4BXE-iE7^DR#p7vQpJOc6*z| zfvB{lP$OO2%8prBExOq|M!=k)ge^*($PBT##`%Bgt#4x$0n+6SYg$fu1krK}Ds!gR zmp+Nb*->pz*%VaLY)@Bj1{sf7Hya=bg`f4{Bci8Li&Ps}?<>K+{zTaU}c1G%ei@U7d3Ve|Gfm!No{r(#<>lvZiDE9kCpZm@K?#a)yVOr=Bf6g@Br z1)xTTxNxQC($z)iN`NVqez(h(I)hzS-prw4V#l8IK950cr4#Bw)uk5qr||PStPhy1Fc4a7zqVO; z`=-6?N#I}r0E=JNn>qDfc01?KJY?tJiV&H;m`liF_i`-)<`SRt6s%g zYc17>2LI=%^!j`6aPSxW5$;(EDKw1}!&q(P;DE*4bCToH2n$kRZ`5 zF!qKjR4!{w=O9eCo^O3s0A`2y@E;fv+3^O<4@4b3qz9Ek zZlpoKBxvKIDNl>99_2qGaxOvDaAe!x4%-!?U_HwTYw>UK-tB{?TtmKcUFrv0j zzLUm_nyR$7POq8kg+K2)MG1_O54O4e5JpoGoH^0i)$6eX^>s_O*Hv+A+o8Tb2Kspe z0>UG%O1$*q(7j5p(oIF4I;7US)#^e2vyS(1&I(m-CDg%nLeTOgCFbn0%Ww{~N3Av* zwuC)S8dJBV6Zg0p5j8#rPC+s+`4-bEC;dF=hb2lz41#NRrl*Ogq*vnazmH(5=KQ-6 zhrL6T@+dTg_}atIJ+yhNU?A%9$xzFTdVuZjd@Hv7NTO@^;7wL4wSNp_&ZtW#k@Xy)^C;dH89wRPHBe)Dniu z)<1kjfakcQuALv%L1N@n2%sb!9PRXKKYq8v%Q}ZZlEw~|Mg>0gbvbCp35UwX=T*9| zJvb5X%?~ha>SlcEP^sAa+iQQ~Wg(hZqiR9_^4UM%QCCpd9*lzKu8iFzYCTttcJs8? z7*U7@2>9h)%+XecKmY&)HWsq3-r3>4ycs_>C6FD~dYTu1KRQ4YsYZwQPo;Oe#;pV- z@wCD|LsOk*2Zm|R&rGlzj^Ff^fP)#}*EklRL*gTVax(64oe2=e7d5xcLmpaZm>_Jljt3vTY!l*X>rxt*WSik|yPrgM+8j~ac6Rri7$pixY=1l4XwE!zNBG*i;xy0Ox#wpZ^-e}EK zz}hX=5uwUXjM=Kpu_;bqFPt|Qk0LDW{R#e@y|vHjqm#b;oRSYVNL0$W9`8pUOqME+Frkz}&mpM}EzPlBH^}JD?!D)Z}Qzy*} zB8YFHNJ(kYe|b-Z>HvR#Pa^b*ln(!vXs*Jwz$*5#@Qo1NnV9ZHt@5%}NKRGlJ**ni zJgP1Vs<0)@XM)!(*M-@lbh={1U+@*ILN|fo&6<1?NC2Hz_mLFJlFSaxPZ&>q;4%-&3qBm+yP%U2sptu)UFo)F?}3UWF!LFyag_61{q^j6Nr^4SAKg} z)GlUEBxg7=wE+x7P7@Kk=zX~JP{v}lk6N@U^8ta7#ew3J6V!XkanC*QH@|3fnwj(m zyuzE|CTr3CDfv!4g5=!~2uR*x$VV^HtKSfyapBq<{bbEiN=^w(LimyD-E)G?`PLUyFaf@&PQf zlRyFF>4N%CjoTo^@S!OHfx<(nqT zQTD(bhwB3ifN_Jp9^8Sun%8+i`K`WFG*UUL*4S!HZ7_3VesY#AX*u;F8*&j1Ev!WZ zE>$t3zkHmJVB3%{esq%F-^F-qX705htB1pRU0iv-^wb#aDeEN-Eg-kVVDQF6X6^B+ z(pT*~h90&udpKDnfGfERX=Xa*og#&$&GwK{#$!;V`G_=?uOyCyYmQ>SFB(!+y_`wV zP_>&TQa)nU2_OyZAHcka!c{5HqAMvoZ*TwYicq?ihA1H;tVSW@~!*? zUGvFOs;av^X+j|L^$%eqnGk%Ojk|+3B{2W>c}>o7gH>Ap1n(0}Yc?f;?>H~t zhy`S)($bot$X)A{79b<}FbKX;S$UbQc^>-H5d zi6bbxeHt&X`ns>hg@Xw5C`4JN9IQL*^Uq;u^kJ~a12Rzonj}iBsrmrugwi=dJoifC zPmV_kC?3tb(~450^#jdU6IH)*A~oKnJg@m*-W+@LngI_1g*PUUf?h7_2^W|#%uE5( zy#?mA9Kx|zPU6JCH+O>yDuUJjd%f7ye22;-_GQa0s!RpYu%QYedC8vBUvY)Uq*QOI zTQxWU06q-TQ&{T0WqWB;nG zg#7efYq(8}t?;|Cf$~*}ODA$7&IVTt zH?(Z+zYA}M4@m89sxpkT-#&bx7$7>zr-Oft79c_8L&p-SUd(?Xf|&Uj|D&UAvW$H% zN(>-W(%xSdAPt9Q#TBhfW)uB_lB#h%U(I(h2GY7>=1Dh2ju(=r!!48EHPf(riEJOg z=2Lf4>ApoRRFAB5rnV5RHnyd>#+9yfbs-F8-QT1wj-RQOU<+mNfDDUl&a)4^CE)t7 zExqv?npeP-piisnK#d;8sy6{H(?|gc!x_xnTC?asCDh5Er=QlB*j#dM*(7U=SwYo< zrD`a7*!rKUxKhc1C5_3qchnw_B+ULU0xuJf3$#@4gQW z*Xg1L_n_ZcXQi>gh!}qGut-P2?oL_7(07ADWO;39eamR8BY5cBSB{1eG?huVJmwRz zMuww-D+9XUg(Lj!`sQWYbd&y`sX1aJ&R{HAsoPt8_LObxPyxC9Q&R4Wyk`C zR_2GK%RSSF-R%(y6-hBx*F^ueORP-YI}YZfS3ln78xsQka_ubgdQ(WYf$nC+WdyUa z9{0)ft?m&~t|u|<^L|K-6mev!^SuVa*|Je@w09@lAJdItZ9Q?S$RqQ&*C2-kAa^DI z#VN$s-I@L0?q09X2${;R-e7_LO{;_p>UXs{n^(+jI|N(Z&i%aXDy0AnAHZO^yUivt zk7js}=0|nC!MDdD+HEcZoIqWQY z8HZzx12z2V&*t-DZcb|PeW~1d3MOSbe&XERO34ugW;*7$0M)YKZk$+h8WqT=kn6;SCDJiX{2C+hclIeT37AeL*2u$R z9H5+hsfR?Pyk2%Yp{{dl5d?S1z+br={D7MngQ29<6w~~HnL}(%=NQ+~Xn+a9K8@ML^>4Rl1O zvNTQD`ed<`J%{=|43T+&Q)uqo006=xCvH@zUe<}E*mc=*=GUw15-pa$zb3tkgNlI( zF{aqIYfDyFN2)fs6fna+9a3;A4H7lJLKM5NCYY%fAAYa_Zss`s53Hbtj$JkIWXB@b z-SNpN2!sWFmGlommU_anrudg?-0N$DXqfM)Qdkb6?#ElJ;o9{UQ2kq+N=37RZB7eH z9^AKQNG|AYi~~Zi>FwU4&h1&xV+a9kS_!5rS7>pxj{3%Gdd+Tugm$}>8i|YXe`SJ* z>mF4hTYl{$JO2LtB7Du}Nea2x`IpO~3~k?l02ChO9_YFiYOI){PT-!ZC|h6WAnv)W z0U*h$E0TEQ`(fMqk)o{ReVR01ker(mUaVG0KSy8GRboQVK~BH`XWK%*W3|+M?+*O@ zcQr2c-#Ej!j3hHF^s{OgQg5qU{H#b68nt9bdnWjg)E3?{)*>PWIVL;hpBk20=8zZE zv@WMzk(pjfR&8{^>z#VY8-l~48=K`={HrZK&oHz|+8e=I5Ro#rWc`1JUo#F5yH%n1 zOm75^CItx>d6zroTlD#Bz#?nk%uVqm-3BKZ#1r z;70ks#%Jv1%dyDwH8McKy{BnB@w3Su%Qhn?kHEtM6ZM2PAsK9PE+>lzrrANZm&Gdg z!!;lRgjgs@?qdt2Hx|eV%{*w^9@9gU0_|?t{qU<}OjZT% z-~b*n3fo3Af=VuAOJm|MA~pey!~HD_ip9>NmBZGBX}rpOL(|O+{GS6k%F$$h*C>qa z&e|N&u9qrJTB~x3pI3R<@ju%m;{XMLwoPWE?oz*C!aZ;Flw}Hb$Ft8a zFE;~oukIA%$eX0Mdj=AHRL+aSYk%fvI?r?49-Mf+MSVUpCv1dxl6eyUB+KE|YsyD# zu0&vfo``>CoiPBL(xE2+00I)jJ`id^i_NMNw77o*m$=Y;f(?w0x{2WU=(JF7^4ndU z!!eIxxWNj3=|x^O!mefg7@cOLS^?@dT7Up8Lg+qW4;lxjuy*4YY#=8rxvwOx{TWTo zc8b6f7<`cj>p1W9TPFMzT8u7z_@0bjlDGXPRlaDB$O&uoPGMp&H}hWx>COka=P;W) z`+}6ri_At+vx+tK6~WZ$fXgNd4gC~mI$y@H_SMfCxaT-kB-%83FY#kj5)l|alKj{g z&03_R1CSWrrLkQPRJdvx+H6L;c%Nb08QYP0+4bxC2(x-qY6VEg9-siLz=s&^l@yqg z);c@)Rw-M&bCJS|S}g>kYKo=5Bvp^@RGq~W7WhZ)?7zEgUvu8?c8BVQ%Mx2gEu_Z_2TWw|nn33IYXQgNS3A-JRb zTGcim>Yy)i+uNBbQ0~&WEH_(>6YleQheycY6LE|8{v3O zw@aKJKPrU$m%nr65CAdn3XVt^k%!ll09W6tuX@X&B5fT{ykWsVAQ7h4 zs{-zf|^90Rs`kQh8!BO@`Kyh zV=7*j*^BPNLVGS<*}*4EDXOdRoV@0j9*n2rH}EqPC|Vxk4wvj5ojoj1S4E<9=wJ436`UJiyk+ zp%aGsj?sH3Y2`S9;Tt16INAi*|G5-zI6mK1`J5T(-qZQD*Jww+9D+qUgwVq164clRII^{lGy z>f(a5jnhCOo)b7yuEN%s^UsY{Qh8>>$p@sTN^IT3hHql$hDEH-0f6T1?cSaAsiJ`B zRlyw*FbrCE^HaC$WCUEW2+}?+vY2LdiBdG{u*io9;m@Q(YyJ8YuuaF@5G^mEx>9BD zgk%ItD)ImT=!Q**0DIc5X`3e?9GDvOvC#A>n!oZvY;y@2ER{LMa37jga-@6LJ16n4 zbr7fagYV6MQ|&hMx7g$NjUeV}fIb00m~g@EcL+}YX&OlygyB)^WvKI%351RLHGhaQ zNJO+e#UHr~MbokDP+KjoOO2^DC4_<5n>+MQ$_^y7PYSu2m0Hj>Tk&6YwdF^zBN z>BE2`bY1`y_QI%}Av>=XIv+BaN@umMXH9%F)~tYHv5W%s@oMKCA6A&uZN71D6nsTP zL-`QRT;I3^GQBo*sYSeJeu1d1pyxQz35VeO+p*x< zbK1~Co$Rz61U)YP=|vE#=n1&TejVAKh&)8|>QMx~3g8lIM%_yrrA2!(i#>g*~!G}x6&)nIAdlUTQex|0ELpg;p?xV~%U9`R zH?ngQepr>paBfMUTRyXOt5Q)qEcS1-_gABK{)yl`ql@)QM!wz8-?Vjm!PqSErPC| zuRntBqp3y&)`i0IXfJ@~_J$&Qg~rTADFA;+zP1bK3*4@7SaSuH#_x$o+(x54WGiem z!)RLJkjfMSK-s*h>?kx+qd`75b_Zx=??TN~_7l-42mRS=;_R=RQ2#B~Yb@^=TNj)j zpf*pUlg%GpKy$$fB$(p?8Algi1C7`?cuR5YqmCd~aLQ7pg?JjUJuqZn!;7YfO++#y zqJhMr*R+OY7>T{N5q9q<0!I%3hgf4yMSbQJ(UGcsv=yiKG;=)>1R=;FGqR>JgCu|C zi-59%_l$T+6x^&lY)lyJ+M7_MB2w8K1Lw= z=Z3?V45mRA_hKRc+>>D@GI8qtxcarbtQy<$m>$hcj(3F83~4g$;hPwiSui3V>t@-j z>ViWfVX~ey_E9&*X^BJncz8CO^jHOFU`6pW!U4Wsdv9|>P9Sf{gI06^0F-ahU`AS!Q1`;}` z1}K%)hZIpgP5ZiSC!L_*1k!M|#e_xmD2i6gxLm$Zl+N9D|5SK%;CC&MO`aEh$A%>i zQ6Sf%*ReFKl0(puh_|Jyw|t?(tH4wvq!88=J7t}uN2`9|87%zffSJzr5JgU05Bzdj zAubQlvZn1Gk4(HtZ_BG$_@n~dkaiqV|3+f52nT18N^0>jbK^cY}I0rLU+603@lcRr=gF=)m&iCBX{kE2BZ%pX@Q)a`_kv*%uxr zdUKYvG@i^3aNEn}PyzrP#7vFBS29B*d~qFSmywSg?#HEAPm5Nu3CC*7MBKzgcCd3Y z3yl=;t-WEDH+V_4VD?r`cG_3Wf|Ny4JlRAYPy=O1q9P$IXU9Vyg&JL;u278+j8fDT zQd+X18yeV0#fsjU9(RuF7Pkdf`GB`u8#Pw`-BKOO>mI^8R3X}p{At%0I1M<|$%cZ} zc=B(WP2Oiyp=!=%F5_jSZ{XJdRKqLc`=E?no1FzA+en z>k=Ldlq&1~444}OsaOed;FrBW_=_yGT{IxaLaIvpFNR|%<)|}TZ9UhM-Ofr|NI%SV zFtWMd6wMdW?sE+@VbQeL_!)Mep##8S-i$wa)i=@1A4fK(jiIjHYgaBgh#IunN zwxtl*TW#_7h+40=f|X^(vy-W;KRagU!VR9FT?7Zga6aQufs%_fL*cCQL6Qr~{u%3n2&+ zjP!w)NI4caZ{RC>&bKc3A*{b2Ak-3OY`*K1ch4?F@YRjl99qi{&n7v!D?&uiRVJPub@=z$Sz<&LPy0Ve&qK)*&Y2;+ktRT|XnT$GCQ&yT(5~QCk9tH1WkEV}ud`mFWYDrk6`toeVf_}c)+>Kq z%bvzZa!Al1k)@bNWW*IQsLG9`8}6CXi-x(5YvQ_Zm++8%l5K66Gxgd$Y8lvwNX{y; zWaL$6aFq1E+cQon_+IFz2wQ|T*GRE-+|{$|C4x@Vc0?|(8w?LdN?wm`{=xsRa0k^yBVZ$a zJHUI@&15+g;u=+x0o2?L6;gxvK%139rlp9vG$*&FsiyvZ{6mfK05(5V601d4)_U75 zW=yCbQ2HfsJvdV`Tyc4F8CMP)D4yjJE@VXTRUJk?h80 z?XPiEZ30T%5PzoDL>R7p&b8o?~OoC=f+ z*Hv&=+9|7oEAWSjaShto%BA%sWAo+cfiG<*Dh$qZllPp1ghlAQg;Pac+Lg!J*-Mx7 zS2BG?ed&9UwEu_}2ehfH0ySZ*)?#6#wBhmB>+ERjDU{n_@(I+93GM!R7mDZZZ`Zbg zGPC&PNtx?yZHfAwDsUX3LWT;RY!A5iq9UY^-@3}8uf*D9cxlG86^@&G%6=|;Kjr}4 z%dNr3=RANR6>Bm6c&I)14}9y|x#|YY*b76pZLOLK##?in|JJe&*BgJyWR5fYnUV|0!uF3cT1X4W zp(;j>oY2qGXWc117=DXA=uKiNoc+tl_~+s~kpUNO$r~gnBqO!Ez__1DKy!kE6<}g(t?+f2n6*U z4eA7lY>8zM7JmO*?8X?v=aQG(E7(q)|!b4Q#7(Kl`KDTJvZh$WRSQ9HBm%t*A@=@28p-&FR1A_Nz+YJAV4Cz)r z7chyZ8@zWV7X}~7=uWX6fcj0$qRX-vGVvzWaO6827aHSsqrLasWb3nvs;AkZllsqx z1RhC9Wn+zj=wGdtE>Gh>ZbF8@96lTig6k=_niLAy?-?Bu__A73tO(9lq4bEJ$TjH` z=!hAHMJz^)yroI+V8Ct6Esk7l03qJ@Ena_L=~wnEgbVdK9CeoQ7Av+~DgGtRS_BHk z;v|=fCeDfGUwZ@GI`21z26IhYpA7JR478hMdc^3BpAo+-=#9p4l(_R1N)yX5sNFBt)a zem40mNm=|HJ(F@=m-dX^sk98lGAmemV|-#J(rB|&m}lq*%0H}1=L*A;P+SuXdTkb4 zS_D^pi$&jPs=A~1q=2e+QjDj4dXky1;{5!Kl^SL;cyKCtRPhDQWXJy3*09t1pGK5s z#XyE#9cy!+t+RhW3Uc$&RquA0I}n8Pj$s_stNd8I{dY1z9ObXTxiw*WHYMWpMHd~4 z^26XlHi}@_t0p_SnEJ~;UVM~PkkBRD?c{IrJ~fxebuClZO(I?VT0(-wc8ls(Q1AYv z!BYzp_{+za(aOZQ z6)UAvH_stBp@;)`Y~^G*;{5O6(zSDrJzd^$T1`F+E(4&^0m4?J$E;_iR|UNLjngM*ul2BLoOnIYyqTc(@);2oR#t|YiXe6yWr9@CiiI$SrFMjFq$Y2><+5B*7ENBFwI&CD2wB(+CBOVb~2en9Q{2$7ZrF z`L?5Nv(_B1XP3P7oM=>gz>*S z)Myme8Wf}2USni}>r7{`Ro78vLCVB=|0>$n{|qbqiF6Avw7`kwg{3wd8LERh!+Tx? z2uXCNQof3>)`A9n9~>09R44GK?W7?!(7Ks6UWM)c621r#eXX;JG2O0#A`GEaGqK*o zgGk%q3n8a?(J45(9u>OpDojdEGyYa)2%?yhvQr_W*lUdOgc*{dM^FGC7&)g;qeoVi z5I%ISgJXeC)ot(l&m*t|nQ$qyz`t5TR{kCC`6$4#N{90N^eneU7}>AOI$$G-J-CThAQG^Zt|{aV?bsatGYfrR*O?t%cAgEOz}hx z)99_lPW$~@krGHfyL5wAGD2Px)p4&<=BD8W!C8#>jNI7b&y?`iBXQmdDI|??YVu`w z*sdyACDV?n)3xe`{ckd9;N`fHnSqRvLNpM35Fplqf?dN&=h@`B_Ez-q7;awT05GYq zSAB{7&?GpSvhQQRAfaIrKwJ*``?wV$Zf?jlGh;6p6LC+=p&M%xyckGGw-ceSW&Md6uq%>9?b-Qkma-Y&R@A>_U)^cA7=6Vx3$7lLMw_+t1LIDGjPUXxGK1L z?~l#&Zq?hK==fh!I=^wD+mqPVhj35+$d+s7qY~JZ9f-ykhKx}=Hp$QP(v`OJuw{kQ zRF#VJ)Ny zzc)_#xDJJJ^$CI!5~>z8ns`Al^+a$^FIx2}qq&|J>wt_7 z97i4uFLne|*LL*%r5LP^3&vS93Bm4}Pxlkl2FIfD@iXraW9rXLlYKh~$EiQD;3dn! zkuc#XQ+Qj+mcZTY$OC_fzClr?tbO`M*pblf$T8aSSq49N>BCHHO?al8?M^fM$Q)G$ zMQ{=Yx`O59gX)gHvJne_pKoW-lx}5T|MFWHtlf;UuoEXt{Qwh<7G#O2tgqFad+^Y! zR60?~h_LoN0DYn7jFa(^|CS?`Y$K~~WTl8p@S8{w*tj&g0JLC>#HJaEh>4usFh7`K z{rS(Dks0&C*U_9|*jlgZ@}gmPl*-fT_)rx{#<#GEs>r2(SB(;#wxQ#rcPRit9p+2l z8U-Wa^@!+*eZ{@9k$sAV3toAvgSsjI>P8rNS#OgVyD3hbuMQA7VXcGAj>yxu+s+Q5 zRHWSxbsJ!ZWBu)RP^%DwmtH5#SYt^Lko|TDtZ+bBC5NYqb=dcBCJ9o#lREclM%cw2 zV{gSKga63@9f)EkAYy5NtBVd&AEz|!ykv=<@feJXSKfq z&adS>f|KQvc2;FQt5~d)uqWT}{(fA|S(r=~_bPNGb|=hEWq5`XpF~3#xE~&dXI8qH z6tN>2vfaVxKgsU^ATy6EM(#I3}m^i$|Ir|G3G>=Ef=HG6>?sr=sSd>Y*C`OQ?cc)R%U2S1XNApQ3U7TD9eu^K?JMa{pYLrQO>vB5#)cs1EPZzKVbE z{B6ZV!3-~#?WyIG${$xf;8iJHb#yTBH9fKkddKi9H_5oWgh$FG@ecmG@TL3h;IaxN zu$33h`eo^k!P`m`W^ZOJs~LNVt|o9;C^?fI0kBm;>Sc~v1fC3@Bbdu*40Q&2wf#Of z)0!=c8aZ%lW0YZgSHy|0t)zZ<{m%V{691-nI{z5TY6vbl%4V4d`htUPYR$jpdOX1; zb`Cyl&F)>J+GfH>KR)^JI5FJOg^K8RIb4%Q*nRQXI}r=~NrO;m%mFR4e&BJ9-Cll;Y z*X4d0UDJk9(6}wlm!3koxwx=r29C_`nXeu_1%v9TV0BvtN*ZtszE>gunw9u%v{Yp* z{8>J2LSeCd$b$t506BR1i~U-v+apvg#ku;*W*9H`uB3V-c|QmBXU9Ho0DdLThMUM% zE^ab3H+w2E3*4HUYa(=7rnJlJi432Dw-H`R{$u-`A4M z?iJ!+JpFLgC*pH)r5O2T1Q>W0vy%`yP{;nBwdM< zM*w-rLrRfCnP-<+4W_$XSrA&5i)=A-sf?k4ekTh-BHlq?KBz~P>uwq$Ma~}D@R-DI zHblg=*`nL2srtj-rot<5=L<(Z{U&EoM4W+0!-Fg2bS>qa!MvfRo~n<0f_}lp@2@Sk zpt`vR)dhO*FKwOxiEm1A|3Nca^%f2Ur&VdkrH@e|*cW+_g>;*d>$5u#d7bpd0^40q ztD5H_$Yf=ZKKv|Q?j#k{UnpvTCwDA#=T|;Q6?d7-Izd;2WYouixY%=!Y=<0|gudrx+X@g|r0?`4hMDU)?m{Jw3)HbT z7h*3NX&TOEyDBpD2*Lmbk2UN1%mC*MlFHckJI{%%!nr6^F+(2xt{c-5$OJEW@ zbn*BQU)950DqJRWZ5!J~@+%XB@6qt>V4S(mu&p^o6Q{`F1(84y_isF;P20%+@if&7 zI>)P5_OkRTM)WM|juRaI z=Cxk;*WNyHuL*qK$h?(l$clvl~t8@cV&OxS>29*X`&z- zQw_B2e$eeCvNU)44M?@7=-Pvs{#$#v9W_gNG!YlLmquRyR9IvM6P0zPTq-F7fV81A zz594F@v|w1lVM2Bo1u3$2$1%p7!h(6OZv-lvcDpm{zHBsN>oz7hk>E-fb9YxT;_?> z0m#im!+`ZW&*m_%$mZ}JXxAG?vl_~CA@Nim>S;?-Yc9RBc0uYhk_Ynd={r-i#C6sI z&RObOA02rI=)C#n@W#$zKz9%WKMOa3w|9Yb6yewhz7c0jfDBer znRaOtVHbi-9x=IPt(^5BYA9-r>cN0e?_$Z|Z0gUj%l~E;{lXm^qGHOA=scg7t(t<0e;k+-UI*u3kL_PNsXx>vSbz16z4q)sOC(H zz=4kN`}e)h!fD)8I#ps!5PX@=LGut7pqPejdsSS^(t839iYFX>RG$C<2>x07nGf7Y z{>2s82?$wP`VK8uZE|38MJ_h1Bqz=vfVn$)Iu>cL$g4?JUQI>bfKs_qO>yMO<>W~^ zj6ZAu9G#)8gPY|@uCKS|mXl$iy9BUMj%!jWgom62|AfV#T~dKIh#;zs`$QV&fi-{* z_&``Pi5I5>@6ZIQ8C{vwZ3ENi@tU2`L2#42A~sS1WA{2yqf8_v>GAYr>#ypYF5z_R zA1g-s#!bn}X;a!pmE0h$TsxN~I996@NYi$dGQI=gws42CB#^sS82&F3(8--72IqDt(_uxKF$a%wZnp1VS|X!``f_sgRDJNM`&dD6=9pLcjkA#BBVbgz0FDL9JH!Sc3b!ZZHLwal2P>gEXB zP%UWYkK}(R%TErVVOUL)(<={BDHq2?mz68|;fo2Wtt%Oow9z z{{|Lb<+Q-+&`OiGhc5c8iG4lk?9G#ZU9l(QNy&ePJ7u&)d-~cX!^?Q$T`Vi*x@SeC z%U-#$DQITEhrXu&SLHW-ebDSCI9|^U3QzdrZ7wA}r=_X0H(osgR5D~VE1P_BXDl$z z?VQr@-%m17#5xZy6hQLzCv*xBMbcszIn2?NAun*fN^w)`ymwr|_uv*_GJlnyt-X$u8ctg#I)lB-EaxwE z;+ud($8jd?ZvJhtsIhSD=V5am^a%>r0%KkuB{U`$38I{pJR9Bq33!R?uSl?JitqGK zJMqeG?!OSV32g$)kQpN>3%1cr1eOGHvp`(lX=Ov@~nrI zv}Kp=^#d6_`Ry^2>?3Gg&-S{vcLkB$9$V|QwYPL+3b|w2!_A4$El~*RjE*&wpalS6 zFR$>KFE3JDB_wBPUk*tzt3wOi+4=+uoV?&yhB zrsIVHAjBX{z2y3}N7jg2IS&Z|Uq*~4Ef1lm;(hPVEC9cy1~wr3QGQYPGE(HmfTH)Q zg^l1%&A?e3ClKCC2-azGYJql+z)usW36FIe|9EEoXy8Cey&g|=W`SB%1_ZgQX3&>G z9RO4xX3zxUA^}kWwo^(H6pw9(wwI?U zm2(4-JEF9TRX`mH$n822E2hf=Sh|kC#jd;h!0dsAa*W#gY zD6WvQD&UyR;$|q&@jejBKrWo4qJw@xNh62beJfa5Z(#yXwB%fF&Y?IQn%-DV8Oeeu z4Y1%!9O(>>xo$z1htGG{BPy3+3t>-AtJe1hkLuJ{`ds@vXwQUf#_AfwE*k&nEW zi9hHbto=p>MZ4aBa?5WtQ8G$pi`NfHqjOZDJ$x%adJ^EzCR|VJYs?%b>3(`=2U%d0 zj6X(VKY*QMOQG&l&WZzB{P`y-c){haIQf*u_HZBj%~2T``y`>-gpTpJ8mR?|V)Mw0 zLu%wdJxNjG;ViA~@i8Hjq@a~^c_z)YOMC?WClP>Gx?s{p!aZD}S+Z|To3I|!ji5D> z_!~KNL^93*l)l2k`8YdiW*y5Ep?S9rbW$c$^Pb`QP07UY8C#;4rpB+cK#?R_A0OoL zqV~5pOZ(puvPYxS++W@GMnTG9YY9#UZ6+WL1w70OQI<;L80GS6;jPi@X@X9eZYzOR8;$iQKRI5FHV@a`JTAR}R22yEOMoHALO;wh z$UZ|%cDiD?bUz-OH(nojK9{Y>uD_hRsy&hHU$Wcuzk{imoO(P5FH>v#8l?8=z4@E1B5dYM!|`G06C z1$iPNY({FD#zM617S`Y=L8sD1T+Pk+F6Vf9Q(8k6I{e0!pjA!due>P(ya-M5=Q2`d6L9Yf@f>x0LW*Xz}XW5WGT$(lU@W_6ZJ8d4_>X0beT_}w9V#-gVg+3c zAZFrF?|(WYCLJpZVuQw9o?&f1z;?!+g0KeLo|{sU`tDiuhDTj8`S^Ir{Q_egc^qj`lxJMJBIs1lzkpQ027G*cwT>So1ZTAv zj|V%`2`=|J`aVPplFc*{DByoO0Irg#(=LvHpBVr<9JfNKTtL8?>W?fl`w;Maq8fQL z43VxV_@$o916$vIFRG$Pb)9}Ac~UON11z{g)0d&;4}88_N;>B7$XL46#EbtlT0(VW zpU?3w&eE3MTwbK{hdVEZrGUc9AxwOvW4x+?F+m9bW$Q(cS?RoP zP%Cz&-i+gK=UHoPK9GTM(I-g^zzC$d&T?66DlEPGy)qTXx3V_zJWJV@9}#42jo01* zxOq@CBloOw63`qHm*4Il)^GQ#1k8-5106r0ErA73^nTk@`=PrAUV_xo70)NL=~60g zza18hlc=O?|FC?|S{BDwNi|b}F83JXeH&i8MhgrUhS(r9O|bYLuCpg8-fiIi*;-7z zi#l1q`W1Q=$h%XhE^`W!l`&v61pT+~g}NvEk?P2uJo(4AR;(-XTK&hG8hc@uKBC#E z8qW(!*+}&I2NQn1dqSG3>+k-HmAsDL{3(8%+czta8+7JXT9nTf?!4w9&*t~Q&{O@P zA5=p1q^c3`1Q?x{mXI4d1s6&S-)PGfI`(rzgk6~1qL$w{D1kM_lS^P4)#vAnMFQcS zBqWQ2Qe>)fw<5ZoV->tj4Qy5@uU5UdH`4U&1NhP!|HB&q!adehTH@S!jUJqkK^0kf zmnm`U7Z!J}nVgblqSkvq_0eE}>Y#+;2P}F4Fv3GR`icaipx^PP?g8|JpQc(y3;7AF zbE;>W#vYBLivJE{1@r}HMfEFnt*?WaO8%?{t?zh@4Sw-#*q_UtrKwf%Uw6(6pmknT zlH-JUFRavz#RK2^(?+syyrz);iS*a1yR{wJwvkl7!E(nI>u83^vqsHs90H2>TFOu% zc$MXDgS@1a`|49cHFo+Co1c9%;D6&V-^Y=2$UYTqiB7g2?RHx~gd_KzNUKQ{5x0sN zH!Xh|xwv!jd>4&kvM^H_KAF5w4n7lyx{jSaXL=&sG z2t*@Bf2i#W6xu)5eGZGiEi5{hB_FygqEIQFg`n|A>iMg%zMPgEKN4HBwZ#szXJfMzd!Z~E$8CtC znL`UQs90A&NHdwM=daoVAq!7m^w{^Bx_zMO09Dd5pIC`FtV=L;qV2JkoBE06$``=$ z9fbtDl$rO%yKVIfQ!9+_y1Eq^sT)No+cnsGHbCB)<6r`Y7H6wN@w;|g_le~5^N@*U zr}Sm&m0J~-``=QPSg|*MidbnB?+uwU)xrGp_jNkS=BjZ+0m?77i{sl}3XYat4~LG> z&e?z2Y0AMkvpg>h0!7NBK8m)V!&)?jel_)Buk|pWl;x$aMf8tguw$f6+LEKd%l}8V z@udB@SDi>bj6-_GLvwAyF@&eW%p3P*zo(; zIw#M3=z~v32EFH=QazS_2_26FGb?el{A=hJ=qRtM!HXD-q8gp!grB-HzCAEz!W^yC zMvY*MB=O&v%ekLaD=B1OQTF+hqwx1aaJw~#CL0;&{ZX%;r5c+mE(Bptz3sZXobI$0 z_&_ZBmtMh-lL896p&Myrx`X#q%bOzuId)2W2%GZR&r0rV8^+or;oh53y@gx)LS=mC z4Q7;haAI>8`*Hz64qjf~N9R2x_lBAGEm0Y0`wv(X2&U@GHWxa7=a~9Czky)!3coWA z$_p-^QtM+2HQ33jgd0dLsa^V+m9$zc$#Ctj=L&XN=^7LFOU}e@5K4@uEs{(2lv08J zfd^D+h&)Y)=(k{0j>4jr2j059#|Msz%h*PqZjjMx8O6GKD-@RE6YahpeZ#L{ZGIUO z_b;kS;dSeO?deX5)3Ym})~^~Q+H#M0!X}P{(}~G`4pAd3>Cz$uz9+%ukWwE8Os>Hs z5?9VI5kiUg0;IP`H#vuz{|6du5I6PUQakuI-Ih^%-OP_sK(5_v6Mo-&RRdS-rVKqt z>?8!h50m!V>R7L4`T+iIglTNxeRg+f{1An|XOEcKw9XFUa7b^`<-AR;WkUj#yj8%` z79twAgm@|J?AcGvRJQ7J8t#%hrWDsP? zI=lLto6In>G(+K!G?ld6eWZIFu+@$irpG#kqM#!vb6~K5B_VezCADi zPm59{(=0m#%TXEZrGUh5YxNqrN(r-AKA>fT1c`VdP@-)X{ro71qD{JfNx^s0 zT5S2HB%t?HvC2A`!hk~5)vvj`W!n>buIUQl9z7w0{qx0A9A)BHm)n>wR99ye)vWe< z1~B3|^+8msJe5Ssy02r8H&@v(>=+nD4`P+G9q$lLTp+3qXk=N`^&B0lnAj3dWO?*O zWE2Aklc-AvNtRWC4}meT0U0;F_q^<^L6hr${qgCfZ{m?(K(+z@gA%bPRf4vY*rob+ ze4e35dGpP{(B|7B7rqd4?5<%v9OIY9FxIgVq-JtR48A+{`IRiMa@J^&9Ot<5X**NC z|Asu{yb8tNebHp(83CL+a*q3A+jVr*e#Ood_)86PrZtLK<0sF5ttKmo4x$TWvcG15 za}FwdogW_Pa;alA=DVCfjlFec4O`$G#DAP$-cGHf4o@NIZC%l3Zw^0`kOQ(m9Cvz3 zbvdpIfTA(#=abqdsQ;`z2GPFkpW=Zlpbt~eBgCN3 zV0p#a6C!BE3`&>(#})nG2WcTqimv-X@)^}Bz3jDW39rB^UJx%;h{{ySkBfqmhlJZJ z^Sg6N{g3GscG+lcKsL{0EEWQ_*k`2S_ZA+!9K<*g!j6^S2JjV>)7zCd9Xs z@}uX6&|hmWQ&%Gw_5An=1KL7QiwN6Qd_o(-)B&%^ovyOT$p=G}nS+H5mP?q(^wrt$ zQ1<);Pr9^sWm57^4}zXG2KctO(Cvg8Gn}rvHxG~hi5n=@JdJW^gEa$N%yv@bn|LYR zNg-R&B$NnJ!HEwVMO|Rnh&Kzyy4U(A}u!QOR9Tld+fi$#7dEzH!}GzHR_%qr?!- zDW3LqX6Y}~s<>}0?V;3DZj0Ae_ui==SJM3)zm7#$bCVT`jYzy{?@zg_CiN@Zk&a~h zfSKZh>1m=LXy*LqSLj8G6lWk}oh1SzoGKDO!sJxxyJk2ZKl#9+SAriG<>+WRwBqFF zR4leo*q3T2Sy5d|2kD+UmJQ{AjJ1Il@nqFiZY_mEz<=1G&xa=BI;0?+f zt(?1H6v%C?3^=dh(ec$C1lx-#7z4UzBW@5Y$k-WIBxH}eieV8c=|WDzi`?yH+T^{R z8uQ6bqm&;aVY)mn-(BLg-5PTxPhf@rHi-d{Nr#qLRKf-o)oF>Us-GJy`#ThV-=2gr z;KuiLk!=%r8UoHDLzhuQ&Z_rxdB0~s+1ojma^A>btQ^-QzDZ#gRBpsbc1&#u1k$^N ziL|#F`|*BB;T~X!b+Q!uD6DN5qGaNHM_dy1_H2AjlJ)>8;_nbJ^*A>1f5t0BvH>5d zOB2j)RI`Du_Y`sEkk}$%rS|~;&k@1NvpYkx5uzsH5$S_wTH$u=6g-+ipIIy_aZU7I z1QN5CKN?Dl7v251PosG13JG8>&EgupIq(taJIPs;4o)W$&DWQzRf!$N(jv`*bYyF& zo5Mg0gZkyKbEMdx=&AE^X~UsBwH<`|p@EaH?>|l;`{eS`_5E>|!dq@w(CrA@ z)S#6*@8T;o&=r&``W0=|>eui>w+hdGvE8-)8~m^drx8l%$yD9t{1Ne(hl21UH#X^r zSPcs;Mm!+W0_2;22X}B_Ow!QQSUmA1*4`X_^F2{SQF>Oa)6R~vu_HE0*cvz)pW>?p0Rl^rg;5&ZjbIyZb1B43KTT9^Y5!%kcBKb%x zkM-Jbm98EXmm$X$$3p|UAX4&DV<9~;xO3k`7ki`N{>kZy`ueD6Q)F#LzC`UDA6)Xm z0l2*u_U(EnD7@9K{jMZ5UZHkMnH-sG?I*o;K}HcoVRbjS5NbH=rs47hfl)y&MM9RC z!&O{fVjmM^0nR@(^{GwBuzq(0keKP_iLu7NFo*fBwxm~PE8#ej@KxyNG)!jWPg;3? z-F4~^O^1DvrdOmlMU4o?0vTZ~-o8TnEfl4nut9QHu}I(mSbg~V;+_5_1 zqq2cFbB*r1GJ>j9jurT;a+%>S%is)bv@bdQ=%yO!8u3CUL0J z3p_CRH^>Rr=& z6)bzwqI+iDi`Uh-)P)#OT+g_By(Z3bg87X7E;$}o(wY@xsAPTH)R?AhYd07o79kmr z5-qMsOASFl8)M`EUMM=MVyv%B`wb!L5m5XrW%r5OavU<-ZQz;>7$Q@QsR`{#rvCkx z{L+Rk@1Y|?oHUCRpye#K(h#2rUrmzoO9kao_%jAUqTb8Zg!#^Qb1?UvznZ9DV-!yS zo1pl;3>@uB}Ih76o0f=E)&K z=06&BAd5#nF>PZhUZeHA%$}W$IE6j2Vy$nWU?cgkV7bG>$ygKYc4Mgv`pQGI^r~L6 zR(DlgJ7G(rv3+m$xD?K{=-YJ2BH+fM?l;pWCBR+F`?+>!E!a0_wlqvYK&QH6z<)L* zUe0hYiWZ?B>8d41!nP)+TFrDvRVuR{$;UG&1}t+RJx(F0mrf}biM4&vq7(T@%DE;R`XoO5FPn0uQt)C@X?@t5r}Qfb+gm@=Ch{zH9}gGUR{L7! zjAp2i@f-Ew)Q(%rf9kLV;q=KV!-hx%ET8{(eXsCN4T4lV->}=NyCkrFfrq zt)(S&h|YTZN0bYoMxx5b%?y8xIHCh8;^{$cXZt2wr;qjDUgck@=RZzh-Ef@T1!!LI zgWUQ(OMvr`bi9Ey1@bFx%Ewl6NFO809Ne6}|MUmHp_koI*tfF@XWV=xW6oXU*W(Z# z*HwxMEjEd{@hlytE4H`&N^h)|+N?$B$mOoiX;KV3tz>uUY;1drVt|)l{Z2m=@X21U zFb7d#+p7feV)|MT>~vk! z-64Z_i{F23-sDN=;ns2T4N^PxgSBJ$sy=c~(hrDsI!vL+)qVXjIYpe)k7xv=qckU% zz50-M!6Sf|a;0OrkB84o5 zA4@T2csS^;S9wd3-ti5O(0Du?KC253C0vz(j!7f<^8{^wbvwkULybddF@%ofe$qUU(Z@z!8e7l^a{qJ^?}`&p>UzWP7zsZs^Fs=LR)Qv(DDp=*$BcIXKopAyXq9 zII|e?n#foC2-B3d$jbb(Vy^fv1DmhZ4%#mbwNhlY?(nWPpBv(woWz2R5Pzs~wZ9iV zsg*igf=^^XylkuZ6ELI64G6Z=H-6Nqp0tYNpgIN#!r486JjJvu@7FxBh(Yix%Fuvn z_3RXhj1(2m6n@4JHj`;%P6uaKmSRPQ&!BPijvo2Ket{!HINe|47@_|rrp$z}=$%D& zzt0JlS(DC-bUo{?#ODQqzY052)V-!;?Z{7JjU0A;Eku`PWjo2s@OR9!ILVgaVcFV{ zwpKawdH*Q#&(KPZmVFF}gE$AK7$Vjq+jOnN?ml;S8Tjo=h4g#cPUG8jOEEn``p;;BI;Qr( zeR;Xd7@^O}p-R<=CFCCZ_USTvs}$fBuBdTT*DPC|YYiPEoQQ##l$EEUy5=^D7FGc9chKBol=k>f*@vaNKsD6j5m<&4(k~1ghtinY9#nR+l7o zeAVfvvNB}sj8gk)!!(ky9wieNeCRtqmgjxlSBBwAK$Lseaam+= zPMqbsWr-Jb2>}hndTmmv_=ec4V)^1})$)EvcM`J3bBUGi7t<9F^WCP~!IC?KQ%|Pl zNM)$G0MN2m8)1Tqk&ur90IX2sf>HYL>Ej)U!+!pk9?GicM;kY>U3;mdp%Y;U;ibp8 zXGyJ9t2N+`U-OD-E@0+D^~6+;&t1|2p>V!U%^^pCYL9s4vQRZh7fY;x*y2&$h`|Sn zZEL|@X!2!i*jap}lk?w)LVqKlM1jil4x~LSgR?YfRAZSN&xM*9_8tpWuT4(v_+*t zCfRER+sU!cvvdPAKkS35v@S4^v`J}ZyiYZ13Er+5R5@T|lD0n9R1`;?;N! z?fE*)?u=TRm6D-H(KFny5H*rI2}f*d#Ol^S0dI>)=LDmzvCE7{TEd%WXvUBXV}fr8 z1RnsD@xO1Rb3F-9ipdJM=2p<7qqaTN)#?UADa`|Vu`N=^!Vf#qYe_g!Xa(2EtwfL+u;fYM8TlR|^5F3O|3gA%BD-u4WTY4PC^ zH{=lVgM&e5h^>50Y9rh`!CWx?vG^(_{AuqTFa6ZS?ICSk#5=a+4qaV(#44cZ-v+aGmIeML4H!4awj(QcbL zWVnY-{wvtpslgu71g+17<2C!&8=n|4{Qo>kf;XSy^S`v|B)KXOe^BT6G{U2?qh} zfvcB|1jOTsibvigk)u#aw5YO3p$g3fj{tN(~oG8 z#2U8bL({9@H3r#QZyt>b12YGCLbbv}vh9%kNIbm5Tr*s;s{khx5pPwQ>e1p*bA&e770aq2A*SZ0c4g` zgcO)c(f^wla^xAEaVNHZiiHZg6=YA!o&baFn}f;tjoj&0T_N#}!(l(vPs zFt1O-0YZABs(CvGHVDu8+IVdr;~s!mQwFhAj0h;?(|eiI5@6jx_NG&y6N7PW$>T3+ z1r3wirqQ0I?^S*nA^F{H%0WFI$}Hioa+Ir1A7=y8p0wbnuFweDMdy~|S30AVn=La) z3p8~jQ1JaCyN5^m{Q+$9G0TY^(MJ?6ZrIwf39Uc?000XrQO6G&0Z+DJ+?dATD8E}@ z^ff6iw4HP*v}~7|z&2B?TmLePyWhC2q+5y2vtKrwo3e}}2zjiJm_;y!oKvYALx zXK%&b$c5Ut+ypKW73&a^1NvFPdkZR}Unwg7{TP^0Gwa-f*5}9^CMU!EBB95KO>Kl} zDIJT_%tA{X6Pept4ZyH%ZiViM8zcah|ItsaBDRP**PPcIBoCt% zeqEYgO=)kKo=Q)3b!=O3qLvYgc#@sVjmXZbKWJ>9TFZIhuoQ7*SjcbdZ0EM^BZ@b& z8E0Y4+Yc?Ne)pGOeP#tKlzr7dsppLcc}*p}Xc?aicmGF^#2>2%Hcl*E&X7vY; zy?G>RBQv~+01o@!r#VTWt&zs~Vu@m!qNIG* zav{)QA%eztg(=RAT+paMFvAOdp)X-gC`vh;6yZt<%qcUT%#k*88x%ab-gsN()WJx8 zL4L9uOucowrgU65({3-j;=jGkBzIj_kyAuzOh#MRpQ&rPG8v8lziQAG8vSmV;6L8o za~WgfAO)&Q_|=u$e)jS^miv31Ir#VP^?Dir0wvfhLVDjRiSAtQd~>Y?o_v8S1lTNq ztDv{2?P==_FgH_KnsTYYZr%p?LZOc_CWPE&y&$qJ8iocUg3;OQonu9Gl`m9i^@ji? zPKlJs7s?}(?H&sgn#b~25E1IO?%FIZ*z&EhONrc&T$ij{pBI{d00K$Y zQ+5LX(@q{L5Tdcm;MtTRJznyio%~xw;Phcz$zgLxEk=sShzC>01Uo|pf5TN7z-JsP z4jNo!6>TZwCM~kj7FOx3&A{vnU}S$i&*i)S#||N^KBx3XAG@xX627ImhB?N=TCwB< zkzw*?#!jdg65Q$8*odSi`!&HjpCu2h%Fc`YOiLM_PT3z8pQs5%Rbf9M!|};nrY82d z=f8wGe~TF`Q>q4!KB*G`8-w8;HT}|Xe7{8;Ez2Syy7Dv;o;U^R`^VyyY&~g$6q;MK zQ@fg$@idQ4r(No(j=bBFRya$n0is(#u;2|v?u@NvT!m_$HPnOfH2 zK)iR0T`bTTwz0bxl@uOGI!RPBWiF0P^;c$>SyjfRgTM$7Xka^3!>^Dydukm=t%!rs zsdKoVJCxJlV{F1w=l$%!+{@W}qSt+J0qY{gXBfuL!a#4es~}9N(|JLaR?Ms3p{iy5 zqt|rh#Xo;;Vtpm*ibi522l^?-klrK)_oHCqOw11-HMv6f@;KYzMi}t*oVN0uc-41e zrLRQ?G@{-V4eje?qbE zgulxg`lMdc>n#|79W$fanA})37!@LCDcqUBpv8x46V&83o#M|9q*;4eVsh-zBQe76 zQ0Qh$B<#c2%I7E;c~ecsj-2CX@nU2n2*e0*>gBEhq^`Ez{yFMkR#+dup%_}pI^xJ% z9#zh=mFl_};gWGaO1|DUrQhEYb!*E@^tG%6V$&t1j+Jv~%R4&KX>)(gc`4%RVHB?{ z2><{?2z};eYnmp)yCkoRPO(Ev|3r?3emlgNz6O6vU{%j!-WDv+6%p-t9d*A zrtMYl!YvBDA9h{D!3GJ0s*znS=1GZkx#gwWUV0j+T7#~To<1?*G` zEUn|3ZZHFj)p^5}>Hx+Q&s|UdvmKntItV{|N)B2ZvCo6LrSXnnK{Rl?%rc$x1p!#=lr!7#p3T@2+=c}alvQ~p%1ga7YSfdAKL7MPbBekZz%{+> zz%4CBF&|Z|hGh9GkzKBhN(~_~#Tpqhsld&o{dVI*bPFMXCa0}%WGim-XlG->tyOi> zAeyd%zZ%5+?+5;1n*q$l?Wh9@a?EO3#cPfu4@s}>z@!j?1K5fxP{NQv>G`ls##jPn>0I`~ zQmvR|a?==ts;^GWAT{~es*=>yxQ8Mx;}}5L04A;2*tRuOrO< z-0J63KRA{oG}|-oaGdpr1QO`u*wr@M2Aq7lz<_57gp>NF^&USXn(LQlPuaJ;C1%}1gGcmF zM9-@=X-B3(-l>YMla;k+ibny>V_8sPQwNP`x9Kkl$(2t+S#9vJs6r_$2$aaH8D!d? ze+%O%1}s5S$?@i=_%ouSpW5SU{=v=ViYzmDZVnq6<9BDHzVa6j18#+HT8bpJm6yH|%=#SZynKA2{hapZ z<4Po$8wJKWkFK+7k+E%|Gy8`ujhMx}wk&M3-8r-b1mAWW1u#U61I>X)&rxu^JezR) zeYSEj?}hnC&d``6s%`3ePP5hN&2g%AoS#&xYZ-~vqT>nVBxak(4QNC_yUBf@w}p}q zLADZJ#>*!J*)TxC0RNTeoW}9>)BI#M2D&qt#N#Uy2yZl25gUNbe}RXpxk1GXC!lSP z_A(0M=i-vE64PI-EjNcL2}s#VHoiE#$zp(=LGe=NTIw@Y9|{2$ixfhR(?9?Nh1K=9 z`UWmUnwftthfJ0r9%19JTenuJsBxdJ@{93Tk2ra2#A{Ate9Z(((zK7+mi`yQA&Y&O zJWQknCz8{JqiUeKk+e%;pjTXqpz!$fiu^{x#-4Dj*A@+xY5L#mti~QLuU^wF>l7?~ zoDotuu|;Dz8vJWbgNX<;Zs(4=*k~5g%I11OCYh6&nS$&^XRP-j5-Fl@2xf9rU`1r= z?7d%P1nW^<`kecU1mAbPl*WMLW04zWqp)gr#5kMhnZ5UV+SHQlJVzg?!7+30KDj6s zB2v*@MgiCvv48ST?qdrik0kK0LtJtzr#N42)NsNzohGWw>G+_R9 zKgi>!szrLQ0~b1LY5i%&`{!YuRcfxgQRGwLfMU)aBG{(?Vd8aO`YY;DWOWux6f;7e zxbV%38Yt5HJ1nGEWk@v$4_h`k@VptJo)(XAf! ze1hQy>w+lpS9T4R^1g8^{J-dfmGR~n#w0Dps44#W#~&Bw$aIMtepPiuC+{*A@p(xC zeOQJiEwwHRV!rrnAZj}$qA}}D6YWo+BLFf!wMX!L{qVF)f#O^43|oq3smeN- zj7xYZh}~_#Hv@Oj5AQ5>)SL%Q!E*{ZE)Ot7nhOZ$jZ*L-0P|)Z%Afj@1ar6LN26`R zK3;;gKl50^xLC@$xAi20qsF?q=QoOPDbdz!TbAG(m)l|j$+@jv;9mN=y4YQUS{ygCK%juqflS-g8Hs0n7O=n=Z2-2z1iSWAW8+Z24kpG+8|7Q=v=^D=T)wga zU)Q+Z8}JRliPj#UqefJwFTT(%P&KW2^pbLDM%M5mM(asA5_@s#5$^61M1m&U3=LUL zc2(s2Y-3`d7WI75*k9?breogzQJ#4jJ~7Gq}>fQTJb7I<~|lK@dNn^7|D?5`Q)tXdKI;< zeovABYmSEn;CFz%yz9C1UaO-aO?g=ioNovd?wYR30AhXVW?4lX>@Nc07zSc0dltYj z9|4uWo#=nrAtco-qt_FAnJcLXGKhIKaCB0-+-w_Or7GJW=Gw>US%G|OkU*BIi^>7K zCx=p+n6_f9-Wx_$`a;E*jN!MgY+2PYv`6iP03Ye?q6 zEyeL#4Vr|~&{J{AK=(ABH)h~}NA3VPY$hl3DTR2c`-GSB)qiAgC@*Y-%+=#AKeSWu z5JNt!6W@H;e{KC?2BLoKIgkH^HP|vnXOO-QVKC1oYA8jTobIs+DeFy2l2f zQl*F_aj?}1X0hSpAPH@(!kvQ>AN#7oXK7}34>p&nW8s{(#uIh+9T`Yq^R0pof}K1` zy3y`}yAhWM&OC66ruIVQ$U@CiJC0b*QoDYvsJN$h#LMLUUNMZl;(8aXk0}?PKh97f zNbn%u5q|egSeV|I0a?gFaLf`U@ijKfcD)O zd3UBLZXFvkuYD)0d!o|4w3sozofdRSCjMHqcjaFMZe$V8VC5OVQc&R)k0hacITOGx ze~ttiJ|!^=QY>>sz0#p677*>h%GYo+`=LZH1R~;Aou|Z-S24vySEKvt22T3wOrH+P z2v*hc^ij5pxjZeXs+Pj79qsr>`7U~Udh!4vugb6-n| z0}%TdVeK6>OktyN9!k75il4S+cEoNNq68_R+Om{*Y#K{PkRmqL8!MMSbB!t1Etlit zTW6`vZ@CB2K>*E2o1>Y?@$DqF`S$*M-w}&Qjr^>(!urMOf8m*m4H}}id6nyMCAX@> zVdD0WmX$a8zG!(VABeV71cVc3S8b2wEkIn;!^@PN0vW*YK~8w~N^xnq=v3$I`EZt> zyo?Pe_xbs*ig18&t2L}IA|w8A{+n4`Ug8et)D^YzZG^PR{6DK2h!V+EY;!_$E)jp{ zYjVC!T&zxnIdh-})S0vPyw;p^_SXpdga>4lg(~fa`k+PxS6#d;;B?izfMpHS;h!uf zn?o?0`pnmW7Yf*IvUU;kr|m~NcX`bBp%6GW2#pV15m*8i#w=`* z)yY{%8CWXIgsH-X8=H}bGW&89O7*E3;4Z7U_dSEPGyN|rLJ9j~;1TaF%LS?{IWsk2 z+7~Ig9&#RB_UJVmpFnAwA)FemzDg=4bqw1@z@FzwQ}i}7yN4^9zTxzM#mJyTg218_ zac4Hjiv1A`mdOAs@T~as)yE%)iP5RN%hcf<>0hrjm7+W1K+o`Q@wO?|45xXo;s>u} zRo@9<58WuWZw?txO~S99iXWC7Na~I948VtWc|-85 z(oPa-<9H`MMGey0^p9D#=0tREW=B24Fh-~41D%E{oGyy8#~SeRBOs++v>DUfKaF;8 zu?e9U!6d$n#Kk;jxIh2^SRfOjG|FT$hc{|kWA)>cZ@3imhijEIpL60M4ROVshW-$Y z%WY~oq>}Pt%X(NNF@@ciUVAzPvMOLBx+bi>F7;qBVSjRTC!tAHF@XNe+Rd3dw~bnh z_NngwYQli%I6tH4{Xj_L880sX3r=U?edSun_+!_8+j(TkoY&mUHCtdn;*kGdOwI&w zn+D=s=YjBFC z7nru3=|srFt-N6*0o?&agecyg80Gsqa^`qUxt8UxlzHJ&d}YoPk*f&@xxvLXRtJj= zH@3o^J97VQx}=uaVkrI%(XxLvsC`I;Io%{Iw~-@QUgZ0jN@Ilvk~d_ zJ~SO-A>cc#Kp0Todz-c%Gte0yUoLu1v99*80P;?Gob_mhLa-CDwQfDrjHeI{8 z&vR6IcDC1^1zm`29j2jyIPB^;nF$ZA#elCrGod`vs43+M zR~Hdwa>X|15eg(=-=xB<^s|rHeGd8%&3)FHe3m=3i{vyViM|;j!wNrKEgnwlSCV+= zZ>@7%>uUL)mOaMl0EJOfhagJ}k@8Ivs=Uc?RwtDou||QWJPA#*wq|qqDWEPc@1Zx- zCp@d-S{UN%mKHjVmSUJ9r#Igr&(u6-9QPL#kR(4!)wp&ytlmYn4pGte(*hxS1y3iC zsyD>}1{>uJ*u4hZKm+u6f-53~NIg{j1mvpYT?3a)i*fnWluDK-REO`#QUC^QmJzLe zS{v46TjSX-!pA>HO@D@;V(VobqY&W!^HwuG+qw%j`WYwM8mcT~?XrrRxZS@cO;#HG+`6P=;dw1IdUbJHgJ*J=UQ+ z&u0#<(I6d%o6wT)e4U(-7U=>lS*Q#%A+9CPW z~6!1lTW68iw5ZC}2(VfT-nPfL0 z+*FS*D*#u;Htq|c_2DwJPE~f-l0-LA>V&U?A&4DCBP)M%tx2jS`cu;Pan$DDXujD> zhxL@KMjnHw=yfiqFZ}E(z%lcYq45zL$Ff#J0L^d`DY4U+DMp>1%Z?O>)@Cs_3>p1} z5wS{Ibhw!QAT@zLcOWc1pPJ7wUMpqp4tjl;DOS7F;q0Y@OVf^1N$pL<6S)T%lFx{- zWbLe7O2k1pB%t=eWI|*P74xN=Hj%qxQ&>Agx3)!JlZZfbxTXhf@Xj}82aCUI)!e+T zXUn66xbv5niG>Eu!ig+TYU$Xav_rrs_nch-Q(#K5Zo=kQ;cuf2B6_+v3_}A8ZjjvP z%sEqJa1G(oxxiH@tKn~>4I+BJqV7}U2-H5E$wuh@9SQM0l*p|;0;O{2UmqdbimLL`?iVnw7`opvj>CukCGX|x;FG$s@P4co7pu0 zVMgXPXeW1JB7cO*74hUZigQ6AwPn!3RWyQ&ljzTXNN?y?0)8;Vh$(hK6#;oU|BH=(x$W(eqq5-+Aggs94s0u#?W;5 zlBcohPWn5d;Q?&oW@Z_8TNOY(xA-#avNmRe!iUvBD<`um?fm1o>lt!3tm9nbykm+} z=DgNfpMU)MYF?!qX#eH3XD&+1GDe;x!8bY?F>B^G`7$2J z=u+z*1yi7Uz3HUfL5H&tBKTp{{yqz$ zrKa@>b;dui_bnY&YOe2^ojlQj0)??=R-2a8>@#b78Mc$I!qg*8gVXAxwF6EAgIFP2 zqlkIt>HQScoGC+9M9%1YzD|-*x2ySCDbx-QIFZ^|VNJXajZ+A(3$U8@74$v`0+E!= zHT_$VNrO)8xjayLa^|N36qRuj+c`miX}HbHK8eJ1F8Xg{wd>}6FbP4%c5<*FbCc6q zG-ZXQ>~v0s9WPxFU;xB@&z9&}f$?OqD8J{a3@UNl(uPgXIc(-XkUudW*pYRXGcXI) z%=sY;|1+Wvy!((8K*gJ&Ak-l@f~YUHcg>yOz28#`OxH`ycN6)2i;kr{Q}(_}X<7mD zTaq~2MZW<&WN}>p#@BdL9X|K)IGq=;SND$Swpq3?7D;qiyZhqw()~wvm?aTD5>}5g zYa?l>(V?A3LXbkr^Kgjpe`}yQ3_~sSS+$>)svYb_Hw;cHkAol+x%W8z0FN)#tFmu+ z8eeHVYZJ*8^ypW?Kr|Rwf_G;OSPn>j5G#56k3+>CZJhpCI)vEFR%v7O)nPJG$O(JC z5F_(Mg%}gzAs;}(-1AHuw|*cmTOJu4d>8EqE7}_~X$~Vx)$=2R<;Qp)=}v#XAl%gW zV>{YPmxw((aSH+Ov8|}f@dS@j;6W^aN1$>hM(IV zWs)cI4sRf?m{7aOFX;qVRNf4yk=B&{ zTZhuxrGU|lGh@3}&Yzd60O2VbigF61^VRPk!)c?r>L(?)MW4ENQo5?aH5i(zDUL59 zOJpG(!CJyLfbI92;~&Nz-$g!=h7clY4HQLdglpSMDf;O9SL@$%YowJD8J18AS4*4> z7OH!W+lT6ng<=OM2YJ-@2d=UW#m2}g&1Hr*ytI#G?y8U7BSY5THs<(`0|=+)GWmss z`YdB8{Lw!ETc@`%d7=^erJ1uZX8+LC8t-AmKMfvV& z(*yPI(=d$$lf!)wH3ar8o*}utJQeJnJ2`?Ww*kf^z$fg?WdDuzZ>PK;S`XMplG0x68w}mjEojHJ?nClwcc?+4pUl{LSn^&rYy-AxNcq8lX65?7 z>xqj_w`@N~8FNg%uto^b3D4G(Bp?HfxIG&^Cs;xiibrN92nkzWBedwo;{zz)h3&5b zcRWBMEd=}*Pg4ZUS4qy5lEnXf1UpjVpBP&t!a+Ssz3%*~IiZUBmt2SIAXG&(d%1XM z)Vh*|VQamjq(W3rM9-+uX@KESNQ@Fc(CAdCjG^0%oT1KG8W0!n`(}RS75EcQBbE>6 zY1Ka%VHh18e7_)`7`)@$W+62TO|t_rd)uYKr2wZNkQ7@{Kl&ovR=V?i4OI8iD-#PI zrbDA1;#Sl^?|6@kT(osm$6#Ze4G@OQ*=boUOdVTQPoUw2Iv>;u+-soLH{Lm|MiF33 zO;MlOYiiBSJNe)-h-8~lNC(g$Jg9F;uTqXBJFY9G^eFkJ?O+c$@Roh)ww*H{His?+ z)+X{(Vdaw!4$ABn@2xLktSgX$TyC98h1VGgzKVQmaB*f~E}vNV+RppZKZQ(=bI})Z zIq{PSfTk7(I$SMRo?+e2C#GA}61QO@`2=lBHb?G)3DAMUiIi`cDKapOO-HH&AP_zBzhrMudAYGDOUX^&pLpPqdJTRdZ| z9Q@55fjfk>$>>8E8{6d&+X)?(BG9W7pkkq#X>mz!X8;p5k_4+Wc?7i#)P5&d+y!nR zNG8?ux?o8-%bPwGcP&OMYTQy44c9WwQ9_xoh-e!@L#~iZU{d@&rBI2M4gNKSQ!&69 zPcQ4vpgja`Y+dEu%TULKC092r?xpN%Fd?~M{H06;_%2!VG3&r2)H_A7JwnMz#nnLU zXR-AW&z50W59B{i*w~pu(boeoOOiQ|3N#s!T=uAtFOq+7GV}1Awn|lLbq9bBJ27RN z6A9ZzvIEjYLe_3}li&?Avs$iyFs)B}0QV#IR=V9nKmAR|W$7l2OTvRahj z5--(}%~GZ&4PE$7wmdWwUjw&l%3cBPv-yZM{l=fsD$cwE5a&nSX~-Z)0UTrF!}qVB z1;CNUy#lSJ&Nvv~*Rq8&5fVR88O=o&nekng$;?@N<}4;-HylFI@<$jv#|GEPa4%^vf7&~+eBvH0@*C28!xN~e9D4JXkf*8DsnazWm=q5Vya zhMq^dIGZPrs^YK_-hst3T*(cxr^zUC0xG(^b?_#fM=T&3+$hR=6W4N&UGm0pzzU=zv~g3#np``8O!9Ab;$F$~_wdbe}2r?5e$%4#jUD>|+}IsKA;4+-0Y7LOue)Dk&ydi)OmD3cxLx zn47nr+$MiBj&P_#e(gweSHioblVZh(v*TU+UeE9x!8Nf!c&6zpOaL9B@f@N&G|^c- z8FHs3zOM+j^h_Q#Xdb|cJ#(BU2C)t|qC=7%EGdM1kf)GdP0?^4>6QQp z_|od8W<9M@eZSt*+-l6iU3pL*zHoa11Q=DUfL43vgsSUQ}Z zoT)5Nbc5K-vBnW#x%D8Y2&qp9dIe?i5R|SSEX>U_O}U#N+;9~`Xvhy^^SZ&7FPRfaLMLDv&y!F zmb!zi*ai-uJ$oUL8L2${iPCVI#_tH(S5@Z3JpjM$3I*z#!P%dW2l99H7lGPJ1z{lZ z)cd_f*6@}x*xi!1x|}7k#EVd4t{F#7)RI8paapQ<~3h=+l1*WtoyDPhF<|L5BW=@WJo4wWVG6ZYKc2Ga%$=iEYRjP0f zOF*J{ExpetL@%2g3`Pb z5C^%N(s~1u_U1#z6|WY4H{TJ7SDXZ?sWn`Wm-fA?76!(Tdi(c|)(g+?M@8;uSfm={ zHFN+0U5OF$rvpW-xc~Ttj+;4iG5Pet#1f81r{_X_vvxc=n&yM~YO3!Dr)nM!mlLXb zthpuf>q&Kh2}|VwEM>gUB*S7SigTmK4t+<=04q3`1|;W8h$=2T5+a~fPlz?cww#eh zJ8l{XLl}e<_fmzLZ(npUo?S$VMyee*XPc0CLvuoS?_g`dDom2KtFuq;6?zmTnuldf zlyPs*qJc2Oszr~f(EVw6wnR1H?Y)~?p}Lcsngpg$%hWI_8z0%1>m%+P=7{vtrYW%e zo8UlW3w_&sC236&s&M6C_+%Pz-bkP)#QC-R$ z_mtMvr~B}`j(9YJ6sGPCww?`nv1uioenuh-=2P>+#gs_33iOxs%-+i6?jCIMHy6#6f+}rSFWU) zDU_k>W=A_)PR^;3LE6l?lyBLT+cTf|tlRcps!_OlUYs=Inpx&ZTZcbQw{r^u8IR

    - -
    - -
    - -
    OIDC Configuration
    - - <%= fields_for :oidc_configuration, oidc_configuration do |oidc_form| %> -
    - - <%= oidc_form.text_field :issuer, class: "input input-bordered", required: true, placeholder: "https://your-idp.com" %> - -
    - -
    - - <%= oidc_form.text_field :client_id, class: "input input-bordered", required: true %> -
    - -
    - - <%= oidc_form.password_field :client_secret, class: "input input-bordered", required: true %> -
    - -
    - - <%= oidc_form.text_field :scopes, class: "input input-bordered", placeholder: "openid email profile" %> - -
    - -
    - - Advanced Settings (Optional) - -
    -
    - - <%= oidc_form.text_field :authorization_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> -
    - -
    - - <%= oidc_form.text_field :token_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> -
    - -
    - - <%= oidc_form.text_field :userinfo_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> -
    - -
    - - <%= oidc_form.text_field :jwks_uri, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> -
    -
    -
    - <% end %> - - -<% end %> diff --git a/app/views/accounts/sso_providers/edit.html.erb b/app/views/accounts/sso_providers/edit.html.erb index fbb4b760..4cdd0f3e 100644 --- a/app/views/accounts/sso_providers/edit.html.erb +++ b/app/views/accounts/sso_providers/edit.html.erb @@ -5,9 +5,41 @@
    - Update your OIDC provider configuration. + Update your <%= @sso_provider.ldap? ? "LDAP" : "OIDC" %> provider configuration.
    - <%= render "accounts/sso_providers/form", sso_provider: @sso_provider, oidc_configuration: @oidc_configuration %> + <%= form_with model: @sso_provider, url: sso_provider_path do |form| %> + <%= render "shared/error_messages", resource: form.object %> + +
    + + <%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %> + +
    + +
    + +
    + +
    <%= @sso_provider.ldap? ? "LDAP" : "OIDC" %> Configuration
    + + <% if @sso_provider.ldap? %> + <%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %> + <% else %> + <%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %> + <% end %> + + + <% end %> <% end %> <% end %> diff --git a/app/views/accounts/sso_providers/ldap/_form_fields.html.erb b/app/views/accounts/sso_providers/ldap/_form_fields.html.erb new file mode 100644 index 00000000..03a9f6e8 --- /dev/null +++ b/app/views/accounts/sso_providers/ldap/_form_fields.html.erb @@ -0,0 +1,110 @@ +<%= fields_for :ldap_configuration, ldap_configuration do |ldap_form| %> +
    + + <%= ldap_form.text_field :host, class: "input input-bordered", required: true, placeholder: "ldap.example.com" %> + +
    + +
    + + <%= ldap_form.number_field :port, class: "input input-bordered", required: true, placeholder: "389" %> + +
    + +
    + + <%= ldap_form.text_field :base_dn, class: "input input-bordered", required: true, placeholder: "dc=example,dc=com" %> + +
    + +
    + + <%= ldap_form.select :encryption, + LDAPConfiguration.encryptions.keys.map { |key| [key.titleize, key] }, + {}, + class: "select select-bordered" %> + +
    + +
    Bind Credentials (Optional)
    + +
    + + <%= ldap_form.text_field :bind_dn, class: "input input-bordered", placeholder: "cn=admin,dc=example,dc=com" %> + +
    + +
    + + <%= ldap_form.password_field :bind_password, class: "input input-bordered" %> +
    + +
    + + Advanced Settings (Optional) + +
    +
    + + <%= ldap_form.text_field :uid_attribute, class: "input input-bordered", placeholder: "uid" %> + +
    + +
    + + <%= ldap_form.text_field :email_attribute, class: "input input-bordered", placeholder: "mail" %> + +
    + +
    + + <%= ldap_form.text_field :name_attribute, class: "input input-bordered", placeholder: "cn" %> + +
    + +
    + + <%= ldap_form.text_field :filter, class: "input input-bordered", placeholder: "(objectClass=person)" %> + +
    +
    +
    +<% end %> diff --git a/app/views/accounts/sso_providers/new.html.erb b/app/views/accounts/sso_providers/new.html.erb index 1e0fb228..0cdc690a 100644 --- a/app/views/accounts/sso_providers/new.html.erb +++ b/app/views/accounts/sso_providers/new.html.erb @@ -1,13 +1,50 @@ <%= settings_layout do %> <%= turbo_frame_tag "sso_provider" do %>
    - Add OIDC Provider + Add <%= @provider_type == "ldap" ? "LDAP" : "OIDC" %> Provider
    - Configure an OpenID Connect (OIDC) provider for SSO authentication. This will allow users to sign in using your organization's identity provider. + <% if @provider_type == "ldap" %> + Configure an LDAP directory for SSO authentication. This will allow users to sign in using their LDAP credentials. + <% else %> + Configure an OpenID Connect (OIDC) provider for SSO authentication. This will allow users to sign in using your organization's identity provider. + <% end %>
    - <%= render "accounts/sso_providers/form", sso_provider: @sso_provider, oidc_configuration: @oidc_configuration %> + <%= form_with model: @sso_provider, url: sso_provider_path do |form| %> + <%= hidden_field_tag :provider_type, @provider_type %> + <%= render "shared/error_messages", resource: form.object %> + +
    + + <%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %> + +
    + +
    + +
    + +
    <%= @provider_type == "ldap" ? "LDAP" : "OIDC" %> Configuration
    + + <% if @provider_type == "ldap" %> + <%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %> + <% else %> + <%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %> + <% end %> + + + <% end %> <% end %> <% end %> diff --git a/app/views/accounts/sso_providers/oidc/_form_fields.html.erb b/app/views/accounts/sso_providers/oidc/_form_fields.html.erb new file mode 100644 index 00000000..b87f9ec1 --- /dev/null +++ b/app/views/accounts/sso_providers/oidc/_form_fields.html.erb @@ -0,0 +1,70 @@ +<%= fields_for :oidc_configuration, oidc_configuration do |oidc_form| %> +
    + + <%= oidc_form.text_field :issuer, class: "input input-bordered", required: true, placeholder: "https://your-idp.com" %> + +
    + +
    + + <%= oidc_form.text_field :client_id, class: "input input-bordered", required: true %> +
    + +
    + + <%= oidc_form.password_field :client_secret, class: "input input-bordered", required: true %> +
    + +
    + + <%= oidc_form.text_field :scopes, class: "input input-bordered", placeholder: "openid email profile" %> + +
    + +
    + + Advanced Settings (Optional) + +
    +
    + + <%= oidc_form.text_field :authorization_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> +
    + +
    + + <%= oidc_form.text_field :token_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> +
    + +
    + + <%= oidc_form.text_field :userinfo_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> +
    + +
    + + <%= oidc_form.text_field :jwks_uri, class: "input input-bordered", placeholder: "Auto-discovered if not set" %> +
    +
    +
    +<% end %> diff --git a/app/views/accounts/sso_providers/show.html.erb b/app/views/accounts/sso_providers/show.html.erb index 8a86767a..67b0580c 100644 --- a/app/views/accounts/sso_providers/show.html.erb +++ b/app/views/accounts/sso_providers/show.html.erb @@ -10,7 +10,7 @@

    <%= @sso_provider.name %>

    - OIDC + <%= @sso_provider.ldap? ? "LDAP" : "OIDC" %> <% if @sso_provider.enabled %> Enabled <% else %> @@ -24,21 +24,40 @@
    - <% if @oidc_configuration %> + <% if @configuration %>
    -
    - Issuer: -
    <%= @oidc_configuration.issuer %>
    -
    -
    - Client ID: -
    <%= @oidc_configuration.client_id %>
    -
    -
    - Scopes: -
    <%= @oidc_configuration.scopes || "openid email profile" %>
    -
    + <% if @sso_provider.oidc? %> +
    + Issuer: +
    <%= @configuration.issuer %>
    +
    +
    + Client ID: +
    <%= @configuration.client_id %>
    +
    +
    + Scopes: +
    <%= @configuration.scopes || "openid email profile" %>
    +
    + <% else %> +
    + Host: +
    <%= @configuration.host %>:<%= @configuration.port %>
    +
    +
    + Base DN: +
    <%= @configuration.base_dn %>
    +
    +
    + UID Attribute: +
    <%= @configuration.uid_attribute %>
    +
    +
    + Encryption: +
    <%= @configuration.encryption.titleize %>
    +
    + <% end %>
    <% end %> @@ -49,7 +68,10 @@ No SSO provider configured for this account. - <%= link_to "+ Add OIDC Provider", new_sso_provider_path, class: "btn btn-primary btn-sm" %> +
    + <%= link_to "+ Add OIDC Provider", new_sso_provider_path(provider_type: "oidc"), class: "btn btn-primary btn-sm" %> + <%= link_to "+ Add LDAP Provider", new_sso_provider_path(provider_type: "ldap"), class: "btn btn-primary btn-sm" %> +
    <% end %> <% end %> <% end %> diff --git a/app/views/devise/sessions/ldap.html.erb b/app/views/devise/sessions/ldap.html.erb new file mode 100644 index 00000000..f02e5a58 --- /dev/null +++ b/app/views/devise/sessions/ldap.html.erb @@ -0,0 +1,67 @@ +
    +
    +
    +

    + Sign in to access your account +

    + + <%= form_with( + model: resource, + as: resource_name, + url: account_sign_in_path(@account.slug), + method: :post, + data: { turbo: false }, + html: { class: "space-y-4" }) do |f| + %> + <% if flash[:alert] %> +
    + + <%= flash[:alert] %> +
    + <% end %> + + <%= render 'shared/error_messages', resource: resource %> + +
    + <%= f.label :username, class: "label" do %> + LDAP Username + <% end %> + <%= f.text_field( + :username, + autofocus: true, + placeholder: "Enter your username", + class: "input input-bordered w-full", + required: true, + ) %> +
    + +
    + <%= f.label :password, class: "label" do %> + LDAP Password + <% end %> + <%= f.password_field( + :password, + autocomplete: "current-password", + placeholder: "Enter your password", + class: "input input-bordered w-full", + required: true, + ) %> +
    + +
    + <%= f.submit "Sign in to #{@account.name}", class: "btn btn-primary w-full" %> +
    + <% end %> + +
    New to <%= @account.name %>?
    + +

    + Contact your account administrator to get access +

    + +
    + <%= link_to "Back to main login", new_user_session_path, class: "link link-primary text-sm" %> +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/settings/_layout.html.erb b/app/views/settings/_layout.html.erb index a14ce47e..3d8e6274 100644 --- a/app/views/settings/_layout.html.erb +++ b/app/views/settings/_layout.html.erb @@ -1,6 +1,6 @@
    - <% if current_account.stack_manager.present? && current_account.stack_manager.stack.provides_authentication? %> + <% if (current_account.stack_manager.present? && current_account.stack_manager.stack.provides_authentication?) || current_account.sso_provider.present? %>

    Account Login URL

    diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index d74342d4..79f49a1b 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -287,7 +287,8 @@ Devise.setup do |config| require Rails.root.join('lib', 'devise', 'strategies', 'ldap_authenticatable') config.warden do |manager| - manager.strategies.add(:ldap_authenticatable, Devise::Strategies::LdapAuthenticatable) + puts "Adding LDAP authenticatable strategy" + manager.strategies.add(:ldap_authenticatable, Devise::Strategies::LDAPAuthenticatable) end # ==> Mountable engine configurations diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ad5df37e..ea25935f 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -14,4 +14,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym "OIDC" inflect.acronym "SSO" + inflect.acronym "LDAP" end diff --git a/db/migrate/20251121043926_create_ldap_configurations.rb b/db/migrate/20251121043926_create_ldap_configurations.rb index 48ad11dd..dc3f0280 100644 --- a/db/migrate/20251121043926_create_ldap_configurations.rb +++ b/db/migrate/20251121043926_create_ldap_configurations.rb @@ -1,9 +1,9 @@ -class CreateLdapConfigurations < ActiveRecord::Migration[7.2] +class CreateLDAPConfigurations < ActiveRecord::Migration[7.2] def change create_table :ldap_configurations do |t| t.string :host, null: false t.integer :port, default: 389, null: false - t.string :encryption, default: "plain" + t.integer :encryption, null: false t.string :base_dn, null: false t.string :bind_dn t.string :bind_password diff --git a/db/schema.rb b/db/schema.rb index 924d3720..a75d0e98 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_21_024136) do +ActiveRecord::Schema[7.2].define(version: 2025_11_21_043926) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -333,6 +333,21 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_21_024136) do t.datetime "updated_at", null: false end + create_table "ldap_configurations", force: :cascade do |t| + t.string "host", null: false + t.integer "port", default: 389, null: false + t.integer "encryption", null: false + t.string "base_dn", null: false + t.string "bind_dn" + t.string "bind_password" + t.string "uid_attribute", default: "uid", null: false + t.string "email_attribute", default: "mail" + t.string "name_attribute", default: "cn" + t.string "filter" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "log_outputs", force: :cascade do |t| t.bigint "loggable_id", null: false t.string "loggable_type", null: false diff --git a/lib/devise/strategies/ldap_authenticatable.rb b/lib/devise/strategies/ldap_authenticatable.rb index 1587cba1..e140029f 100644 --- a/lib/devise/strategies/ldap_authenticatable.rb +++ b/lib/devise/strategies/ldap_authenticatable.rb @@ -1,41 +1,48 @@ -require 'net/ldap' require 'devise/strategies/authenticatable' module Devise module Strategies - class LdapAuthenticatable < Authenticatable + class LDAPAuthenticatable < Authenticatable + def valid? + true + end + def authenticate! if params[:user] - ldap_config = find_ldap_configuration + ldap_configuration = find_ldap_configuration - return fail(:invalid_login) unless ldap_config + return fail(:invalid_login) unless ldap_configuration ldap = Net::LDAP.new( - host: ldap_config.host, - port: ldap_config.port, - encryption: ldap_config.encryption_method + host: ldap_configuration.host, + port: ldap_configuration.port, + encryption: ldap_configuration.encryption_method ) # Build the user DN for authentication - user_dn = build_user_dn(ldap_config, email) + user_dn = build_user_dn(ldap_configuration, username) ldap.auth user_dn, password if ldap.bind # LDAP authentication successful, find or create user - user = User.find_or_create_by(email: email) do |u| - u.password = SecureRandom.hex(32) # Set random password since LDAP handles auth - u.account = find_or_create_account_for_ldap(ldap_config) + email = construct_email(username, ldap_configuration) + user = User.find_or_create_by(email: email) do |user| + password = SecureRandom.hex(32) + user.password = password + user.password_confirmation = password + + AccountUser.create!(account: ldap_configuration.account, user:) end success!(user) else Rails.logger.info "LDAP bind failed for #{email}: #{ldap.get_operation_result.message}" - return fail(:invalid_login) + fail(:invalid_login) end end end - def email - params[:user][:email] + def username + params[:user][:username] end def password @@ -52,22 +59,25 @@ module Devise if account_id sso_provider = SSOProvider.find_by(account_id: account_id, enabled: true) return sso_provider.configuration if sso_provider&.ldap? + else + raise "No account ID provided" end - - # Fallback: try to find by email domain or return first enabled LDAP config - LdapConfiguration.joins(:sso_provider) - .where(sso_providers: { enabled: true }) - .first end - def build_user_dn(ldap_config, email) - # Extract username from email if needed - username = email.split('@').first - + def build_user_dn(ldap_config, username) # Build the DN using the uid attribute and base DN "#{ldap_config.uid_attribute}=#{username},#{ldap_config.base_dn}" end + def construct_email(username, ldap_config) + # If username is already an email, use it as-is + return username if username.include?('@') + + # Otherwise, construct email using mail_domain from config if available + domain = ldap_config.try(:mail_domain) || ldap_config.host + "#{username}@#{domain}" + end + def find_or_create_account_for_ldap(ldap_config) sso_provider = ldap_config.sso_provider sso_provider&.account @@ -75,5 +85,3 @@ module Devise end end end - -Warden::Strategies.add(:ldap_authenticatable, Devise::Strategies::LdapAuthenticatable) diff --git a/spec/factories/ldap_configurations.rb b/spec/factories/ldap_configurations.rb index bf7e136e..928fa10f 100644 --- a/spec/factories/ldap_configurations.rb +++ b/spec/factories/ldap_configurations.rb @@ -1,14 +1,32 @@ +# == Schema Information +# +# Table name: ldap_configurations +# +# id :bigint not null, primary key +# base_dn :string not null +# bind_dn :string +# bind_password :string +# email_attribute :string default("mail") +# encryption :integer not null +# filter :string +# host :string not null +# name_attribute :string default("cn") +# port :integer default(389), not null +# uid_attribute :string default("uid"), not null +# created_at :datetime not null +# updated_at :datetime not null +# FactoryBot.define do factory :ldap_configuration do - host { "MyString" } - port { 1 } - encryption { "MyString" } - base_dn { "MyString" } - bind_dn { "MyString" } - bind_password { "MyString" } - uid_attribute { "MyString" } - email_attribute { "MyString" } - name_attribute { "MyString" } - filter { "MyString" } + host { "ldap.example.com" } + port { 389 } + encryption { "plain" } + base_dn { "ou=users,dc=example,dc=com" } + bind_dn { "cn=admin,dc=example,dc=com" } + bind_password { "password" } + uid_attribute { "uid" } + email_attribute { "mail" } + name_attribute { "cn" } + filter { nil } end end diff --git a/spec/models/ldap_configuration_spec.rb b/spec/models/ldap_configuration_spec.rb index 22dd7518..33c0c2ee 100644 --- a/spec/models/ldap_configuration_spec.rb +++ b/spec/models/ldap_configuration_spec.rb @@ -1,5 +1,23 @@ +# == Schema Information +# +# Table name: ldap_configurations +# +# id :bigint not null, primary key +# base_dn :string not null +# bind_dn :string +# bind_password :string +# email_attribute :string default("mail") +# encryption :integer not null +# filter :string +# host :string not null +# name_attribute :string default("cn") +# port :integer default(389), not null +# uid_attribute :string default("uid"), not null +# created_at :datetime not null +# updated_at :datetime not null +# require 'rails_helper' -RSpec.describe LdapConfiguration, type: :model do +RSpec.describe LDAPConfiguration, type: :model do pending "add some examples to (or delete) #{__FILE__}" end From 00d4650a6a02b03857188146ec8a55420b769b3d Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 24 Nov 2025 14:45:16 -0800 Subject: [PATCH 05/75] added teams --- app/actions/add_ons/visible_to_user.rb | 47 ++++++ app/actions/clusters/visible_to_user.rb | 38 +++++ app/actions/projects/visible_to_user.rb | 47 ++++++ app/avo/resources/team.rb | 13 ++ app/avo/resources/team_membership.rb | 13 ++ app/avo/resources/team_resource.rb | 13 ++ .../teams/team_members_search_controller.rb | 34 ++++ .../teams/team_memberships_controller.rb | 31 ++++ .../teams/team_resources_controller.rb | 44 +++++ app/controllers/accounts/teams_controller.rb | 53 ++++++ .../avo/team_memberships_controller.rb | 4 + .../avo/team_resources_controller.rb | 4 + app/controllers/avo/teams_controller.rb | 4 + .../team_member_search_controller.js | 78 +++++++++ app/models/account.rb | 1 + app/models/add_on.rb | 1 + app/models/cluster.rb | 1 + app/models/concerns/team_accessible.rb | 10 ++ app/models/project.rb | 1 + app/models/team.rb | 32 ++++ app/models/team_membership.rb | 27 ++++ app/models/team_resource.rb | 27 ++++ app/models/user.rb | 2 + .../accounts/account_users/index.html.erb | 10 ++ app/views/accounts/teams/_list.html.erb | 40 +++++ .../teams/_team_memberships_list.html.erb | 34 ++++ .../teams/_team_resources_list.html.erb | 34 ++++ app/views/accounts/teams/edit.html.erb | 39 +++++ app/views/accounts/teams/index.html.erb | 55 +++++++ app/views/accounts/teams/new.html.erb | 39 +++++ app/views/accounts/teams/show.html.erb | 151 ++++++++++++++++++ app/views/settings/_layout.html.erb | 2 +- config/routes.rb | 5 + db/migrate/20251122223743_create_teams.rb | 14 ++ .../20251122224404_create_team_memberships.rb | 12 ++ .../20251122230641_create_team_resources.rb | 12 ++ db/schema.rb | 38 ++++- spec/actions/add_ons/visible_to_user_spec.rb | 112 +++++++++++++ spec/actions/clusters/visible_to_user_spec.rb | 91 +++++++++++ spec/actions/projects/visible_to_user_spec.rb | 105 ++++++++++++ spec/factories/team_memberships.rb | 27 ++++ spec/factories/team_resources.rb | 27 ++++ spec/factories/teams.rb | 27 ++++ spec/models/service_spec.rb | 8 + spec/models/team_membership_spec.rb | 26 +++ spec/models/team_resource_spec.rb | 26 +++ spec/models/team_spec.rb | 26 +++ 47 files changed, 1483 insertions(+), 2 deletions(-) create mode 100644 app/actions/add_ons/visible_to_user.rb create mode 100644 app/actions/clusters/visible_to_user.rb create mode 100644 app/actions/projects/visible_to_user.rb create mode 100644 app/avo/resources/team.rb create mode 100644 app/avo/resources/team_membership.rb create mode 100644 app/avo/resources/team_resource.rb create mode 100644 app/controllers/accounts/teams/team_members_search_controller.rb create mode 100644 app/controllers/accounts/teams/team_memberships_controller.rb create mode 100644 app/controllers/accounts/teams/team_resources_controller.rb create mode 100644 app/controllers/accounts/teams_controller.rb create mode 100644 app/controllers/avo/team_memberships_controller.rb create mode 100644 app/controllers/avo/team_resources_controller.rb create mode 100644 app/controllers/avo/teams_controller.rb create mode 100644 app/javascript/controllers/team_member_search_controller.js create mode 100644 app/models/concerns/team_accessible.rb create mode 100644 app/models/team.rb create mode 100644 app/models/team_membership.rb create mode 100644 app/models/team_resource.rb create mode 100644 app/views/accounts/teams/_list.html.erb create mode 100644 app/views/accounts/teams/_team_memberships_list.html.erb create mode 100644 app/views/accounts/teams/_team_resources_list.html.erb create mode 100644 app/views/accounts/teams/edit.html.erb create mode 100644 app/views/accounts/teams/index.html.erb create mode 100644 app/views/accounts/teams/new.html.erb create mode 100644 app/views/accounts/teams/show.html.erb create mode 100644 db/migrate/20251122223743_create_teams.rb create mode 100644 db/migrate/20251122224404_create_team_memberships.rb create mode 100644 db/migrate/20251122230641_create_team_resources.rb create mode 100644 spec/actions/add_ons/visible_to_user_spec.rb create mode 100644 spec/actions/clusters/visible_to_user_spec.rb create mode 100644 spec/actions/projects/visible_to_user_spec.rb create mode 100644 spec/factories/team_memberships.rb create mode 100644 spec/factories/team_resources.rb create mode 100644 spec/factories/teams.rb create mode 100644 spec/models/team_membership_spec.rb create mode 100644 spec/models/team_resource_spec.rb create mode 100644 spec/models/team_spec.rb diff --git a/app/actions/add_ons/visible_to_user.rb b/app/actions/add_ons/visible_to_user.rb new file mode 100644 index 00000000..a63ded32 --- /dev/null +++ b/app/actions/add_ons/visible_to_user.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module AddOns + class VisibleToUser + extend LightService::Action + + expects :user, :account + promises :add_ons + + executed do |context| + user = context.user + account = context.account + + # If account has no teams, user can see all add_ons + if account.teams.empty? + context.add_ons = AddOn.joins(:cluster).where(clusters: { account_id: account.id }) + next context + end + + # Get user's teams in this account + user_teams = user.teams.where(account: account) + + # If user is not in any teams, they can't see any resources + if user_teams.empty? + context.add_ons = AddOn.none + next context + end + + # Find add_ons accessible via: + # 1. Direct add_on access via team_resources + # 2. Cluster access via team_resources (includes all add_ons in that cluster) + direct_add_on_ids = TeamResource.where( + team: user_teams, + resourceable_type: 'AddOn' + ).pluck(:resourceable_id) + + cluster_ids_via_teams = TeamResource.where( + team: user_teams, + resourceable_type: 'Cluster' + ).pluck(:resourceable_id) + + context.add_ons = AddOn.where(id: direct_add_on_ids) + .or(AddOn.where(cluster_id: cluster_ids_via_teams)) + .distinct + end + end +end diff --git a/app/actions/clusters/visible_to_user.rb b/app/actions/clusters/visible_to_user.rb new file mode 100644 index 00000000..245a701e --- /dev/null +++ b/app/actions/clusters/visible_to_user.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Clusters + class VisibleToUser + extend LightService::Action + + expects :user, :account + promises :clusters + + executed do |context| + user = context.user + account = context.account + + # If account has no teams, user can see all clusters + if account.teams.empty? + context.clusters = Cluster.where(account_id: account.id) + next context + end + + # Get user's teams in this account + user_teams = user.teams.where(account: account) + + # If user is not in any teams, they can't see any resources + if user_teams.empty? + context.clusters = Cluster.none + next context + end + + # Find clusters accessible via direct cluster access via team_resources + cluster_ids = TeamResource.where( + team: user_teams, + resourceable_type: 'Cluster' + ).pluck(:resourceable_id) + + context.clusters = Cluster.where(id: cluster_ids).distinct + end + end +end diff --git a/app/actions/projects/visible_to_user.rb b/app/actions/projects/visible_to_user.rb new file mode 100644 index 00000000..87e8d8ad --- /dev/null +++ b/app/actions/projects/visible_to_user.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Projects + class VisibleToUser + extend LightService::Action + + expects :user, :account + promises :projects + + executed do |context| + user = context.user + account = context.account + + # If account has no teams, user can see all projects + if account.teams.empty? + context.projects = Project.joins(:cluster).where(clusters: { account_id: account.id }) + next context + end + + # Get user's teams in this account + user_teams = user.teams.where(account: account) + + # If user is not in any teams, they can't see any resources + if user_teams.empty? + context.projects = Project.none + next context + end + + # Find projects accessible via: + # 1. Direct project access via team_resources + # 2. Cluster access via team_resources (includes all projects in that cluster) + direct_project_ids = TeamResource.where( + team: user_teams, + resourceable_type: 'Project' + ).pluck(:resourceable_id) + + cluster_ids_via_teams = TeamResource.where( + team: user_teams, + resourceable_type: 'Cluster' + ).pluck(:resourceable_id) + + context.projects = Project.where(id: direct_project_ids) + .or(Project.where(cluster_id: cluster_ids_via_teams)) + .distinct + end + end +end diff --git a/app/avo/resources/team.rb b/app/avo/resources/team.rb new file mode 100644 index 00000000..35963c65 --- /dev/null +++ b/app/avo/resources/team.rb @@ -0,0 +1,13 @@ +class Avo::Resources::Team < Avo::BaseResource + # self.includes = [] + # self.attachments = [] + # self.search = { + # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } + # } + + def fields + field :id, as: :id + field :name, as: :text + field :account, as: :belongs_to + end +end diff --git a/app/avo/resources/team_membership.rb b/app/avo/resources/team_membership.rb new file mode 100644 index 00000000..591d19e2 --- /dev/null +++ b/app/avo/resources/team_membership.rb @@ -0,0 +1,13 @@ +class Avo::Resources::TeamMembership < Avo::BaseResource + # self.includes = [] + # self.attachments = [] + # self.search = { + # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } + # } + + def fields + field :id, as: :id + field :user, as: :belongs_to + field :team, as: :belongs_to + end +end diff --git a/app/avo/resources/team_resource.rb b/app/avo/resources/team_resource.rb new file mode 100644 index 00000000..d88b0445 --- /dev/null +++ b/app/avo/resources/team_resource.rb @@ -0,0 +1,13 @@ +class Avo::Resources::TeamResource < Avo::BaseResource + # self.includes = [] + # self.attachments = [] + # self.search = { + # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } + # } + + def fields + field :id, as: :id + field :team, as: :belongs_to + field :resourceable, as: :text + end +end diff --git a/app/controllers/accounts/teams/team_members_search_controller.rb b/app/controllers/accounts/teams/team_members_search_controller.rb new file mode 100644 index 00000000..df88087c --- /dev/null +++ b/app/controllers/accounts/teams/team_members_search_controller.rb @@ -0,0 +1,34 @@ +class Accounts::Teams::TeamMembersSearchController < ApplicationController + before_action :set_team + + def index + query = params[:q].to_s.strip + + # Get users in the account who are NOT already in this team + users = if query.present? + current_account.users + .where.not(id: @team.users.pluck(:id)) + .where("email ILIKE ? OR first_name ILIKE ? OR last_name ILIKE ?", + "%#{query}%", "%#{query}%", "%#{query}%") + .limit(10) + else + [] + end + + render json: users.map { |user| + { + id: user.id, + email: user.email, + name: user.name, + first_name: user.first_name, + last_name: user.last_name + } + } + end + + private + + def set_team + @team = current_account.teams.friendly.find(params[:team_id]) + end +end diff --git a/app/controllers/accounts/teams/team_memberships_controller.rb b/app/controllers/accounts/teams/team_memberships_controller.rb new file mode 100644 index 00000000..beaeac93 --- /dev/null +++ b/app/controllers/accounts/teams/team_memberships_controller.rb @@ -0,0 +1,31 @@ +class Accounts::Teams::TeamMembershipsController < ApplicationController + before_action :set_team + + def create + user = User.find(team_membership_params[:user_id]) + @team_membership = @team.team_memberships.new(user: user) + + if @team_membership.save + redirect_to team_path(@team), notice: "Member was successfully added to team." + else + redirect_to team_path(@team), alert: "Failed to add member to team." + end + end + + def destroy + @team_membership = @team.team_memberships.find(params[:id]) + @team_membership.destroy + + redirect_to team_path(@team), notice: "Member was successfully removed from team." + end + + private + + def set_team + @team = current_account.teams.friendly.find(params[:team_id]) + end + + def team_membership_params + params.permit(:user_id) + end +end diff --git a/app/controllers/accounts/teams/team_resources_controller.rb b/app/controllers/accounts/teams/team_resources_controller.rb new file mode 100644 index 00000000..a97009d1 --- /dev/null +++ b/app/controllers/accounts/teams/team_resources_controller.rb @@ -0,0 +1,44 @@ +class Accounts::Teams::TeamResourcesController < ApplicationController + before_action :set_team + + def create + resourceable = find_resourceable(team_resource_params[:resourceable_type], team_resource_params[:resourceable_id]) + @team_resource = @team.team_resources.new(resourceable: resourceable) + + if @team_resource.save + redirect_to team_path(@team), notice: "Resource access was successfully granted." + else + redirect_to team_path(@team), alert: "Failed to grant resource access." + end + end + + def destroy + @team_resource = @team.team_resources.find(params[:id]) + @team_resource.destroy + + redirect_to team_path(@team), notice: "Resource access was successfully revoked." + end + + private + + def set_team + @team = current_account.teams.friendly.find(params[:team_id]) + end + + def team_resource_params + params.permit(:resourceable_type, :resourceable_id) + end + + def find_resourceable(type, id) + case type + when "Cluster" + current_account.clusters.find(id) + when "Project" + current_account.projects.find(id) + when "AddOn" + current_account.add_ons.find(id) + else + raise "Invalid resourceable type" + end + end +end diff --git a/app/controllers/accounts/teams_controller.rb b/app/controllers/accounts/teams_controller.rb new file mode 100644 index 00000000..f87b59a3 --- /dev/null +++ b/app/controllers/accounts/teams_controller.rb @@ -0,0 +1,53 @@ +class Accounts::TeamsController < ApplicationController + include SettingsHelper + before_action :set_team, only: %i[show edit update destroy] + + def index + @pagy, @teams = pagy(current_account.teams) + end + + def show + @pagy, @team_memberships = pagy(@team.team_memberships) + end + + def new + @team = current_account.teams.new + end + + def create + @team = current_account.teams.new(team_params) + + if @team.save + redirect_to teams_path, notice: "Team was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @team.update(team_params) + redirect_to team_path(@team), notice: "Team was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @team.destroy + + redirect_to teams_path, notice: "Team was successfully destroyed." + end + + private + + def set_team + @team = current_account.teams.friendly.find(params[:id]) + end + + def team_params + params.require(:team).permit(:name) + end +end diff --git a/app/controllers/avo/team_memberships_controller.rb b/app/controllers/avo/team_memberships_controller.rb new file mode 100644 index 00000000..302ed147 --- /dev/null +++ b/app/controllers/avo/team_memberships_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::TeamMembershipsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/team_resources_controller.rb b/app/controllers/avo/team_resources_controller.rb new file mode 100644 index 00000000..e9e2ed4c --- /dev/null +++ b/app/controllers/avo/team_resources_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::TeamResourcesController < Avo::ResourcesController +end diff --git a/app/controllers/avo/teams_controller.rb b/app/controllers/avo/teams_controller.rb new file mode 100644 index 00000000..76d7ecb0 --- /dev/null +++ b/app/controllers/avo/teams_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::TeamsController < Avo::ResourcesController +end diff --git a/app/javascript/controllers/team_member_search_controller.js b/app/javascript/controllers/team_member_search_controller.js new file mode 100644 index 00000000..a42087fc --- /dev/null +++ b/app/javascript/controllers/team_member_search_controller.js @@ -0,0 +1,78 @@ +import AsyncSearchDropdownController from "./components/async_search_dropdown_controller" + +export default class extends AsyncSearchDropdownController { + static values = { + teamId: String, + addUrl: String + } + + async fetchResults(query) { + const url = `/accounts/teams/${this.teamIdValue}/team_members_search?q=${encodeURIComponent(query)}` + const response = await fetch(url, { + headers: { + 'Accept': 'application/json' + } + }) + + if (!response.ok) { + throw new Error('Failed to search team members') + } + + return await response.json() + } + + renderItem(user) { + return ` +
    +
    +
    ${this.escapeHtml(user.name || user.email)}
    + ${user.email !== user.name ? `
    ${this.escapeHtml(user.email)}
    ` : ''} +
    +
    + ` + } + + async onItemSelect(user, itemElement) { + try { + // Add loading state + itemElement.classList.add('opacity-50') + itemElement.innerHTML = ` +
    + + Adding ${this.escapeHtml(user.name || user.email)}... +
    + ` + + // Send POST request to add team member + const response = await fetch(this.addUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.getCsrfToken() + }, + body: JSON.stringify({ user_id: user.id }) + }) + + if (!response.ok) { + throw new Error('Failed to add team member') + } + + // Redirect to reload page (or you could use Turbo Stream) + window.location.reload() + } catch (error) { + console.error('Error adding team member:', error) + alert('Failed to add team member. Please try again.') + itemElement.classList.remove('opacity-50') + } + } + + escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } + + getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.content || '' + } +} diff --git a/app/models/account.rb b/app/models/account.rb index 1112e036..6b9bd68d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -26,6 +26,7 @@ class Account < ApplicationRecord has_many :account_users, dependent: :destroy has_many :users, through: :account_users has_one :stack_manager, dependent: :destroy + has_many :teams, dependent: :destroy has_many :clusters, dependent: :destroy has_many :build_clouds, through: :clusters diff --git a/app/models/add_on.rb b/app/models/add_on.rb index b45459ba..94e30861 100644 --- a/app/models/add_on.rb +++ b/app/models/add_on.rb @@ -24,6 +24,7 @@ # class AddOn < ApplicationRecord include Loggable + include TeamAccessible belongs_to :cluster has_one :account, through: :cluster diff --git a/app/models/cluster.rb b/app/models/cluster.rb index 58571a27..033a4c3c 100644 --- a/app/models/cluster.rb +++ b/app/models/cluster.rb @@ -22,6 +22,7 @@ # class Cluster < ApplicationRecord include Loggable + include TeamAccessible broadcasts_refreshes belongs_to :account diff --git a/app/models/concerns/team_accessible.rb b/app/models/concerns/team_accessible.rb new file mode 100644 index 00000000..8d9e1aa0 --- /dev/null +++ b/app/models/concerns/team_accessible.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module TeamAccessible + extend ActiveSupport::Concern + + included do + has_many :team_resources, as: :resourceable, dependent: :destroy + has_many :teams, through: :team_resources + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 6cb47e2f..4913f38c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -32,6 +32,7 @@ # fk_rails_... (project_fork_cluster_id => clusters.id) # class Project < ApplicationRecord + include TeamAccessible broadcasts_refreshes belongs_to :cluster has_one :account, through: :cluster diff --git a/app/models/team.rb b/app/models/team.rb new file mode 100644 index 00000000..20803e4f --- /dev/null +++ b/app/models/team.rb @@ -0,0 +1,32 @@ +# == Schema Information +# +# Table name: teams +# +# id :bigint not null, primary key +# name :string not null +# slug :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# +# Indexes +# +# index_teams_on_account_id (account_id) +# index_teams_on_account_id_and_name (account_id,name) UNIQUE +# index_teams_on_slug (slug) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# +class Team < ApplicationRecord + extend FriendlyId + friendly_id :name, use: :slugged + + belongs_to :account + has_many :team_memberships, dependent: :destroy + has_many :users, through: :team_memberships + has_many :team_resources, dependent: :destroy + + validates :name, presence: true, uniqueness: { scope: :account_id } +end diff --git a/app/models/team_membership.rb b/app/models/team_membership.rb new file mode 100644 index 00000000..7e2ba12a --- /dev/null +++ b/app/models/team_membership.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: team_memberships +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# team_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_team_memberships_on_team_id (team_id) +# index_team_memberships_on_user_id (user_id) +# index_team_memberships_on_user_id_and_team_id (user_id,team_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (team_id => teams.id) +# fk_rails_... (user_id => users.id) +# +class TeamMembership < ApplicationRecord + belongs_to :user + belongs_to :team + + validates :user_id, uniqueness: { scope: :team_id } +end diff --git a/app/models/team_resource.rb b/app/models/team_resource.rb new file mode 100644 index 00000000..27fa33ce --- /dev/null +++ b/app/models/team_resource.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: team_resources +# +# id :bigint not null, primary key +# resourceable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# resourceable_id :bigint not null +# team_id :bigint not null +# +# Indexes +# +# index_team_resources_on_resourceable (resourceable_type,resourceable_id) +# index_team_resources_on_team_and_resourceable (team_id,resourceable_type,resourceable_id) UNIQUE +# index_team_resources_on_team_id (team_id) +# +# Foreign Keys +# +# fk_rails_... (team_id => teams.id) +# +class TeamResource < ApplicationRecord + belongs_to :team + belongs_to :resourceable, polymorphic: true + + validates :resourceable_id, uniqueness: { scope: [ :team_id, :resourceable_type ] } +end diff --git a/app/models/user.rb b/app/models/user.rb index 4248de9e..d896b989 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -33,6 +33,8 @@ class User < ApplicationRecord has_many :account_users, dependent: :destroy has_many :accounts, through: :account_users, dependent: :destroy has_many :owned_accounts, class_name: "Account", foreign_key: "owner_id", dependent: :destroy + has_many :team_memberships, dependent: :destroy + has_many :teams, through: :team_memberships has_many :providers, dependent: :destroy has_many :clusters, through: :accounts diff --git a/app/views/accounts/account_users/index.html.erb b/app/views/accounts/account_users/index.html.erb index d623b00f..e387186f 100644 --- a/app/views/accounts/account_users/index.html.erb +++ b/app/views/accounts/account_users/index.html.erb @@ -17,6 +17,16 @@
    + +
    + <%= link_to account_users_path, role: "tab", class: "tab tab-active" do %> + Members + <% end %> + <%= link_to teams_path, role: "tab", class: "tab" do %> + Teams + <% end %> +
    +
    diff --git a/app/views/accounts/teams/_list.html.erb b/app/views/accounts/teams/_list.html.erb new file mode 100644 index 00000000..c667322a --- /dev/null +++ b/app/views/accounts/teams/_list.html.erb @@ -0,0 +1,40 @@ +
    + + + + + + + + + + + <% teams.each do |team| %> + + + + + + + <% end %> + +
    + Name + + Members + + Resources +
    +
    <%= team.name %>
    +
    +
    <%= team.users.count %>
    +
    +
    <%= team.team_resources.count %>
    +
    +
    + <%= button_to team_path(team), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %> + + <% end %> +
    +
    +
    diff --git a/app/views/accounts/teams/_team_memberships_list.html.erb b/app/views/accounts/teams/_team_memberships_list.html.erb new file mode 100644 index 00000000..a2fc2c85 --- /dev/null +++ b/app/views/accounts/teams/_team_memberships_list.html.erb @@ -0,0 +1,34 @@ +
    + + + + + + + + + + <% team_memberships.each do |team_membership| %> + + + + + + <% end %> + +
    + Name + + Email +
    +
    <%= team_membership.user.name %>
    +
    +
    <%= team_membership.user.email %>
    +
    +
    + <%= button_to team_team_membership_path(team_membership.team, team_membership), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %> + + <% end %> +
    +
    +
    diff --git a/app/views/accounts/teams/_team_resources_list.html.erb b/app/views/accounts/teams/_team_resources_list.html.erb new file mode 100644 index 00000000..221a08dd --- /dev/null +++ b/app/views/accounts/teams/_team_resources_list.html.erb @@ -0,0 +1,34 @@ +
    + + + + + + + + + + <% team.team_resources.each do |team_resource| %> + + + + + + <% end %> + +
    + Type + + Name +
    +
    <%= team_resource.resourceable_type %>
    +
    +
    <%= team_resource.resourceable.name %>
    +
    +
    + <%= button_to team_team_resource_path(team, team_resource), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %> + + <% end %> +
    +
    +
    diff --git a/app/views/accounts/teams/edit.html.erb b/app/views/accounts/teams/edit.html.erb new file mode 100644 index 00000000..930314c6 --- /dev/null +++ b/app/views/accounts/teams/edit.html.erb @@ -0,0 +1,39 @@ +<%= settings_layout do %> + <%= content_for :title, "Edit Team" %> + +
    +
    +

    Edit Team

    +
    +
    + + <%= current_account.name %> +
    +
    +
    + +
    +
    +
    + <%= form_with(model: @team, url: team_path(@team), method: :patch) do |form| %> +
    + + <%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0" %> + <% if @team.errors[:name].any? %> + + <% end %> +
    + + <% end %> +
    +
    +
    +
    +<% end %> diff --git a/app/views/accounts/teams/index.html.erb b/app/views/accounts/teams/index.html.erb new file mode 100644 index 00000000..45d7b9cf --- /dev/null +++ b/app/views/accounts/teams/index.html.erb @@ -0,0 +1,55 @@ +<%= settings_layout do %> + <%= content_for :title, "Team Members" %> + <%= turbo_stream_from [:teams, current_account] %> + +
    +
    +

    Team Members

    +
    +
    + + <%= current_account.name %> +
    +
    + + <%= current_account.users.count %> Member<%= "s" if current_account.users.count != 1 %> +
    +
    + + <%= current_account.teams.count %> Team<%= "s" if current_account.teams.count != 1 %> +
    +
    +
    + + +
    + <%= link_to account_users_path, role: "tab", class: "tab" do %> + Members + <% end %> + <%= link_to teams_path, role: "tab", class: "tab tab-active" do %> + Teams + <% end %> +
    + +
    +
    +
    +
    + <%= link_to new_team_path, class: "btn btn-primary btn-sm" do %> + + + <% end %> +
    + +
    + <%= tag.div id: ("teams" if @pagy.page == 1) do %> + <%= render "accounts/teams/list", teams: @teams, cached: true %> + <% end %> +
    +
    +
    +
    + + <%= render 'shared/pagination', pagy: @pagy %> +
    +<% end %> diff --git a/app/views/accounts/teams/new.html.erb b/app/views/accounts/teams/new.html.erb new file mode 100644 index 00000000..4e46c15c --- /dev/null +++ b/app/views/accounts/teams/new.html.erb @@ -0,0 +1,39 @@ +<%= settings_layout do %> + <%= content_for :title, "Create Team" %> + +
    +
    +

    Create Team

    +
    +
    + + <%= current_account.name %> +
    +
    +
    + +
    +
    +
    + <%= form_with(model: @team, url: teams_path) do |form| %> +
    + + <%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", placeholder: "Engineering, DevOps, etc." %> + <% if @team.errors[:name].any? %> + + <% end %> +
    + + <% end %> +
    +
    +
    +
    +<% end %> diff --git a/app/views/accounts/teams/show.html.erb b/app/views/accounts/teams/show.html.erb new file mode 100644 index 00000000..3146c6f2 --- /dev/null +++ b/app/views/accounts/teams/show.html.erb @@ -0,0 +1,151 @@ +<%= settings_layout do %> + <%= content_for :title, @team.name %> + <%= turbo_stream_from [:team_memberships, @team] %> + +
    +
    +
    +
    +

    <%= @team.name %>

    +
    +
    + + <%= current_account.name %> +
    +
    + + <%= @team.users.count %> Member<%= "s" if @team.users.count != 1 %> +
    +
    +
    +
    + <%= link_to edit_team_path(@team), class: "btn btn-ghost btn-sm" do %> + + Edit + <% end %> +
    +
    +
    + + +
    +
    +
    +
    +

    Team Members

    + +
    + +
    + <%= tag.div id: ("team_memberships" if @pagy.page == 1) do %> + <%= render "accounts/teams/team_memberships_list", team_memberships: @team_memberships, cached: true %> + <% end %> +
    +
    +
    +
    + + <%= render 'shared/pagination', pagy: @pagy %> + + +
    +
    +
    +
    +

    Team Resources

    + +
    + +
    + <%= render "accounts/teams/team_resources_list", team: @team, cached: true %> +
    +
    +
    +
    +
    + + + + + + + + + + + + +<% end %> diff --git a/app/views/settings/_layout.html.erb b/app/views/settings/_layout.html.erb index 169ccf8a..84feccc3 100644 --- a/app/views/settings/_layout.html.erb +++ b/app/views/settings/_layout.html.erb @@ -27,7 +27,7 @@ Profile <% end %>

    T@r_fK=g^I|C1g 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 $!dX(|qMgVL@jD@AFyl+^M@B2EFLVYJ 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%0AUnS;td*=!r`ujtFIDy`ckBBNnlw=AqafYY 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 I_W#uX16-5|rvLx| literal 0 HcmV?d00001 From 458381fecff618d39ddd2e18364fce0cb511ee1b Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 22 Nov 2025 09:12:10 -0800 Subject: [PATCH 04/75] more implementation for ldap --- Gemfile | 2 + Gemfile.lock | 4 + .../sso_providers/build_sso_configuration.rb | 17 +++ app/actions/sso_providers/create.rb | 20 ++++ .../sso_providers/save_configuration.rb | 20 ++++ app/actions/sso_providers/update.rb | 18 +++ .../sso_providers/update_configuration.rb | 20 ++++ app/avo/resources/ldap_configuration.rb | 34 +++--- .../accounts/sso_providers_controller.rb | 75 ++++++++++-- .../avo/ldap_configurations_controller.rb | 2 +- app/controllers/users/sessions_controller.rb | 19 ++- app/models/ldap_configuration.rb | 44 ++++--- app/models/oidc_configuration.rb | 1 + app/models/sso_provider.rb | 2 +- .../accounts/sso_providers/_form.html.erb | 98 ---------------- .../accounts/sso_providers/edit.html.erb | 36 +++++- .../sso_providers/ldap/_form_fields.html.erb | 110 ++++++++++++++++++ app/views/accounts/sso_providers/new.html.erb | 43 ++++++- .../sso_providers/oidc/_form_fields.html.erb | 70 +++++++++++ .../accounts/sso_providers/show.html.erb | 52 ++++++--- app/views/devise/sessions/ldap.html.erb | 67 +++++++++++ app/views/settings/_layout.html.erb | 2 +- config/initializers/devise.rb | 3 +- config/initializers/inflections.rb | 1 + ...251121043926_create_ldap_configurations.rb | 4 +- db/schema.rb | 17 ++- lib/devise/strategies/ldap_authenticatable.rb | 58 +++++---- spec/factories/ldap_configurations.rb | 38 ++++-- spec/models/ldap_configuration_spec.rb | 20 +++- 29 files changed, 687 insertions(+), 210 deletions(-) create mode 100644 app/actions/sso_providers/build_sso_configuration.rb create mode 100644 app/actions/sso_providers/create.rb create mode 100644 app/actions/sso_providers/save_configuration.rb create mode 100644 app/actions/sso_providers/update.rb create mode 100644 app/actions/sso_providers/update_configuration.rb delete mode 100644 app/views/accounts/sso_providers/_form.html.erb create mode 100644 app/views/accounts/sso_providers/ldap/_form_fields.html.erb create mode 100644 app/views/accounts/sso_providers/oidc/_form_fields.html.erb create mode 100644 app/views/devise/sessions/ldap.html.erb diff --git a/Gemfile b/Gemfile index 16758cb9..0a958770 100644 --- a/Gemfile +++ b/Gemfile @@ -114,3 +114,5 @@ gem "actioncable-enhanced-postgresql-adapter", "~> 1.0" gem 'flipper', '~> 1.2.2' gem 'flipper-active_record', '~> 1.2.2' gem 'flipper-ui', '~> 1.2.2' + +gem "net-ldap", "~> 0.20.0" diff --git a/Gemfile.lock b/Gemfile.lock index ed064f46..3f80664f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -355,6 +355,9 @@ GEM net-imap (0.5.12) date net-protocol + net-ldap (0.20.0) + base64 + ostruct net-pop (0.1.2) net-protocol net-protocol (0.2.2) @@ -724,6 +727,7 @@ DEPENDENCIES kubeclient (~> 4.12) light-service (~> 0.20.0) name_of_person! + net-ldap (~> 0.20.0) noticed (~> 2.9) octokit (~> 10.0) oj (~> 3.16) diff --git a/app/actions/sso_providers/build_sso_configuration.rb b/app/actions/sso_providers/build_sso_configuration.rb new file mode 100644 index 00000000..c84c164e --- /dev/null +++ b/app/actions/sso_providers/build_sso_configuration.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module SSOProviders + class BuildSSOConfiguration + extend LightService::Action + expects :provider_type, :configuration_params + promises :configuration + + executed do |context| + context.configuration = if context.provider_type == "ldap" + LDAPConfiguration.new(context.configuration_params) + else + OIDCConfiguration.new(context.configuration_params) + end + end + end +end diff --git a/app/actions/sso_providers/create.rb b/app/actions/sso_providers/create.rb new file mode 100644 index 00000000..764fe9c0 --- /dev/null +++ b/app/actions/sso_providers/create.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module SSOProviders + class Create + extend LightService::Organizer + + def self.call(account:, sso_provider_params:, configuration_params:, provider_type:) + sso_provider = account.build_sso_provider(sso_provider_params) + + with( + sso_provider: sso_provider, + provider_type: provider_type, + configuration_params: configuration_params + ).reduce( + SSOProviders::BuildSSOConfiguration, + SSOProviders::SaveConfiguration + ) + end + end +end diff --git a/app/actions/sso_providers/save_configuration.rb b/app/actions/sso_providers/save_configuration.rb new file mode 100644 index 00000000..3ab6efc2 --- /dev/null +++ b/app/actions/sso_providers/save_configuration.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module SSOProviders + class SaveConfiguration + extend LightService::Action + expects :sso_provider, :configuration + + executed do |context| + context.sso_provider.configuration = context.configuration + + unless context.configuration.save + context.fail_and_return!("Failed to save configuration", errors: context.configuration.errors) + end + + unless context.sso_provider.save + context.fail_and_return!("Failed to save SSO provider", errors: context.sso_provider.errors) + end + end + end +end diff --git a/app/actions/sso_providers/update.rb b/app/actions/sso_providers/update.rb new file mode 100644 index 00000000..0949674c --- /dev/null +++ b/app/actions/sso_providers/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module SSOProviders + class Update + extend LightService::Organizer + + def self.call(sso_provider:, sso_provider_params:, configuration_params:) + sso_provider.assign_attributes(sso_provider_params) + + with( + sso_provider: sso_provider, + configuration_params: configuration_params + ).reduce( + SSOProviders::UpdateConfiguration + ) + end + end +end diff --git a/app/actions/sso_providers/update_configuration.rb b/app/actions/sso_providers/update_configuration.rb new file mode 100644 index 00000000..1f089171 --- /dev/null +++ b/app/actions/sso_providers/update_configuration.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module SSOProviders + class UpdateConfiguration + extend LightService::Action + expects :sso_provider, :configuration_params + + executed do |context| + configuration = context.sso_provider.configuration + + unless configuration.update(context.configuration_params) + context.fail_and_return!("Failed to update configuration", errors: configuration.errors) + end + + unless context.sso_provider.save + context.fail_and_return!("Failed to save SSO provider", errors: context.sso_provider.errors) + end + end + end +end diff --git a/app/avo/resources/ldap_configuration.rb b/app/avo/resources/ldap_configuration.rb index fc4384f1..72cbadee 100644 --- a/app/avo/resources/ldap_configuration.rb +++ b/app/avo/resources/ldap_configuration.rb @@ -1,21 +1,23 @@ -class Avo::Resources::LdapConfiguration < Avo::BaseResource - # self.includes = [] - # self.attachments = [] - # self.search = { - # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } - # } +class Avo::Resources::LDAPConfiguration < Avo::BaseResource + self.includes = [] def fields field :id, as: :id - field :host, as: :text - field :port, as: :number - field :encryption, as: :text - field :base_dn, as: :text - field :bind_dn, as: :text - field :bind_password, as: :text - field :uid_attribute, as: :text - field :email_attribute, as: :text - field :name_attribute, as: :text - field :filter, as: :text + field :host, as: :text, required: true, help: "LDAP server hostname (e.g., ldap.example.com)" + field :port, as: :number, required: true, help: "LDAP server port (default: 389 for plain/STARTTLS, 636 for LDAPS)" + field :encryption, as: :select, required: true, help: "Encryption method", enum: { + plain: "No encryption", + simple_tls: "LDAPS (SSL/TLS)", + start_tls: "STARTTLS" + }, default: "plain" + field :base_dn, as: :text, required: true, help: "Base DN for user searches (e.g., ou=users,dc=example,dc=com)" + field :bind_dn, as: :text, help: "Bind DN for authentication (optional for anonymous bind)" + field :bind_password, as: :password, help: "Bind password (optional for anonymous bind)" + field :uid_attribute, as: :text, required: true, help: "Attribute for username (e.g., uid, sAMAccountName)", default: "uid" + field :email_attribute, as: :text, help: "Attribute for email address", default: "mail" + field :name_attribute, as: :text, help: "Attribute for full name", default: "cn" + field :filter, as: :textarea, help: "Additional LDAP filter (optional)" + + field :sso_provider, as: :has_one end end diff --git a/app/controllers/accounts/sso_providers_controller.rb b/app/controllers/accounts/sso_providers_controller.rb index a9b95045..f8d7b62c 100644 --- a/app/controllers/accounts/sso_providers_controller.rb +++ b/app/controllers/accounts/sso_providers_controller.rb @@ -2,22 +2,44 @@ module Accounts class SSOProvidersController < ApplicationController def show @sso_provider = current_account.sso_provider - @oidc_configuration = @sso_provider&.configuration + @configuration = @sso_provider&.configuration end def new @sso_provider = current_account.build_sso_provider - @oidc_configuration = OIDCConfiguration.new + @provider_type = params[:provider_type] || "oidc" + + if @provider_type == "ldap" + @ldap_configuration = LDAPConfiguration.new + else + @oidc_configuration = OIDCConfiguration.new + end end def create - @oidc_configuration = OIDCConfiguration.new(oidc_configuration_params) - @sso_provider = current_account.build_sso_provider(sso_provider_params) - @sso_provider.configuration = @oidc_configuration + provider_type = params[:provider_type] || "oidc" + configuration_params = provider_type == "ldap" ? ldap_configuration_params : oidc_configuration_params - if @oidc_configuration.save && @sso_provider.save + result = SSOProviders::Create.call( + account: current_account, + sso_provider_params: sso_provider_params, + configuration_params: configuration_params, + provider_type: provider_type + ) + + if result.success? redirect_to sso_provider_path, notice: "SSO provider created successfully" else + @sso_provider = result.sso_provider + @configuration = result.configuration + @provider_type = provider_type + + if provider_type == "ldap" + @ldap_configuration = @configuration + else + @oidc_configuration = @configuration + end + render :new, status: :unprocessable_entity end end @@ -25,16 +47,36 @@ module Accounts def edit @sso_provider = current_account.sso_provider redirect_to new_sso_provider_path, alert: "No SSO provider configured" unless @sso_provider - @oidc_configuration = @sso_provider&.configuration + @configuration = @sso_provider&.configuration + + if @sso_provider&.ldap? + @ldap_configuration = @configuration + else + @oidc_configuration = @configuration + end end def update @sso_provider = current_account.sso_provider - @oidc_configuration = @sso_provider.configuration + configuration_params = @sso_provider.ldap? ? ldap_configuration_params : oidc_configuration_params - if @oidc_configuration.update(oidc_configuration_params) && @sso_provider.update(sso_provider_params) + result = SSOProviders::Update.call( + sso_provider: @sso_provider, + sso_provider_params: sso_provider_params, + configuration_params: configuration_params + ) + + if result.success? redirect_to sso_provider_path, notice: "SSO provider updated successfully" else + @configuration = @sso_provider.configuration + + if @sso_provider.ldap? + @ldap_configuration = @configuration + else + @oidc_configuration = @configuration + end + render :edit, status: :unprocessable_entity end end @@ -67,5 +109,20 @@ module Accounts :scopes ) end + + def ldap_configuration_params + params.require(:ldap_configuration).permit( + :host, + :port, + :base_dn, + :bind_dn, + :bind_password, + :uid_attribute, + :email_attribute, + :name_attribute, + :filter, + :encryption + ) + end end end diff --git a/app/controllers/avo/ldap_configurations_controller.rb b/app/controllers/avo/ldap_configurations_controller.rb index db3b9ff6..947dd25c 100644 --- a/app/controllers/avo/ldap_configurations_controller.rb +++ b/app/controllers/avo/ldap_configurations_controller.rb @@ -1,4 +1,4 @@ # This controller has been generated to enable Rails' resource routes. # More information on https://docs.avohq.io/3.0/controllers.html -class Avo::LdapConfigurationsController < Avo::ResourcesController +class Avo::LDAPConfigurationsController < Avo::ResourcesController end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index b5199475..9e53ad09 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -43,14 +43,31 @@ class Users::SessionsController < Devise::SessionsController @sso_provider = @account.sso_provider if @account.sso_enabled? if @account.stack_manager&.portainer? render "devise/sessions/portainer" + elsif @account.sso_provider&.ldap? + render "devise/sessions/ldap" else render :new end end def account_create + # If account has SSO provider with LDAP, use LDAP authentication + if @account.sso_provider&.ldap? + session[:ldap_account_id] = @account.id + resource = warden.authenticate(:ldap_authenticatable, scope: :user) + + if resource + sign_in(resource) + session[:account_id] = @account.id + redirect_to after_sign_in_path_for(resource), notice: "Logged in successfully" + else + flash[:alert] = "Invalid email or password" + self.resource = resource_class.new(sign_in_params) + clean_up_passwords(self.resource) + render "devise/sessions/ldap" + end # If account has a stack manager, use Portainer authentication - if @account.stack_manager.present? + elsif @account.stack_manager.present? result = Portainer::Login.execute( username: params[:user][:username], password: params[:user][:password], diff --git a/app/models/ldap_configuration.rb b/app/models/ldap_configuration.rb index 49f78280..428d3a94 100644 --- a/app/models/ldap_configuration.rb +++ b/app/models/ldap_configuration.rb @@ -2,39 +2,37 @@ # # Table name: ldap_configurations # -# id :bigint not null, primary key -# base_dn :string not null -# bind_dn :string -# bind_password :string -# email_attribute :string default("mail") -# encryption :string default("plain") -# filter :string -# host :string not null -# name_attribute :string default("cn") -# port :integer default(389), not null -# uid_attribute :string default("uid"), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint not null, primary key +# base_dn :string not null +# bind_dn :string +# bind_password :string +# email_attribute :string default("mail") +# encryption :integer not null +# filter :string +# host :string not null +# name_attribute :string default("cn") +# port :integer default(389), not null +# uid_attribute :string default("uid"), not null +# created_at :datetime not null +# updated_at :datetime not null # -class LdapConfiguration < ApplicationRecord +class LDAPConfiguration < ApplicationRecord has_one :sso_provider, as: :configuration, dependent: :destroy + has_one :account, through: :sso_provider + + enum :encryption, { plain: 0, simple_tls: 1, start_tls: 2 } validates :host, presence: true validates :port, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :base_dn, presence: true validates :uid_attribute, presence: true - validates :encryption, inclusion: { in: %w[plain simple_tls start_tls] } + # Returns encryption method as symbol for Net::LDAP # Encryption options: plain (no encryption), simple_tls (LDAPS), start_tls (STARTTLS) def encryption_method - case encryption - when "simple_tls" - :simple_tls - when "start_tls" - :start_tls - else - nil - end + return nil if plain? + + encryption.to_sym end def requires_auth? diff --git a/app/models/oidc_configuration.rb b/app/models/oidc_configuration.rb index 3928992b..d1b14a4f 100644 --- a/app/models/oidc_configuration.rb +++ b/app/models/oidc_configuration.rb @@ -16,4 +16,5 @@ # class OIDCConfiguration < ApplicationRecord has_one :sso_provider, as: :configuration, dependent: :destroy + has_one :account, through: :sso_provider end diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index da7a3f84..3d54dd02 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -31,6 +31,6 @@ class SSOProvider < ApplicationRecord end def ldap? - configuration_type == "LdapConfiguration" + configuration_type == "LDAPConfiguration" end end diff --git a/app/views/accounts/sso_providers/_form.html.erb b/app/views/accounts/sso_providers/_form.html.erb deleted file mode 100644 index b1b7df1f..00000000 --- a/app/views/accounts/sso_providers/_form.html.erb +++ /dev/null @@ -1,98 +0,0 @@ -<%= form_with model: sso_provider, url: sso_provider.persisted? ? sso_provider_path : sso_provider_path do |form| %> - <%= render "shared/error_messages", resource: form.object %> - -

  • -
  • +
  • <%= link_to account_users_path do %> Team Members diff --git a/config/routes.rb b/config/routes.rb index 34020f20..d505e985 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,11 @@ Rails.application.routes.draw do resources :accounts, only: [ :create ] do collection do resources :account_users, only: %i[create index destroy], module: :accounts + resources :teams, module: :accounts do + resources :team_memberships, only: %i[create destroy], module: :teams + resources :team_resources, only: %i[create destroy], module: :teams + resources :team_members_search, only: %i[index], module: :teams + end end member do get :switch diff --git a/db/migrate/20251122223743_create_teams.rb b/db/migrate/20251122223743_create_teams.rb new file mode 100644 index 00000000..63922e5c --- /dev/null +++ b/db/migrate/20251122223743_create_teams.rb @@ -0,0 +1,14 @@ +class CreateTeams < ActiveRecord::Migration[7.2] + def change + create_table :teams do |t| + t.string :name, null: false + t.string :slug, null: false + t.references :account, null: false, foreign_key: true + + t.timestamps + end + + add_index :teams, :slug, unique: true + add_index :teams, [ :account_id, :name ], unique: true + end +end diff --git a/db/migrate/20251122224404_create_team_memberships.rb b/db/migrate/20251122224404_create_team_memberships.rb new file mode 100644 index 00000000..c8a07547 --- /dev/null +++ b/db/migrate/20251122224404_create_team_memberships.rb @@ -0,0 +1,12 @@ +class CreateTeamMemberships < ActiveRecord::Migration[7.2] + def change + create_table :team_memberships do |t| + t.references :user, null: false, foreign_key: true + t.references :team, null: false, foreign_key: true + + t.timestamps + end + + add_index :team_memberships, [ :user_id, :team_id ], unique: true + end +end diff --git a/db/migrate/20251122230641_create_team_resources.rb b/db/migrate/20251122230641_create_team_resources.rb new file mode 100644 index 00000000..9f98ce71 --- /dev/null +++ b/db/migrate/20251122230641_create_team_resources.rb @@ -0,0 +1,12 @@ +class CreateTeamResources < ActiveRecord::Migration[7.2] + def change + create_table :team_resources do |t| + t.references :team, null: false, foreign_key: true + t.references :resourceable, polymorphic: true, null: false + + t.timestamps + end + + add_index :team_resources, [ :team_id, :resourceable_type, :resourceable_id ], unique: true, name: 'index_team_resources_on_team_and_resourceable' + end +end diff --git a/db/schema.rb b/db/schema.rb index 71cdc21f..6a7d8603 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do +ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -488,6 +488,38 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do t.index ["account_id"], name: "index_stack_managers_on_account_id", unique: true end + create_table "team_memberships", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "team_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["team_id"], name: "index_team_memberships_on_team_id" + t.index ["user_id", "team_id"], name: "index_team_memberships_on_user_id_and_team_id", unique: true + t.index ["user_id"], name: "index_team_memberships_on_user_id" + end + + create_table "team_resources", force: :cascade do |t| + t.bigint "team_id", null: false + t.string "resourceable_type", null: false + t.bigint "resourceable_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["resourceable_type", "resourceable_id"], name: "index_team_resources_on_resourceable" + t.index ["team_id", "resourceable_type", "resourceable_id"], name: "index_team_resources_on_team_and_resourceable", unique: true + t.index ["team_id"], name: "index_team_resources_on_team_id" + end + + create_table "teams", force: :cascade do |t| + t.string "name", null: false + t.string "slug", null: false + t.bigint "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "name"], name: "index_teams_on_account_id_and_name", unique: true + t.index ["account_id"], name: "index_teams_on_account_id" + t.index ["slug"], name: "index_teams_on_slug", unique: true + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -545,5 +577,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do add_foreign_key "providers", "users" add_foreign_key "services", "projects" add_foreign_key "stack_managers", "accounts" + add_foreign_key "team_memberships", "teams" + add_foreign_key "team_memberships", "users" + add_foreign_key "team_resources", "teams" + add_foreign_key "teams", "accounts" add_foreign_key "volumes", "projects" end diff --git a/spec/actions/add_ons/visible_to_user_spec.rb b/spec/actions/add_ons/visible_to_user_spec.rb new file mode 100644 index 00000000..0e7686ea --- /dev/null +++ b/spec/actions/add_ons/visible_to_user_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +RSpec.describe AddOns::VisibleToUser do + let!(:account) { create(:account) } + let!(:user) { create(:user) } + let!(:cluster) { create(:cluster, account: account) } + + before do + account.users << user + end + + describe '.execute' do + context 'when account has no teams' do + let!(:add_on1) { create(:add_on, cluster: cluster) } + let!(:add_on2) { create(:add_on, cluster: cluster) } + + it 'returns all add_ons in the account' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.add_ons).to match_array([add_on1, add_on2]) + end + end + + context 'when account has teams' do + let(:team) { create(:team, account: account) } + let(:other_team) { create(:team, account: account) } + let!(:add_on1) { create(:add_on, cluster: cluster) } + let!(:add_on2) { create(:add_on, cluster: cluster) } + let!(:add_on3) { create(:add_on, cluster: cluster) } + + context 'when user is not in any teams' do + before do + team # Force creation of team + end + + it 'returns no add_ons' do + result = described_class.execute(user: user, account: account.reload) + + expect(result).to be_success + expect(result.add_ons).to be_empty + end + end + + context 'when user has direct add_on access via team' do + before do + team.users << user + create(:team_resource, team: team, resourceable: add_on1) + end + + it 'returns only add_ons granted to user teams' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.add_ons).to eq([add_on1]) + end + end + + context 'when user has cluster access via team' do + let(:cluster2) { create(:cluster, account: account) } + let!(:add_on4) { create(:add_on, cluster: cluster2) } + let!(:add_on5) { create(:add_on, cluster: cluster2) } + + before do + team.users << user + create(:team_resource, team: team, resourceable: cluster2) + end + + it 'returns all add_ons in the granted cluster' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.add_ons).to match_array([add_on4, add_on5]) + end + end + + context 'when user has both direct add_on and cluster access' do + let(:cluster2) { create(:cluster, account: account) } + let!(:add_on4) { create(:add_on, cluster: cluster2) } + + before do + team.users << user + create(:team_resource, team: team, resourceable: add_on1) + create(:team_resource, team: team, resourceable: cluster2) + end + + it 'returns all accessible add_ons without duplicates' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.add_ons).to match_array([add_on1, add_on4]) + end + end + + context 'when user is in multiple teams with different access' do + before do + team.users << user + other_team.users << user + create(:team_resource, team: team, resourceable: add_on1) + create(:team_resource, team: other_team, resourceable: add_on2) + end + + it 'returns add_ons from all teams user belongs to' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.add_ons).to match_array([add_on1, add_on2]) + end + end + end + end +end diff --git a/spec/actions/clusters/visible_to_user_spec.rb b/spec/actions/clusters/visible_to_user_spec.rb new file mode 100644 index 00000000..98f00d4c --- /dev/null +++ b/spec/actions/clusters/visible_to_user_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +RSpec.describe Clusters::VisibleToUser do + let!(:account) { create(:account) } + let!(:user) { create(:user) } + + before do + account.users << user + end + + describe '.execute' do + context 'when account has no teams' do + let!(:cluster1) { create(:cluster, account: account) } + let!(:cluster2) { create(:cluster, account: account) } + + it 'returns all clusters in the account' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.clusters).to match_array([cluster1, cluster2]) + end + end + + context 'when account has teams' do + let(:team) { create(:team, account: account) } + let(:other_team) { create(:team, account: account) } + let!(:cluster1) { create(:cluster, account: account) } + let!(:cluster2) { create(:cluster, account: account) } + let!(:cluster3) { create(:cluster, account: account) } + + context 'when user is not in any teams' do + before do + team # Force creation of team + end + + it 'returns no clusters' do + result = described_class.execute(user: user, account: account.reload) + + expect(result).to be_success + expect(result.clusters).to be_empty + end + end + + context 'when user has cluster access via team' do + before do + team.users << user + create(:team_resource, team: team, resourceable: cluster1) + end + + it 'returns only clusters granted to user teams' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.clusters).to eq([cluster1]) + end + end + + context 'when user is in multiple teams with different cluster access' do + before do + team.users << user + other_team.users << user + create(:team_resource, team: team, resourceable: cluster1) + create(:team_resource, team: other_team, resourceable: cluster2) + end + + it 'returns clusters from all teams user belongs to' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.clusters).to match_array([cluster1, cluster2]) + end + end + + context 'when user has duplicate cluster access across teams' do + before do + team.users << user + other_team.users << user + create(:team_resource, team: team, resourceable: cluster1) + create(:team_resource, team: other_team, resourceable: cluster1) + end + + it 'returns unique clusters without duplicates' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.clusters).to eq([cluster1]) + end + end + end + end +end diff --git a/spec/actions/projects/visible_to_user_spec.rb b/spec/actions/projects/visible_to_user_spec.rb new file mode 100644 index 00000000..9cf128ee --- /dev/null +++ b/spec/actions/projects/visible_to_user_spec.rb @@ -0,0 +1,105 @@ +require 'rails_helper' + +RSpec.describe Projects::VisibleToUser do + let(:account) { create(:account) } + let(:user) { create(:user) } + let!(:account_user) { create(:account_user, account:, user:) } + + describe '.execute' do + context 'when account has no teams' do + it 'returns all projects in the account' do + cluster = create(:cluster, account:) + project1 = create(:project, cluster:) + project2 = create(:project, cluster:) + + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.projects).to match_array([project1, project2]) + end + end + + context 'when account has teams' do + let(:team) { create(:team, account: account) } + let(:other_team) { create(:team, account: account) } + let!(:project1) { create(:project, cluster: cluster) } + let!(:project2) { create(:project, cluster: cluster) } + let!(:project3) { create(:project, cluster: cluster) } + + context 'when user is not in any teams' do + it 'returns no projects' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.projects).to be_empty + end + end + + context 'when user has direct project access via team' do + before do + team.users << user + create(:team_resource, team: team, resourceable: project1) + end + + it 'returns only projects granted to user teams' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.projects).to eq([project1]) + end + end + + context 'when user has cluster access via team' do + let(:cluster2) { create(:cluster, account: account) } + let!(:project4) { create(:project, cluster: cluster2) } + let!(:project5) { create(:project, cluster: cluster2) } + + before do + team.users << user + create(:team_resource, team: team, resourceable: cluster2) + end + + it 'returns all projects in the granted cluster' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.projects).to match_array([project4, project5]) + end + end + + context 'when user has both direct project and cluster access' do + let(:cluster2) { create(:cluster, account: account) } + let!(:project4) { create(:project, cluster: cluster2) } + + before do + team.users << user + create(:team_resource, team: team, resourceable: project1) + create(:team_resource, team: team, resourceable: cluster2) + end + + it 'returns all accessible projects without duplicates' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.projects).to match_array([project1, project4]) + end + end + + context 'when user is in multiple teams with different access' do + before do + team.users << user + other_team.users << user + create(:team_resource, team: team, resourceable: project1) + create(:team_resource, team: other_team, resourceable: project2) + end + + it 'returns projects from all teams user belongs to' do + result = described_class.execute(user: user, account: account) + + expect(result).to be_success + expect(result.projects).to match_array([project1, project2]) + end + end + end + end +end diff --git a/spec/factories/team_memberships.rb b/spec/factories/team_memberships.rb new file mode 100644 index 00000000..fb1479b2 --- /dev/null +++ b/spec/factories/team_memberships.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: team_memberships +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# team_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_team_memberships_on_team_id (team_id) +# index_team_memberships_on_user_id (user_id) +# index_team_memberships_on_user_id_and_team_id (user_id,team_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (team_id => teams.id) +# fk_rails_... (user_id => users.id) +# +FactoryBot.define do + factory :team_membership do + user { nil } + team { nil } + end +end diff --git a/spec/factories/team_resources.rb b/spec/factories/team_resources.rb new file mode 100644 index 00000000..c9d742d0 --- /dev/null +++ b/spec/factories/team_resources.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: team_resources +# +# id :bigint not null, primary key +# resourceable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# resourceable_id :bigint not null +# team_id :bigint not null +# +# Indexes +# +# index_team_resources_on_resourceable (resourceable_type,resourceable_id) +# index_team_resources_on_team_and_resourceable (team_id,resourceable_type,resourceable_id) UNIQUE +# index_team_resources_on_team_id (team_id) +# +# Foreign Keys +# +# fk_rails_... (team_id => teams.id) +# +FactoryBot.define do + factory :team_resource do + team { nil } + resourceable { nil } + end +end diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb new file mode 100644 index 00000000..ebdabd80 --- /dev/null +++ b/spec/factories/teams.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: teams +# +# id :bigint not null, primary key +# name :string not null +# slug :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# +# Indexes +# +# index_teams_on_account_id (account_id) +# index_teams_on_account_id_and_name (account_id,name) UNIQUE +# index_teams_on_slug (slug) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# +FactoryBot.define do + factory :team do + sequence(:name) { |n| "Team #{n}" } + account + end +end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 251f1778..6c39f1e7 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -18,6 +18,14 @@ # updated_at :datetime not null # project_id :bigint not null # +# Indexes +# +# index_services_on_project_id (project_id) +# +# Foreign Keys +# +# fk_rails_... (project_id => projects.id) +# require 'rails_helper' RSpec.describe Service, type: :model do diff --git a/spec/models/team_membership_spec.rb b/spec/models/team_membership_spec.rb new file mode 100644 index 00000000..8db9862d --- /dev/null +++ b/spec/models/team_membership_spec.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: team_memberships +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# team_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_team_memberships_on_team_id (team_id) +# index_team_memberships_on_user_id (user_id) +# index_team_memberships_on_user_id_and_team_id (user_id,team_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (team_id => teams.id) +# fk_rails_... (user_id => users.id) +# +require 'rails_helper' + +RSpec.describe TeamMembership, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/team_resource_spec.rb b/spec/models/team_resource_spec.rb new file mode 100644 index 00000000..0af69cfd --- /dev/null +++ b/spec/models/team_resource_spec.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: team_resources +# +# id :bigint not null, primary key +# resourceable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# resourceable_id :bigint not null +# team_id :bigint not null +# +# Indexes +# +# index_team_resources_on_resourceable (resourceable_type,resourceable_id) +# index_team_resources_on_team_and_resourceable (team_id,resourceable_type,resourceable_id) UNIQUE +# index_team_resources_on_team_id (team_id) +# +# Foreign Keys +# +# fk_rails_... (team_id => teams.id) +# +require 'rails_helper' + +RSpec.describe TeamResource, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb new file mode 100644 index 00000000..2b2ea8fc --- /dev/null +++ b/spec/models/team_spec.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: teams +# +# id :bigint not null, primary key +# name :string not null +# slug :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# +# Indexes +# +# index_teams_on_account_id (account_id) +# index_teams_on_account_id_and_name (account_id,name) UNIQUE +# index_teams_on_slug (slug) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# +require 'rails_helper' + +RSpec.describe Team, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From 5c036e62971168b586e358e042ea770621ee43e3 Mon Sep 17 00:00:00 2001 From: Celina Lopez Date: Mon, 24 Nov 2025 15:34:51 -0800 Subject: [PATCH 06/75] fix some specs --- spec/actions/add_ons/visible_to_user_spec.rb | 10 +++++----- spec/actions/projects/visible_to_user_spec.rb | 19 ++++++++----------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/spec/actions/add_ons/visible_to_user_spec.rb b/spec/actions/add_ons/visible_to_user_spec.rb index 0e7686ea..0154c73d 100644 --- a/spec/actions/add_ons/visible_to_user_spec.rb +++ b/spec/actions/add_ons/visible_to_user_spec.rb @@ -18,7 +18,7 @@ RSpec.describe AddOns::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.add_ons).to match_array([add_on1, add_on2]) + expect(result.add_ons).to match_array([ add_on1, add_on2 ]) end end @@ -52,7 +52,7 @@ RSpec.describe AddOns::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.add_ons).to eq([add_on1]) + expect(result.add_ons).to eq([ add_on1 ]) end end @@ -70,7 +70,7 @@ RSpec.describe AddOns::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.add_ons).to match_array([add_on4, add_on5]) + expect(result.add_ons).to match_array([ add_on4, add_on5 ]) end end @@ -88,7 +88,7 @@ RSpec.describe AddOns::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.add_ons).to match_array([add_on1, add_on4]) + expect(result.add_ons).to match_array([ add_on1, add_on4 ]) end end @@ -104,7 +104,7 @@ RSpec.describe AddOns::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.add_ons).to match_array([add_on1, add_on2]) + expect(result.add_ons).to match_array([ add_on1, add_on2 ]) end end end diff --git a/spec/actions/projects/visible_to_user_spec.rb b/spec/actions/projects/visible_to_user_spec.rb index 9cf128ee..db607fa6 100644 --- a/spec/actions/projects/visible_to_user_spec.rb +++ b/spec/actions/projects/visible_to_user_spec.rb @@ -4,26 +4,23 @@ RSpec.describe Projects::VisibleToUser do let(:account) { create(:account) } let(:user) { create(:user) } let!(:account_user) { create(:account_user, account:, user:) } + let!(:cluster) { create(:cluster, account:) } + let!(:project1) { create(:project, cluster:, account:) } + let!(:project2) { create(:project, cluster:, account:) } describe '.execute' do context 'when account has no teams' do it 'returns all projects in the account' do - cluster = create(:cluster, account:) - project1 = create(:project, cluster:) - project2 = create(:project, cluster:) - result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.projects).to match_array([project1, project2]) + expect(result.projects).to match_array([ project1, project2 ]) end end context 'when account has teams' do let(:team) { create(:team, account: account) } let(:other_team) { create(:team, account: account) } - let!(:project1) { create(:project, cluster: cluster) } - let!(:project2) { create(:project, cluster: cluster) } let!(:project3) { create(:project, cluster: cluster) } context 'when user is not in any teams' do @@ -45,7 +42,7 @@ RSpec.describe Projects::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.projects).to eq([project1]) + expect(result.projects).to eq([ project1 ]) end end @@ -63,7 +60,7 @@ RSpec.describe Projects::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.projects).to match_array([project4, project5]) + expect(result.projects).to match_array([ project4, project5 ]) end end @@ -81,7 +78,7 @@ RSpec.describe Projects::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.projects).to match_array([project1, project4]) + expect(result.projects).to match_array([ project1, project4 ]) end end @@ -97,7 +94,7 @@ RSpec.describe Projects::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.projects).to match_array([project1, project2]) + expect(result.projects).to match_array([ project1, project2 ]) end end end From c15d8014466d85a0ce7434e536e3bbe64ffb0e23 Mon Sep 17 00:00:00 2001 From: Celina Lopez Date: Mon, 24 Nov 2025 16:04:46 -0800 Subject: [PATCH 07/75] rubocop --- spec/actions/clusters/visible_to_user_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/actions/clusters/visible_to_user_spec.rb b/spec/actions/clusters/visible_to_user_spec.rb index 98f00d4c..2c22d202 100644 --- a/spec/actions/clusters/visible_to_user_spec.rb +++ b/spec/actions/clusters/visible_to_user_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Clusters::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.clusters).to match_array([cluster1, cluster2]) + expect(result.clusters).to match_array([ cluster1, cluster2 ]) end end @@ -51,7 +51,7 @@ RSpec.describe Clusters::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.clusters).to eq([cluster1]) + expect(result.clusters).to eq([ cluster1 ]) end end @@ -67,7 +67,7 @@ RSpec.describe Clusters::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.clusters).to match_array([cluster1, cluster2]) + expect(result.clusters).to match_array([ cluster1, cluster2 ]) end end @@ -83,7 +83,7 @@ RSpec.describe Clusters::VisibleToUser do result = described_class.execute(user: user, account: account) expect(result).to be_success - expect(result.clusters).to eq([cluster1]) + expect(result.clusters).to eq([ cluster1 ]) end end end From cc1415bb05365d4dffe74026319251f5810ffad3 Mon Sep 17 00:00:00 2001 From: Celina Lopez Date: Mon, 24 Nov 2025 16:34:52 -0800 Subject: [PATCH 08/75] add logic to clusters and projects controllers --- app/controllers/add_ons_controller.rb | 6 ++++-- app/controllers/clusters_controller.rb | 6 ++++-- app/controllers/projects_controller.rb | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/controllers/add_ons_controller.rb b/app/controllers/add_ons_controller.rb index ab7911ee..c2dd9905 100644 --- a/app/controllers/add_ons_controller.rb +++ b/app/controllers/add_ons_controller.rb @@ -4,7 +4,8 @@ class AddOnsController < ApplicationController # GET /add_ons def index - @pagy, @add_ons = pagy(current_account.add_ons) + add_ons = AddOns::VisibleToUser.execute(user: current_user, account: current_account).add_ons + @pagy, @add_ons = pagy(add_ons) # Uncomment to authorize with Pundit # authorize @add_ons @@ -118,7 +119,8 @@ class AddOnsController < ApplicationController # Use callbacks to share common setup or constraints between actions. def set_add_on - @add_on = current_account.add_ons.find(params[:id]) + add_ons = AddOns::VisibleToUser.execute(user: current_user, account: current_account).add_ons + @add_on = add_ons.find(params[:id]) @service = K8::Helm::Service.create_from_add_on(K8::Connection.new(@add_on, current_user)) rescue ActiveRecord::RecordNotFound redirect_to add_ons_path diff --git a/app/controllers/clusters_controller.rb b/app/controllers/clusters_controller.rb index 93333935..f3f9a984 100644 --- a/app/controllers/clusters_controller.rb +++ b/app/controllers/clusters_controller.rb @@ -8,7 +8,8 @@ class ClustersController < ApplicationController # GET /clusters def index sortable_column = params[:sort] || "created_at" - @pagy, @clusters = pagy(current_account.clusters.order(sortable_column => "asc")) + clusters = Clusters::VisibleToUser.execute(user: current_user, account: current_account).clusters + @pagy, @clusters = pagy(clusters.order(sortable_column => "asc")) # Uncomment to authorize with Pundit # authorize @clusters @@ -171,7 +172,8 @@ class ClustersController < ApplicationController # Use callbacks to share common setup or constraints between actions. def set_cluster - @cluster = current_account.clusters.find(params[:id]) + clusters = Clusters::VisibleToUser.execute(user: current_user, account: current_account).clusters + @cluster = clusters.find(params[:id]) # Uncomment to authorize with Pundit # authorize @cluster diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 1ec2972a..7bd0bc42 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -6,7 +6,8 @@ class ProjectsController < ApplicationController # GET /projects def index sortable_column = params[:sort] || "created_at" - @pagy, @projects = pagy(current_account.projects.order(sortable_column => "asc")) + projects = Projects::VisibleToUser.execute(user: current_user, account: current_account).projects + @pagy, @projects = pagy(projects.order(sortable_column => "asc")) # Uncomment to authorize with Pundit # authorize @projects @@ -86,7 +87,8 @@ class ProjectsController < ApplicationController # Use callbacks to share common setup or constraints between actions. def set_project - @project = current_account.projects.find(params[:id]) + projects = Projects::VisibleToUser.execute(user: current_user, account: current_account).projects + @project = projects.find(params[:id]) # Uncomment to authorize with Pundit # authorize @project From 1fbbf4af84eee6c7b8b5b54c8b93e61abfc66575 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 25 Nov 2025 17:44:19 -0800 Subject: [PATCH 09/75] updates --- app/controllers/users/sessions_controller.rb | 2 +- app/models/account.rb | 4 ++ db/schema.rb | 38 +++++++++++++++- lib/devise/strategies/ldap_authenticatable.rb | 43 +++++++++++++------ 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 9e53ad09..604242f4 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -19,7 +19,7 @@ class Users::SessionsController < Devise::SessionsController super do # If the account has a stack manager that provides authentication, # redirect to the custom account login URL after logout - redirect_url = if account&.stack_manager&.stack&.provides_authentication? + redirect_url = if account.custom_login? account_sign_in_path(account.slug) else root_path diff --git a/app/models/account.rb b/app/models/account.rb index f41460f0..a636d730 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -52,4 +52,8 @@ class Account < ApplicationRecord def sso_enabled? sso_provider&.enabled? end + + def custom_login? + return stack_manager&.stack&.provides_authentication? || sso_enabled? + end end diff --git a/db/schema.rb b/db/schema.rb index a75d0e98..89b59789 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_21_043926) do +ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -528,6 +528,38 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_21_043926) do t.index ["account_id"], name: "index_stack_managers_on_account_id", unique: true end + create_table "team_memberships", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "team_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["team_id"], name: "index_team_memberships_on_team_id" + t.index ["user_id", "team_id"], name: "index_team_memberships_on_user_id_and_team_id", unique: true + t.index ["user_id"], name: "index_team_memberships_on_user_id" + end + + create_table "team_resources", force: :cascade do |t| + t.bigint "team_id", null: false + t.string "resourceable_type", null: false + t.bigint "resourceable_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["resourceable_type", "resourceable_id"], name: "index_team_resources_on_resourceable" + t.index ["team_id", "resourceable_type", "resourceable_id"], name: "index_team_resources_on_team_and_resourceable", unique: true + t.index ["team_id"], name: "index_team_resources_on_team_id" + end + + create_table "teams", force: :cascade do |t| + t.string "name", null: false + t.string "slug", null: false + t.bigint "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "name"], name: "index_teams_on_account_id_and_name", unique: true + t.index ["account_id"], name: "index_teams_on_account_id" + t.index ["slug"], name: "index_teams_on_slug", unique: true + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -586,5 +618,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_21_043926) do add_foreign_key "services", "projects" add_foreign_key "sso_providers", "accounts" add_foreign_key "stack_managers", "accounts" + add_foreign_key "team_memberships", "teams" + add_foreign_key "team_memberships", "users" + add_foreign_key "team_resources", "teams" + add_foreign_key "teams", "accounts" add_foreign_key "volumes", "projects" end diff --git a/lib/devise/strategies/ldap_authenticatable.rb b/lib/devise/strategies/ldap_authenticatable.rb index e140029f..35621fff 100644 --- a/lib/devise/strategies/ldap_authenticatable.rb +++ b/lib/devise/strategies/ldap_authenticatable.rb @@ -4,6 +4,7 @@ module Devise module Strategies class LDAPAuthenticatable < Authenticatable def valid? + puts "Validating ldap authenticatable" true end @@ -26,12 +27,15 @@ module Devise if ldap.bind # LDAP authentication successful, find or create user email = construct_email(username, ldap_configuration) - user = User.find_or_create_by(email: email) do |user| - password = SecureRandom.hex(32) - user.password = password - user.password_confirmation = password - - AccountUser.create!(account: ldap_configuration.account, user:) + # Determine the groups + groups = get_group_information(username) + ActiveRecord::Base.transaction do + user = User.find_or_create_by!(email: email) do |user| + password = SecureRandom.hex(32) + user.password = password + user.password_confirmation = password + end + AccountUser.find_or_create_by!(account: ldap_configuration.account, user:) end success!(user) else @@ -54,14 +58,16 @@ module Devise def find_ldap_configuration # Find the LDAP configuration for the account # This could be based on email domain or a selection during login - account_id = session[:ldap_account_id] || params[:account_id] - - if account_id - sso_provider = SSOProvider.find_by(account_id: account_id, enabled: true) - return sso_provider.configuration if sso_provider&.ldap? - else - raise "No account ID provided" + account = Account.friendly.find(params[:slug]) + unless account.sso_enabled? + raise "SSO is not enabled for this account" end + + sso_provider = account.sso_provider + unless sso_provider.ldap? + raise "Account does not support LDAP authentication" + end + return sso_provider.configuration end def build_user_dn(ldap_config, username) @@ -78,6 +84,17 @@ module Devise "#{username}@#{domain}" end + def get_group_information(user) + return [ + { + name: "developers", + }, + { + name: "administrators", + }, + ] + end + def find_or_create_account_for_ldap(ldap_config) sso_provider = ldap_config.sso_provider sso_provider&.account From 65200e1c32e52f5ecc76490f60418c38371c0d28 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 25 Nov 2025 21:16:06 -0800 Subject: [PATCH 10/75] refactor namespace to be tracked explicitly --- app/actions/project_forks/fork_project.rb | 2 + app/actions/projects/create.rb | 5 +- app/actions/projects/set_up_namespace.rb | 15 +++ app/actions/projects/validate_namespace.rb | 58 ++++++++++++ .../validate_namespace_availability.rb | 31 ------- app/controllers/clusters_controller.rb | 4 +- .../projects/processes_controller.rb | 6 +- .../projects/services/jobs_controller.rb | 4 +- .../expandable_optional_input_controller.js | 17 ++++ app/jobs/projects/deployment_job.rb | 10 +- app/jobs/projects/destroy_job.rb | 8 +- app/jobs/scheduled/check_health_job.rb | 2 +- app/models/add_on.rb | 23 +++-- app/models/cluster.rb | 2 +- app/models/concerns/namespaced.rb | 7 ++ app/models/project.rb | 5 + app/services/k8/stateless/cron_job.rb | 2 +- app/services/k8/stateless/deployment.rb | 2 +- app/services/k8/stateless/ingress.rb | 4 +- app/services/k8/stateless/service.rb | 2 +- .../projects/processes/index_view_model.rb | 2 +- app/views/add_ons/new.html.erb | 40 ++++---- ...ng_container_registry_credentials.html.erb | 4 +- .../create/_missing_git_credentials.html.erb | 2 +- .../_new_form_container_registry.html.erb | 2 + .../projects/create/_new_form_git.html.erb | 12 +++ app/views/providers/_index.html.erb | 2 +- .../partials/_namespace_input_group.html.erb | 30 ++++++ .../shared/partials/_namespace_show.html.erb | 11 +++ ...4503_add_existing_namespace_to_projects.rb | 20 ++++ db/schema.rb | 83 ++++++++++++++++- lib/portainer/stack.rb | 2 +- resources/k8/secrets/registry_secret.yaml | 2 +- resources/k8/stateless/command.yaml | 2 +- resources/k8/stateless/config_map.yaml | 2 +- resources/k8/stateless/cron_job.yaml | 2 +- resources/k8/stateless/deployment.yaml | 2 +- resources/k8/stateless/ingress.yaml | 2 +- resources/k8/stateless/pod.yaml | 2 +- resources/k8/stateless/pv.yaml | 2 +- resources/k8/stateless/pvc.yaml | 2 +- resources/k8/stateless/secrets.yaml | 2 +- resources/k8/stateless/service.yaml | 2 +- spec/actions/projects/create_spec.rb | 6 +- .../actions/projects/set_up_namespace_spec.rb | 26 ++++++ .../validate_namespace_availability_spec.rb | 53 ----------- .../projects/validate_namespace_spec.rb | 93 +++++++++++++++++++ spec/factories/add_ons.rb | 22 +++-- spec/factories/projects.rb | 4 + spec/models/project_spec.rb | 2 + spec/models/service_spec.rb | 8 ++ spec/services/k8/stateless/cron_job_spec.rb | 2 +- 52 files changed, 490 insertions(+), 167 deletions(-) create mode 100644 app/actions/projects/set_up_namespace.rb create mode 100644 app/actions/projects/validate_namespace.rb delete mode 100644 app/actions/projects/validate_namespace_availability.rb create mode 100644 app/javascript/controllers/expandable_optional_input_controller.js create mode 100644 app/models/concerns/namespaced.rb create mode 100644 app/views/shared/partials/_namespace_input_group.html.erb create mode 100644 app/views/shared/partials/_namespace_show.html.erb create mode 100644 db/migrate/20251126014503_add_existing_namespace_to_projects.rb create mode 100644 spec/actions/projects/set_up_namespace_spec.rb delete mode 100644 spec/actions/projects/validate_namespace_availability_spec.rb create mode 100644 spec/actions/projects/validate_namespace_spec.rb diff --git a/app/actions/project_forks/fork_project.rb b/app/actions/project_forks/fork_project.rb index 1aba5db7..fe83ed08 100644 --- a/app/actions/project_forks/fork_project.rb +++ b/app/actions/project_forks/fork_project.rb @@ -10,6 +10,8 @@ class ProjectForks::ForkProject child_project = parent_project.dup child_project.branch = pull_request.branch child_project.name = "#{parent_project.name}-#{pull_request.number}" + child_project.namespace = child_project.name + child_project.managed_namespace = parent_project.managed_namespace child_project.cluster_id = parent_project.project_fork_cluster_id # Duplicate the project_credential_provider child_project_credential_provider = parent_project.project_credential_provider.dup diff --git a/app/actions/projects/create.rb b/app/actions/projects/create.rb index 602db875..468af95c 100644 --- a/app/actions/projects/create.rb +++ b/app/actions/projects/create.rb @@ -6,6 +6,8 @@ module Projects def self.create_params(params) params.require(:project).permit( :name, + :namespace, + :managed_namespace, :repository_url, :branch, :cluster_id, @@ -68,7 +70,8 @@ module Projects steps << Projects::ValidateGitRepository end - steps << Projects::ValidateNamespaceAvailability + steps << Projects::SetUpNamespace + steps << Projects::ValidateNamespace steps << Projects::InitializeBuildPacks steps << Projects::Save diff --git a/app/actions/projects/set_up_namespace.rb b/app/actions/projects/set_up_namespace.rb new file mode 100644 index 00000000..fa390e49 --- /dev/null +++ b/app/actions/projects/set_up_namespace.rb @@ -0,0 +1,15 @@ +class Projects::SetUpNamespace + extend LightService::Action + expects :project + + executed do |context| + project = context.project + if project.namespace.blank? && project.managed_namespace + # autoset the namespace to the project name + project.namespace = project.name + elsif project.namespace.blank? && !project.managed_namespace + project.errors.add(:base, "A namespace must be provided if it is not managed by Canine") + context.fail_and_return!("Failed to set up namespace") + end + end +end diff --git a/app/actions/projects/validate_namespace.rb b/app/actions/projects/validate_namespace.rb new file mode 100644 index 00000000..c26a522e --- /dev/null +++ b/app/actions/projects/validate_namespace.rb @@ -0,0 +1,58 @@ +module Projects + class ValidateNamespace + extend LightService::Action + + expects :project, :user + + def self.validate_namespace_does_not_exist_or_is_managed( + context, + project, + client, + existing_namespaces + ) + namespace_exists = existing_namespaces.any? do |ns| + ns.metadata.name == project.namespace && ns.metadata&.labels&.caninemanaged != "true" + end + if namespace_exists + error_message = "Namespace `#{project.name}` already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name." + project.errors.add(:name, error_message) + context.fail_and_return!(error_message) + end + end + + def self.validate_namespace_exists( + context, + project, + client, + existing_namespaces + ) + existing_namespace = existing_namespaces.any? do |ns| + ns.metadata.name == project.namespace + end + unless existing_namespace + error_message = "`#{project.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable auto create namespace" + project.errors.add(:base, error_message) + context.fail_and_return!(error_message) + end + end + + executed do |context| + project = context.project + cluster = project.cluster + + begin + client = K8::Client.new(K8::Connection.new(cluster, context.user)) + existing_namespaces = client.get_namespaces + + if project.managed_namespace + validate_namespace_does_not_exist_or_is_managed(context, project, client, existing_namespaces) + else + validate_namespace_exists(context, project, client, existing_namespaces) + end + rescue StandardError => e + # If we can't connect to check, we'll let it proceed and fail later if needed + Rails.logger.warn("Could not check namespace availability: #{e.message}") + end + end + end +end diff --git a/app/actions/projects/validate_namespace_availability.rb b/app/actions/projects/validate_namespace_availability.rb deleted file mode 100644 index 9884c200..00000000 --- a/app/actions/projects/validate_namespace_availability.rb +++ /dev/null @@ -1,31 +0,0 @@ -module Projects - class ValidateNamespaceAvailability - extend LightService::Action - - expects :project, :user - - executed do |context| - project = context.project - cluster = project.cluster - - begin - client = K8::Client.new(K8::Connection.new(cluster, context.user)) - existing_namespaces = client.get_namespaces - - # Check if namespace already exists in Kubernetes - namespace_exists = existing_namespaces.any? do |ns| - ns.metadata.name == project.name && ns.metadata&.labels&.caninemanaged != "true" - end - - if namespace_exists - error_message = "'#{project.name}' already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name." - project.errors.add(:name, error_message) - context.fail_and_return!(error_message) - end - rescue StandardError => e - # If we can't connect to check, we'll let it proceed and fail later if needed - Rails.logger.warn("Could not check namespace availability: #{e.message}") - end - end - end -end diff --git a/app/controllers/clusters_controller.rb b/app/controllers/clusters_controller.rb index 93333935..b717153f 100644 --- a/app/controllers/clusters_controller.rb +++ b/app/controllers/clusters_controller.rb @@ -89,8 +89,8 @@ class ClustersController < ApplicationController %w[services deployments ingress cronjobs].each do |resource| yaml_content = K8::Kubectl.new( K8::Connection.new(@cluster, current_user) - ).call("get #{resource} -n #{project.name} -o yaml") - export(@cluster.name, project.name, yaml_content, zio) + ).call("get #{resource} -n #{project.namespace} -o yaml") + export(@cluster.name, project.namespace, yaml_content, zio) end end end diff --git a/app/controllers/projects/processes_controller.rb b/app/controllers/projects/processes_controller.rb index 9b245219..373d3c13 100644 --- a/app/controllers/projects/processes_controller.rb +++ b/app/controllers/projects/processes_controller.rb @@ -13,8 +13,8 @@ class Projects::ProcessesController < Projects::BaseController def show client = K8::Client.new(active_connection) - @logs = client.get_pod_log(params[:id], @project.name) - @pod_events = client.get_pod_events(params[:id], @project.name) + @logs = client.get_pod_log(params[:id], @project.namespace) + @pod_events = client.get_pod_events(params[:id], @project.namespace) respond_to do |format| format.html @@ -30,7 +30,7 @@ class Projects::ProcessesController < Projects::BaseController def destroy client = K8::Client.new(active_connection) - client.delete_pod(params[:id], @project.name) + client.delete_pod(params[:id], @project.namespace) redirect_to project_processes_path(@project), notice: "Pod #{params[:id]} terminating..." end diff --git a/app/controllers/projects/services/jobs_controller.rb b/app/controllers/projects/services/jobs_controller.rb index d5e6277a..b5472f40 100644 --- a/app/controllers/projects/services/jobs_controller.rb +++ b/app/controllers/projects/services/jobs_controller.rb @@ -7,7 +7,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController job_name = "#{@service.name}-manual-#{timestamp}" kubectl = K8::Kubectl.new(active_connection) kubectl.call( - "-n #{@project.name} create job #{job_name} --from=cronjob/#{@service.name}" + "-n #{@project.namespace} create job #{job_name} --from=cronjob/#{@service.name}" ) render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false end @@ -16,7 +16,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController job_name = params[:id] kubectl = K8::Kubectl.new(active_connection) kubectl.call( - "-n #{@project.name} delete job #{job_name}" + "-n #{@project.namespace} delete job #{job_name}" ) render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false diff --git a/app/javascript/controllers/expandable_optional_input_controller.js b/app/javascript/controllers/expandable_optional_input_controller.js new file mode 100644 index 00000000..43ec28a0 --- /dev/null +++ b/app/javascript/controllers/expandable_optional_input_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["container"] + + connect() { + this.containerTarget.classList.add("hidden", "opacity-0", "transition-all") + } + + show(e) { + e.preventDefault(); + + e.target.classList.add("hidden") + this.containerTarget.classList.remove("hidden", "opacity-0") + this.containerTarget.classList.add("opacity-100", "duration-500") + } +} \ No newline at end of file diff --git a/app/jobs/projects/deployment_job.rb b/app/jobs/projects/deployment_job.rb index cfa040ef..2c0d9357 100644 --- a/app/jobs/projects/deployment_job.rb +++ b/app/jobs/projects/deployment_job.rb @@ -108,11 +108,11 @@ class Projects::DeploymentJob < ApplicationJob end def kill_one_off_containers(project, kubectl) - kubectl.call("-n #{project.name} delete pods -l oneoff=true") + kubectl.call("-n #{project.namespace} delete pods -l oneoff=true") end def apply_namespace(project, kubectl) - @logger.info("Creating namespace: #{project.name}", color: :yellow) + @logger.info("Creating namespace: #{project.namespace}", color: :yellow) namespace_yaml = K8::Namespace.new(project).to_yaml kubectl.apply_yaml(namespace_yaml) end @@ -124,7 +124,7 @@ class Projects::DeploymentJob < ApplicationJob kubectl = K8::Kubectl.new(connection) resources_to_sweep.each do |resource_type| - results = YAML.safe_load(kubectl.call("get #{resource_type.downcase} -o yaml -n #{project.name}")) + results = YAML.safe_load(kubectl.call("get #{resource_type.downcase} -o yaml -n #{project.namespace}")) results['items'].each do |resource| if @marked_resources.select do |r| r.is_a?(K8::Stateless.const_get(resource_type)) @@ -132,7 +132,7 @@ class Projects::DeploymentJob < ApplicationJob applied_resource.name == resource['metadata']['name'] end && resource.dig('metadata', 'labels', 'caninemanaged') == 'true' @logger.info("Deleting #{resource_type}: #{resource['metadata']['name']}", color: :yellow) - kubectl.call("delete #{resource_type.downcase} #{resource['metadata']['name']} -n #{project.name}") + kubectl.call("delete #{resource_type.downcase} #{resource['metadata']['name']} -n #{project.namespace}") end end end @@ -150,7 +150,7 @@ class Projects::DeploymentJob < ApplicationJob def restart_deployment(service, kubectl) @logger.info("Restarting deployment: #{service.name}", color: :yellow) - kubectl.call("-n #{service.project.name} rollout restart deployment/#{service.name}") + kubectl.call("-n #{service.project.namespace} rollout restart deployment/#{service.name}") end def upload_registry_secrets(kubectl, deployment) diff --git a/app/jobs/projects/destroy_job.rb b/app/jobs/projects/destroy_job.rb index 021ae7ae..20d61768 100644 --- a/app/jobs/projects/destroy_job.rb +++ b/app/jobs/projects/destroy_job.rb @@ -14,9 +14,11 @@ class Projects::DestroyJob < ApplicationJob end def delete_namespace(project, user) - client = K8::Client.new(K8::Connection.new(project.cluster, user)) - if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.name }).present? - client.delete_namespace(namespace.metadata.name) + if project.managed_namespace + client = K8::Client.new(K8::Connection.new(project.cluster, user)) + if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.namespace }).present? + client.delete_namespace(namespace.metadata.name) + end end end diff --git a/app/jobs/scheduled/check_health_job.rb b/app/jobs/scheduled/check_health_job.rb index ec6cd2b0..1603a653 100644 --- a/app/jobs/scheduled/check_health_job.rb +++ b/app/jobs/scheduled/check_health_job.rb @@ -3,7 +3,7 @@ class Scheduled::CheckHealthJob < ApplicationJob def perform Service.web_service.where('healthcheck_url IS NOT NULL').each do |service| - # url = File.join("http://#{service.name}-service.#{service.project.name}.svc.cluster.local", service.healthcheck_url) + # url = File.join("http://#{service.name}-service.#{service.project.namespace}.svc.cluster.local", service.healthcheck_url) # K8::Client.from_project(service.project).run_command("curl -s -o /dev/null -w '%{http_code}' #{url}") if service.domains.any? url = File.join("https://#{service.domains.first.domain_name}", service.healthcheck_url) diff --git a/app/models/add_on.rb b/app/models/add_on.rb index b45459ba..3dfb8f02 100644 --- a/app/models/add_on.rb +++ b/app/models/add_on.rb @@ -2,16 +2,18 @@ # # Table name: add_ons # -# id :bigint not null, primary key -# chart_type :string not null -# chart_url :string -# metadata :jsonb -# name :string not null -# status :integer default("installing"), not null -# values :jsonb -# created_at :datetime not null -# updated_at :datetime not null -# cluster_id :bigint not null +# id :bigint not null, primary key +# chart_type :string not null +# chart_url :string +# managed_namespace :boolean default(TRUE) +# metadata :jsonb +# name :string not null +# namespace :string not null +# status :integer default("installing"), not null +# values :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# cluster_id :bigint not null # # Indexes # @@ -24,6 +26,7 @@ # class AddOn < ApplicationRecord include Loggable + include Namespaced belongs_to :cluster has_one :account, through: :cluster diff --git a/app/models/cluster.rb b/app/models/cluster.rb index 58571a27..e28a3b4c 100644 --- a/app/models/cluster.rb +++ b/app/models/cluster.rb @@ -58,7 +58,7 @@ class Cluster < ApplicationRecord ] def namespaces - RESERVED_NAMESPACES + projects.pluck(:name) + add_ons.pluck(:name) + RESERVED_NAMESPACES + projects.pluck(:namespace) + add_ons.pluck(:namespace) end def create_build_cloud!(attributes = {}) diff --git a/app/models/concerns/namespaced.rb b/app/models/concerns/namespaced.rb new file mode 100644 index 00000000..501552d5 --- /dev/null +++ b/app/models/concerns/namespaced.rb @@ -0,0 +1,7 @@ +module Namespaced + def self.included(base) + base.class_eval do + validates_presence_of :namespace + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 6cb47e2f..63b19a57 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -9,7 +9,9 @@ # container_registry_url :string # docker_build_context_directory :string default("."), not null # dockerfile_path :string default("./Dockerfile"), not null +# managed_namespace :boolean default(TRUE) # name :string not null +# namespace :string not null # postdeploy_command :text # postdestroy_command :text # predeploy_command :text @@ -32,6 +34,7 @@ # fk_rails_... (project_fork_cluster_id => clusters.id) # class Project < ApplicationRecord + include Namespaced broadcasts_refreshes belongs_to :cluster has_one :account, through: :cluster @@ -54,6 +57,8 @@ class Project < ApplicationRecord validates :name, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" } + validates :namespace, presence: true, + format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" } validates :branch, presence: true validates :repository_url, presence: true, format: { diff --git a/app/services/k8/stateless/cron_job.rb b/app/services/k8/stateless/cron_job.rb index 5d151101..c33718e7 100644 --- a/app/services/k8/stateless/cron_job.rb +++ b/app/services/k8/stateless/cron_job.rb @@ -18,7 +18,7 @@ class K8::Stateless::CronJob < K8::Base private def fetch_jobs_for_cronjob - result = kubectl.call("get jobs -n #{project.name} -o json") + result = kubectl.call("get jobs -n #{project.namespace} -o json") all_jobs = JSON.parse(result, object_class: OpenStruct).items # Filter jobs owned by this CronJob diff --git a/app/services/k8/stateless/deployment.rb b/app/services/k8/stateless/deployment.rb index a7a78a98..a5d4ed55 100644 --- a/app/services/k8/stateless/deployment.rb +++ b/app/services/k8/stateless/deployment.rb @@ -9,6 +9,6 @@ class K8::Stateless::Deployment < K8::Base end def restart - kubectl.call("rollout restart deployment/#{service.name} -n #{project.name}") + kubectl.call("rollout restart deployment/#{service.name} -n #{project.namespace}") end end diff --git a/app/services/k8/stateless/ingress.rb b/app/services/k8/stateless/ingress.rb index 1291f99b..677cabfc 100644 --- a/app/services/k8/stateless/ingress.rb +++ b/app/services/k8/stateless/ingress.rb @@ -15,7 +15,7 @@ class K8::Stateless::Ingress < K8::Base return nil unless @service.domains.any? return nil unless @service.allow_public_networking? - kubectl.call("get certificate #{certificate_name} -n #{@project.name} -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'") == "True" + kubectl.call("get certificate #{certificate_name} -n #{@project.namespace} -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'") == "True" end def certificate_name @@ -25,7 +25,7 @@ class K8::Stateless::Ingress < K8::Base def get_ingress result = kubectl.call('get ingresses -o yaml') results = YAML.safe_load(result) - results['items'].find { |r| r['metadata']['name'] == "#{@service.project.name}-ingress" } + results['items'].find { |r| r['metadata']['name'] == "#{@service.project.namespace}-ingress" } end def self.ip_address(client) diff --git a/app/services/k8/stateless/service.rb b/app/services/k8/stateless/service.rb index 00a3ea6a..fe9baff5 100644 --- a/app/services/k8/stateless/service.rb +++ b/app/services/k8/stateless/service.rb @@ -7,7 +7,7 @@ class K8::Stateless::Service < K8::Base end def internal_url - "#{name}.#{project.name}.svc.cluster.local:80" + "#{name}.#{project.namespace}.svc.cluster.local:80" end def name diff --git a/app/view_models/async/projects/processes/index_view_model.rb b/app/view_models/async/projects/processes/index_view_model.rb index 448b1bf5..7944ed72 100644 --- a/app/view_models/async/projects/processes/index_view_model.rb +++ b/app/view_models/async/projects/processes/index_view_model.rb @@ -19,6 +19,6 @@ class Async::Projects::Processes::IndexViewModel < Async::BaseViewModel def get_pods_for_project(project) # Get all pods for a given namespace client = K8::Client.new(K8::Connection.new(project.cluster, current_user)) - client.get_pods(namespace: project.name) + client.get_pods(namespace: project.namespace) end end diff --git a/app/views/add_ons/new.html.erb b/app/views/add_ons/new.html.erb index b9fc3ca5..0222b39f 100644 --- a/app/views/add_ons/new.html.erb +++ b/app/views/add_ons/new.html.erb @@ -12,25 +12,29 @@
    <%= form_with(model: @add_on) do |form| %> - <%= render(FormFieldComponent.new( - label: "Name", - description: "A unique name for your add on, only lowercase letters, numbers, and hyphens are allowed." - )) do %> - <%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %> - - <% end %> +
    + <%= render(FormFieldComponent.new( + label: "Name", + description: "A unique name for your add on, only lowercase letters, numbers, and hyphens are allowed." + )) do %> + <%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %> + + <% end %> - <%= render(FormFieldComponent.new( - label: "Cluster", - description: "The cluster to deploy your add on to." - )) do %> - <%= form.collection_select :cluster_id, current_account.clusters, :id, :name, {}, { class: "select select-bordered w-full" } %> - - <% end %> + <%= render(FormFieldComponent.new( + label: "Cluster", + description: "The cluster to deploy your add on to." + )) do %> + <%= form.collection_select :cluster_id, current_account.clusters, :id, :name, {}, { class: "select select-bordered w-full" } %> + + <% end %> + + <%= render "shared/partials/namespace_input_group", form: %> +
    diff --git a/app/views/projects/create/_missing_container_registry_credentials.html.erb b/app/views/projects/create/_missing_container_registry_credentials.html.erb index 85b6724a..6ae6c506 100644 --- a/app/views/projects/create/_missing_container_registry_credentials.html.erb +++ b/app/views/projects/create/_missing_container_registry_credentials.html.erb @@ -1,7 +1,7 @@
    -

    Missing Credentials for Docker Hub

    -

    Please provide your Docker Hub credentials to continue.

    +

    Missing credentials for container registry

    +

    Please provide your container registry credentials to continue.

    <%= link_to "Add Credentials", providers_path, class: "mt-6 btn btn-primary", data: { turbo: false } %>
    <%= link_to( diff --git a/app/views/projects/create/_missing_git_credentials.html.erb b/app/views/projects/create/_missing_git_credentials.html.erb index f87fd93a..d1ed7671 100644 --- a/app/views/projects/create/_missing_git_credentials.html.erb +++ b/app/views/projects/create/_missing_git_credentials.html.erb @@ -1,6 +1,6 @@
    -

    Missing Credentials for Git

    +

    Missing credentials for Git repository

    Please provide your Github or Gitlab credentials to continue.

    <% end %> -
    +
    <%= link_to project.link_to_view, target: "_blank" do %> <% if project.git? %> - <% if project.github? %> - - <% elsif project.gitlab? %> - - <% end %> - <%= project.repository_url %> - - <%= project.branch %> +
    + <% if project.github? %> + + <% elsif project.gitlab? %> + + <% end %> + <%= project.repository_url %> + + <%= project.branch %> +
    <% else %> - - <%= project.repository_url %> +
    + + <%= project.repository_url %> +
    <% end %> <% end %> - <%= link_to project.cluster.name, project.cluster, target: "_blank", class: "underline" %> + + <%= link_to( + project.cluster, + target: "_blank", + class: "underline", + ) do %> +
    + +
    <%= project.cluster.name %>
    +
    +
    <%= project.namespace %>
    +
    + <% end %>
    diff --git a/app/views/projects/edit.html.erb b/app/views/projects/edit.html.erb index 8c5fc80e..30588837 100644 --- a/app/views/projects/edit.html.erb +++ b/app/views/projects/edit.html.erb @@ -4,6 +4,9 @@

    General


    + <% unless @project.managed_namespace %> + <%= render "shared/partials/namespace_show", namespaced: @project %> + <% end %> <%= render "edit_form", project: @project %>
    diff --git a/app/views/shared/partials/_namespace_show.html.erb b/app/views/shared/partials/_namespace_show.html.erb index 39224ba6..37cdbd0e 100644 --- a/app/views/shared/partials/_namespace_show.html.erb +++ b/app/views/shared/partials/_namespace_show.html.erb @@ -1,11 +1,24 @@ -
    -
    - - -
    -
    /
    -
    - - +
    +
    +
    +
    +
    + +
    /
    +
    +
    +
    +
    + +
    +
    \ No newline at end of file diff --git a/spec/factories/add_ons.rb b/spec/factories/add_ons.rb index 9b877511..a0dad5f6 100644 --- a/spec/factories/add_ons.rb +++ b/spec/factories/add_ons.rb @@ -30,6 +30,8 @@ FactoryBot.define do chart_url { 'bitnami/redis' } chart_type { "helm_chart" } sequence(:name) { |n| "example-addon-#{n}" } + sequence(:namespace) { |n| "example-addon-#{n}" } + managed_namespace { true } status { :installing } values { {} } metadata { { "package_details" => { "repository" => { "name" => "bitnami", "url" => "https://bitnami.com/charts" } } } } From d2b2de01c44ec4ebb4194887cbd0032649b6fa15 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 25 Nov 2025 23:25:36 -0800 Subject: [PATCH 12/75] lots of refactoring... --- .../add_ons/apply_template_to_values.rb | 25 +++ app/actions/add_ons/create.rb | 68 +++---- app/actions/add_ons/save.rb | 10 + app/actions/add_ons/set_package_details.rb | 18 ++ app/actions/namespaced/set_up_namespace.rb | 16 ++ .../validate_namespace.rb | 30 +-- app/actions/projects/create.rb | 186 +++++++++--------- app/actions/projects/set_up_namespace.rb | 15 -- app/controllers/add_ons_controller.rb | 26 +-- app/models/add_on.rb | 7 - app/models/concerns/namespaced.rb | 8 + app/models/project.rb | 7 - .../add_ons/apply_template_to_values_spec.rb | 22 +++ spec/actions/add_ons/create_spec.rb | 69 +++---- .../add_ons/set_package_details_spec.rb | 31 +++ .../namespaced/set_up_namespace_spec.rb | 26 +++ .../validate_namespace_spec.rb | 12 +- spec/actions/projects/create_spec.rb | 10 +- .../actions/projects/set_up_namespace_spec.rb | 10 +- spec/models/cluster_spec.rb | 4 +- spec/models/project_spec.rb | 4 +- 21 files changed, 350 insertions(+), 254 deletions(-) create mode 100644 app/actions/add_ons/apply_template_to_values.rb create mode 100644 app/actions/add_ons/save.rb create mode 100644 app/actions/add_ons/set_package_details.rb create mode 100644 app/actions/namespaced/set_up_namespace.rb rename app/actions/{projects => namespaced}/validate_namespace.rb (54%) delete mode 100644 app/actions/projects/set_up_namespace.rb create mode 100644 spec/actions/add_ons/apply_template_to_values_spec.rb create mode 100644 spec/actions/add_ons/set_package_details_spec.rb create mode 100644 spec/actions/namespaced/set_up_namespace_spec.rb rename spec/actions/{projects => namespaced}/validate_namespace_spec.rb (83%) diff --git a/app/actions/add_ons/apply_template_to_values.rb b/app/actions/add_ons/apply_template_to_values.rb new file mode 100644 index 00000000..a388cdb6 --- /dev/null +++ b/app/actions/add_ons/apply_template_to_values.rb @@ -0,0 +1,25 @@ +class AddOns::ApplyTemplateToValues + extend LightService::Action + expects :add_on + + executed do |context| + add_on = context.add_on + add_on.values.extend(DotSettable) + + variables = add_on.metadata['template'] || {} + variables.keys.each do |key| + variable = variables[key] + + if variable.is_a?(Hash) && variable['type'] == 'size' + add_on.values.dotset(key, "#{variable['value']}#{variable['unit']}") + else + variable_definition = add_on.chart_definition['template'].find { |t| t['key'] == key } + if variable_definition['type'] == 'integer' + add_on.values.dotset(key, variable.to_i) + else + add_on.values.dotset(key, variable) + end + end + end + end +end \ No newline at end of file diff --git a/app/actions/add_ons/create.rb b/app/actions/add_ons/create.rb index 44b01691..5994d24c 100644 --- a/app/actions/add_ons/create.rb +++ b/app/actions/add_ons/create.rb @@ -1,47 +1,37 @@ class AddOns::Create - extend LightService::Action - expects :add_on - promises :add_on + def self.parse_params(params) + if params[:add_on][:values_yaml].present? + params[:add_on][:values] = YAML.safe_load(params[:add_on][:values_yaml]) + end + if params[:add_on][:metadata].present? + params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]] + end + params.require(:add_on).permit( + :cluster_id, + :chart_type, + :chart_url, + :name, + metadata: {}, + values: {} + ) + end - executed do |context| - add_on = context.add_on - apply_template_to_values(add_on) - fetch_package_details(context, add_on) - unless add_on.save - context.fail_and_return!("Failed to create add on") + class ToNamespaced + extend LightService::Action + expects :add_on + promises :namespaced + executed do |context| + context.namespaced = context.add_on end end - def self.fetch_package_details(context, add_on) - result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url) + extend LightService::Organizer - if result.failure? - add_on.errors.add(:base, "Failed to fetch package details") - context.fail_and_return!("Failed to fetch package details") - end - - result.response.delete('readme') - add_on.metadata['package_details'] = result.response - end - - def self.apply_template_to_values(add_on) - # Merge the values from the form with the values.yaml object and create a new values.yaml file - add_on.values.extend(DotSettable) - - variables = add_on.metadata['template'] || {} - variables.keys.each do |key| - variable = variables[key] - - if variable.is_a?(Hash) && variable['type'] == 'size' - add_on.values.dotset(key, "#{variable['value']}#{variable['unit']}") - else - variable_definition = add_on.chart_definition['template'].find { |t| t['key'] == key } - if variable_definition['type'] == 'integer' - add_on.values.dotset(key, variable.to_i) - else - add_on.values.dotset(key, variable) - end - end - end + def self.call(add_on) + with(add_on:).reduce( + AddOns::ApplyTemplateToValues, + AddOns::SetPackageDetails, + AddOns::Save + ) end end diff --git a/app/actions/add_ons/save.rb b/app/actions/add_ons/save.rb new file mode 100644 index 00000000..a3b0c522 --- /dev/null +++ b/app/actions/add_ons/save.rb @@ -0,0 +1,10 @@ +class AddOns::Save + extend LightService::Action + expects :add_on + + executed do |context| + unless context.add_on.save + context.fail_and_return!("Failed to create add on") + end + end +end \ No newline at end of file diff --git a/app/actions/add_ons/set_package_details.rb b/app/actions/add_ons/set_package_details.rb new file mode 100644 index 00000000..dc42bb10 --- /dev/null +++ b/app/actions/add_ons/set_package_details.rb @@ -0,0 +1,18 @@ +class AddOns::SetPackageDetails + extend LightService::Action + expects :add_on + + executed do |context| + add_on = context.add_on + result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url) + + if result.failure? + add_on.errors.add(:base, "Failed to fetch package details") + context.fail_and_return!("Failed to fetch package details") + end + + # Readme is too large + result.response.delete('readme') + add_on.metadata['package_details'] = result.response + end +end \ No newline at end of file diff --git a/app/actions/namespaced/set_up_namespace.rb b/app/actions/namespaced/set_up_namespace.rb new file mode 100644 index 00000000..d8dbf164 --- /dev/null +++ b/app/actions/namespaced/set_up_namespace.rb @@ -0,0 +1,16 @@ +class Namespaced::SetUpNamespace + extend LightService::Action + expects :namespaced + promises :namespaced + + executed do |context| + namespaced = context.namespaced + if namespaced.namespace.blank? && namespaced.managed_namespace + # autoset the namespace to the namespaced name + namespaced.namespace = namespaced.name + elsif namespaced.namespace.blank? && !namespaced.managed_namespace + namespaced.errors.add(:base, "A namespace must be provided if it is not managed by Canine") + context.fail_and_return!("Failed to set up namespace") + end + end +end diff --git a/app/actions/projects/validate_namespace.rb b/app/actions/namespaced/validate_namespace.rb similarity index 54% rename from app/actions/projects/validate_namespace.rb rename to app/actions/namespaced/validate_namespace.rb index c26a522e..88f29656 100644 --- a/app/actions/projects/validate_namespace.rb +++ b/app/actions/namespaced/validate_namespace.rb @@ -1,53 +1,53 @@ -module Projects +module Namespaced class ValidateNamespace extend LightService::Action - expects :project, :user + expects :namespaced, :user def self.validate_namespace_does_not_exist_or_is_managed( context, - project, + namespaced, client, existing_namespaces ) namespace_exists = existing_namespaces.any? do |ns| - ns.metadata.name == project.namespace && ns.metadata&.labels&.caninemanaged != "true" + ns.metadata.name == namespaced.namespace && ns.metadata&.labels&.caninemanaged != "true" end if namespace_exists - error_message = "Namespace `#{project.name}` already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name." - project.errors.add(:name, error_message) + error_message = "Namespace `#{namespaced.name}` already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name." + namespaced.errors.add(:name, error_message) context.fail_and_return!(error_message) end end def self.validate_namespace_exists( context, - project, + namespaced, client, existing_namespaces ) existing_namespace = existing_namespaces.any? do |ns| - ns.metadata.name == project.namespace + ns.metadata.name == namespaced.namespace end unless existing_namespace - error_message = "`#{project.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable auto create namespace" - project.errors.add(:base, error_message) + error_message = "`#{namespaced.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable auto create namespace" + namespaced.errors.add(:base, error_message) context.fail_and_return!(error_message) end end executed do |context| - project = context.project - cluster = project.cluster + namespaced = context.namespaced + cluster = namespaced.cluster begin client = K8::Client.new(K8::Connection.new(cluster, context.user)) existing_namespaces = client.get_namespaces - if project.managed_namespace - validate_namespace_does_not_exist_or_is_managed(context, project, client, existing_namespaces) + if namespaced.managed_namespace + validate_namespace_does_not_exist_or_is_managed(context, namespaced, client, existing_namespaces) else - validate_namespace_exists(context, project, client, existing_namespaces) + validate_namespace_exists(context, namespaced, client, existing_namespaces) end rescue StandardError => e # If we can't connect to check, we'll let it proceed and fail later if needed diff --git a/app/actions/projects/create.rb b/app/actions/projects/create.rb index 468af95c..cc18d813 100644 --- a/app/actions/projects/create.rb +++ b/app/actions/projects/create.rb @@ -1,93 +1,101 @@ # frozen_string_literal: true -module Projects - class Create - extend LightService::Organizer - def self.create_params(params) - params.require(:project).permit( - :name, - :namespace, - :managed_namespace, - :repository_url, - :branch, - :cluster_id, - :container_registry_url, - :predeploy_command, - :project_fork_status, - :project_fork_cluster_id - ) - end - - def self.call( - params, - user - ) - project = Project.new(create_params(params)) - provider = find_provider(user, params) - project_credential_provider = build_project_credential_provider(project, provider) - build_configuration = build_build_configuration(project, params) - - steps = create_steps(provider) - with( - project:, - project_credential_provider:, - build_configuration:, - params:, - user: - ).reduce(*steps) - end - - def self.build_project_credential_provider(project, provider) - ProjectCredentialProvider.new( - project:, - provider:, - ) - end - - def self.build_build_configuration(project, params) - return unless project.git? - build_config_params = params[:project][:build_configuration] || ActionController::Parameters.new - default_params = build_default_build_configuration(project) - merged_params = default_params.merge(BuildConfiguration.permit_params(build_config_params).compact_blank) - build_configuration = project.build_build_configuration(merged_params) - build_configuration - end - - def self.build_default_build_configuration(project) - { - provider: project.project_credential_provider.provider, - driver: BuildConfiguration::DEFAULT_BUILDER, - build_type: :dockerfile, - image_repository: project.repository_url, - context_directory: ".", - dockerfile_path: "./Dockerfile" - } - end - - def self.create_steps(provider) - steps = [] - if provider.git? - steps << Projects::ValidateGitRepository - end - - steps << Projects::SetUpNamespace - steps << Projects::ValidateNamespace - steps << Projects::InitializeBuildPacks - steps << Projects::Save - - # Only register webhook in cloud mode - if Rails.application.config.cloud_mode && provider.git? - steps << Projects::RegisterGitWebhook - end - - steps - end - - def self.find_provider(user, params) - provider_id = params[:project][:project_credential_provider][:provider_id] - user.providers.find(provider_id) - rescue ActiveRecord::RecordNotFound - raise "Provider #{provider_id} not found" +class Projects::Create + class ToNamespaced + extend LightService::Action + expects :project + promises :namespaced + executed do |context| + context.namespaced = context.project end end -end + + extend LightService::Organizer + def self.create_params(params) + params.require(:project).permit( + :name, + :namespace, + :managed_namespace, + :repository_url, + :branch, + :cluster_id, + :container_registry_url, + :predeploy_command, + :project_fork_status, + :project_fork_cluster_id + ) + end + + def self.call( + params, + user + ) + project = Project.new(create_params(params)) + provider = find_provider(user, params) + project_credential_provider = build_project_credential_provider(project, provider) + build_configuration = build_build_configuration(project, params) + + steps = create_steps(provider) + with( + project:, + project_credential_provider:, + build_configuration:, + params:, + user: + ).reduce(*steps) + end + + def self.build_project_credential_provider(project, provider) + ProjectCredentialProvider.new( + project:, + provider:, + ) + end + + def self.build_build_configuration(project, params) + return unless project.git? + build_config_params = params[:project][:build_configuration] || ActionController::Parameters.new + default_params = build_default_build_configuration(project) + merged_params = default_params.merge(BuildConfiguration.permit_params(build_config_params).compact_blank) + build_configuration = project.build_build_configuration(merged_params) + build_configuration + end + + def self.build_default_build_configuration(project) + { + provider: project.project_credential_provider.provider, + driver: BuildConfiguration::DEFAULT_BUILDER, + build_type: :dockerfile, + image_repository: project.repository_url, + context_directory: ".", + dockerfile_path: "./Dockerfile" + } + end + + def self.create_steps(provider) + steps = [] + if provider.git? + steps << Projects::ValidateGitRepository + end + + steps << Projects::Create::ToNamespaced + steps << Namespaced::SetUpNamespace + steps << Namespaced::ValidateNamespace + steps << Projects::InitializeBuildPacks + steps << Projects::Save + + # Only register webhook in cloud mode + if Rails.application.config.cloud_mode && provider.git? + steps << Projects::RegisterGitWebhook + end + + steps + end + + def self.find_provider(user, params) + provider_id = params[:project][:project_credential_provider][:provider_id] + user.providers.find(provider_id) + rescue ActiveRecord::RecordNotFound + raise "Provider #{provider_id} not found" + end +end \ No newline at end of file diff --git a/app/actions/projects/set_up_namespace.rb b/app/actions/projects/set_up_namespace.rb deleted file mode 100644 index fa390e49..00000000 --- a/app/actions/projects/set_up_namespace.rb +++ /dev/null @@ -1,15 +0,0 @@ -class Projects::SetUpNamespace - extend LightService::Action - expects :project - - executed do |context| - project = context.project - if project.namespace.blank? && project.managed_namespace - # autoset the namespace to the project name - project.namespace = project.name - elsif project.namespace.blank? && !project.managed_namespace - project.errors.add(:base, "A namespace must be provided if it is not managed by Canine") - context.fail_and_return!("Failed to set up namespace") - end - end -end diff --git a/app/controllers/add_ons_controller.rb b/app/controllers/add_ons_controller.rb index ab7911ee..3b804349 100644 --- a/app/controllers/add_ons_controller.rb +++ b/app/controllers/add_ons_controller.rb @@ -39,7 +39,8 @@ class AddOnsController < ApplicationController # POST /add_ons or /add_ons.json def create - result = AddOns::Create.execute(add_on: AddOn.new(add_on_params)) + params = AddOns::Create.parse_params(params) + result = AddOns::Create.execute(add_on: AddOn.new(params)) @add_on = result.add_on # Uncomment to authorize with Pundit # authorize @add_on @@ -58,7 +59,7 @@ class AddOnsController < ApplicationController # PATCH/PUT /add_ons/1 or /add_ons/1.json def update - @add_on.assign_attributes(add_on_params) + @add_on.assign_attributes(AddOns::Create.parse_params(params)) result = AddOns::Update.execute(add_on: @add_on) respond_to do |format| @@ -123,25 +124,4 @@ class AddOnsController < ApplicationController rescue ActiveRecord::RecordNotFound redirect_to add_ons_path end - - # Only allow a list of trusted parameters through. - def add_on_params - if params[:add_on][:values_yaml].present? - params[:add_on][:values] = YAML.safe_load(params[:add_on][:values_yaml]) - end - if params[:add_on][:metadata].present? - params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]] - end - params.require(:add_on).permit( - :cluster_id, - :chart_type, - :chart_url, - :name, - metadata: {}, - values: {} - ) - - # Uncomment to use Pundit permitted attributes - # params.require(:add_on).permit(policy(@add_on).permitted_attributes) - end end diff --git a/app/models/add_on.rb b/app/models/add_on.rb index 3dfb8f02..fff705b7 100644 --- a/app/models/add_on.rb +++ b/app/models/add_on.rb @@ -42,7 +42,6 @@ class AddOn < ApplicationRecord validates :chart_type, presence: true validate :chart_type_exists validates :name, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" } - validate :name_is_unique_to_cluster, on: :create validates_presence_of :chart_url validate :has_package_details, if: :helm_chart? @@ -59,12 +58,6 @@ class AddOn < ApplicationRecord metadata['install_stage'] || 0 end - def name_is_unique_to_cluster - if cluster.namespaces.include?(name) - errors.add(:name, "must be unique to this cluster") - end - end - def has_package_details if metadata['package_details'].blank? errors.add(:metadata, "is missing required keys: package_details") diff --git a/app/models/concerns/namespaced.rb b/app/models/concerns/namespaced.rb index 501552d5..ebdf8dd9 100644 --- a/app/models/concerns/namespaced.rb +++ b/app/models/concerns/namespaced.rb @@ -1,7 +1,15 @@ module Namespaced + def name_is_unique_to_cluster + if cluster.namespaces.include?(namespace) + errors.add(:name, "must be unique to this cluster") + end + end + def self.included(base) base.class_eval do validates_presence_of :namespace + + validate :name_is_unique_to_cluster, on: :create end end end diff --git a/app/models/project.rb b/app/models/project.rb index 63b19a57..c377e8aa 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -70,7 +70,6 @@ class Project < ApplicationRecord validate :project_fork_cluster_id_is_owned_by_account validates_presence_of :build_configuration, if: :git? - validate :name_is_unique_to_cluster, on: :create after_save_commit do broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self } end @@ -97,12 +96,6 @@ class Project < ApplicationRecord end end - def name_is_unique_to_cluster - if cluster.namespaces.include?(name) - errors.add(:name, "must be unique to this cluster") - end - end - def current_deployment deployments.order(created_at: :desc).where(status: :completed).first end diff --git a/spec/actions/add_ons/apply_template_to_values_spec.rb b/spec/actions/add_ons/apply_template_to_values_spec.rb new file mode 100644 index 00000000..736a3d8f --- /dev/null +++ b/spec/actions/add_ons/apply_template_to_values_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe AddOns::ApplyTemplateToValues do + let(:add_on) { build(:add_on) } + let(:template) do + { + 'master.persistence.size' => { 'type' => 'size', 'value' => '10', 'unit' => 'Gi' }, + 'replica.replicaCount' => '5' + } + end + + before do + add_on.metadata['template'] = template + add_on.chart_type = "redis" + end + + it 'applies template values correctly' do + described_class.execute(add_on:) + expect(add_on.values['master']['persistence']['size']).to eq('10Gi') + expect(add_on.values['replica']['replicaCount']).to eq(5) + end +end diff --git a/spec/actions/add_ons/create_spec.rb b/spec/actions/add_ons/create_spec.rb index 1a7d60d0..ff821b1b 100644 --- a/spec/actions/add_ons/create_spec.rb +++ b/spec/actions/add_ons/create_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe AddOns::Create do let(:add_on) { build(:add_on) } - let(:chart_details) { { 'name' => 'test-chart', 'version' => '1.0.0' } } + let(:chart_details) { { 'name' => 'redis', 'version' => '1.0.0' } } before do allow(AddOns::HelmChartDetails).to receive(:execute).and_return( @@ -12,53 +12,40 @@ RSpec.describe AddOns::Create do describe 'errors' do context 'there is a project with the same name in the same cluster' do - let!(:project) { create(:project, name: add_on.name, cluster: add_on.cluster) } + let!(:project) { create(:project, name: add_on.name, cluster: add_on.cluster, namespace: 'taken') } + let(:add_on) { build(:add_on, namespace: 'taken') } + it 'raises an error' do - result = described_class.execute(add_on:) + result = described_class.call(add_on) expect(result.failure?).to be_truthy end end end - describe '#execute' do - it 'applies template and fetches package details' do - expect(add_on).to receive(:save) - result = described_class.execute(add_on:) - expect(result.add_on.metadata['package_details']).to eq(chart_details) - end - - context 'when package details fetch fails' do - before do - allow(AddOns::HelmChartDetails).to receive(:execute).and_return( - double(success?: false, failure?: true) - ) - end - - it 'adds error and returns' do - result = described_class.execute(add_on:) - expect(result.failure?).to be_truthy - expect(result.add_on.errors[:base]).to include('Failed to fetch package details') - end - end - end - - describe '.apply_template_to_values' do - let(:template) do - { - 'master.persistence.size' => { 'type' => 'size', 'value' => '10', 'unit' => 'Gi' }, - 'replica.replicaCount' => '5' + let(:params) do + ActionController::Parameters.new({ + add_on: { + name: 'redis-main', + chart_type: 'redis', + metadata: { + redis: { + template: { + 'replicas' => 3, + 'master.persistence.size' => { + 'type' => 'size', + 'value' => '2', + 'unit' => 'Gi' + } + } + } + } } - end + }) + end - before do - add_on.metadata['template'] = template - add_on.chart_type = "redis" - end - - it 'applies template values correctly' do - described_class.apply_template_to_values(add_on) - expect(add_on.values['master']['persistence']['size']).to eq('10Gi') - expect(add_on.values['replica']['replicaCount']).to eq(5) - end + it 'can create an add on successfully' do + add_on = AddOn.new(AddOns::Create.parse_params(params)) + result = described_class.call(add_on) + expect(result.success?).to be_truthy end end diff --git a/spec/actions/add_ons/set_package_details_spec.rb b/spec/actions/add_ons/set_package_details_spec.rb new file mode 100644 index 00000000..09be11a4 --- /dev/null +++ b/spec/actions/add_ons/set_package_details_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe AddOns::SetPackageDetails do + let(:add_on) { build(:add_on) } + let(:chart_details) { { 'name' => 'test-chart', 'version' => '1.0.0' } } + + before do + allow(AddOns::HelmChartDetails).to receive(:execute).and_return( + double(success?: true, failure?: false, response: chart_details) + ) + end + + it 'fetches package details and saves to add on' do + result = described_class.execute(add_on:) + expect(result.add_on.metadata['package_details']).to eq(chart_details) + end + + context 'when package details fetch fails' do + before do + allow(AddOns::HelmChartDetails).to receive(:execute).and_return( + double(success?: false, failure?: true) + ) + end + + it 'adds error and returns' do + result = described_class.execute(add_on:) + expect(result.failure?).to be_truthy + expect(result.add_on.errors[:base]).to include('Failed to fetch package details') + end + end +end diff --git a/spec/actions/namespaced/set_up_namespace_spec.rb b/spec/actions/namespaced/set_up_namespace_spec.rb new file mode 100644 index 00000000..714db2a6 --- /dev/null +++ b/spec/actions/namespaced/set_up_namespace_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe Namespaced::SetUpNamespace do + let(:subject) { described_class.execute(namespaced: project) } + + context "canine managed namespace" do + let(:project) { build(:project, managed_namespace: true, namespace: "") } + + it "autosets the name" do + result = subject + + expect(result.namespaced.namespace).to eq(project.name) + expect(result.namespaced.errors).to be_empty + end + end + + context "self managed" do + let(:project) { build(:project, managed_namespace: false, namespace: "") } + + it "raises error" do + result = subject + expect(result.namespaced.errors).to be_present + expect(result.failure?).to be_truthy + end + end +end diff --git a/spec/actions/projects/validate_namespace_spec.rb b/spec/actions/namespaced/validate_namespace_spec.rb similarity index 83% rename from spec/actions/projects/validate_namespace_spec.rb rename to spec/actions/namespaced/validate_namespace_spec.rb index bebaa1ef..e9f0b644 100644 --- a/spec/actions/projects/validate_namespace_spec.rb +++ b/spec/actions/namespaced/validate_namespace_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -RSpec.describe Projects::ValidateNamespace do +RSpec.describe Namespaced::ValidateNamespace do let(:cluster) { create(:cluster) } let(:user) { create(:user) } let(:k8_client) { instance_double(K8::Client) } @@ -23,7 +23,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'fails' do - expect(described_class.execute(project:, user: user)).to be_failure + expect(described_class.execute(namespaced: project, user:)).to be_failure end end @@ -35,7 +35,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'succeeds' do - expect(described_class.execute(project:, user:)).to be_success + expect(described_class.execute(namespaced: project, user:)).to be_success end end end @@ -56,7 +56,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'succeeds' do - expect(described_class.execute(project: project, user: user)).to be_success + expect(described_class.execute(namespaced: project, user: user)).to be_success end end @@ -71,7 +71,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'fails with error message' do - result = described_class.execute(project: project, user: user) + result = described_class.execute(namespaced: project, user: user) expect(result).to be_failure expect(result.message).to include("already exists") end @@ -83,7 +83,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'succeeds' do - result = described_class.execute(project: project, user: user) + result = described_class.execute(namespaced: project, user: user) expect(result).to be_success end end diff --git a/spec/actions/projects/create_spec.rb b/spec/actions/projects/create_spec.rb index c72a83da..ad5bab9c 100644 --- a/spec/actions/projects/create_spec.rb +++ b/spec/actions/projects/create_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Projects::Create do before do allow(Projects::ValidateGitRepository).to receive(:execute) - allow(Projects::ValidateNamespace).to receive(:execute) + allow(Namespaced::ValidateNamespace).to receive(:execute) allow(Projects::RegisterGitWebhook).to receive(:execute) end @@ -156,7 +156,9 @@ RSpec.describe Projects::Create do it 'validates with github and registers webhooks' do expect(subject).to eq([ Projects::ValidateGitRepository, - Projects::ValidateNamespace, + Projects::Create::ToNamespaced, + Namespaced::SetUpNamespace, + Namespaced::ValidateNamespace, Projects::InitializeBuildPacks, Projects::Save, Projects::RegisterGitWebhook @@ -172,7 +174,9 @@ RSpec.describe Projects::Create do it 'validates with github and does not register webhooks' do expect(subject).to eq([ Projects::ValidateGitRepository, - Projects::ValidateNamespace, + Projects::Create::ToNamespaced, + Namespaced::SetUpNamespace, + Namespaced::ValidateNamespace, Projects::InitializeBuildPacks, Projects::Save ]) diff --git a/spec/actions/projects/set_up_namespace_spec.rb b/spec/actions/projects/set_up_namespace_spec.rb index 9ab81eef..714db2a6 100644 --- a/spec/actions/projects/set_up_namespace_spec.rb +++ b/spec/actions/projects/set_up_namespace_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' -RSpec.describe Projects::SetUpNamespace do - let(:subject) { described_class.execute(project:) } +RSpec.describe Namespaced::SetUpNamespace do + let(:subject) { described_class.execute(namespaced: project) } context "canine managed namespace" do let(:project) { build(:project, managed_namespace: true, namespace: "") } @@ -9,8 +9,8 @@ RSpec.describe Projects::SetUpNamespace do it "autosets the name" do result = subject - expect(result.project.namespace).to eq(project.name) - expect(result.project.errors).to be_empty + expect(result.namespaced.namespace).to eq(project.name) + expect(result.namespaced.errors).to be_empty end end @@ -19,7 +19,7 @@ RSpec.describe Projects::SetUpNamespace do it "raises error" do result = subject - expect(result.project.errors).to be_present + expect(result.namespaced.errors).to be_present expect(result.failure?).to be_truthy end end diff --git a/spec/models/cluster_spec.rb b/spec/models/cluster_spec.rb index 372143d2..ea6dccdc 100644 --- a/spec/models/cluster_spec.rb +++ b/spec/models/cluster_spec.rb @@ -29,8 +29,8 @@ RSpec.describe Cluster, type: :model do let!(:add_on) { create(:add_on, cluster: cluster) } it 'returns the reserved namespaces and project/add_on names' do - expect(cluster.namespaces).to include(project.name) - expect(cluster.namespaces).to include(add_on.name) + expect(cluster.namespaces).to include(project.namespace) + expect(cluster.namespaces).to include(add_on.namespace) end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index a2ca4809..76b384c5 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -37,12 +37,12 @@ require 'rails_helper' RSpec.describe Project, type: :model do let(:cluster) { create(:cluster) } - let(:project) { build(:project, cluster: cluster, account: cluster.account) } + let(:project) { build(:project, cluster:, account: cluster.account, namespace: "taken") } describe 'validations' do context 'when name is not unique to the cluster' do before do - create(:project, name: project.name, cluster: cluster) + create(:project, name: project.name, cluster:, namespace: "taken") end it 'is not valid' do From 0ab942f21ed03900273e57df4d91680cbc0bdab3 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 26 Nov 2025 01:54:10 -0800 Subject: [PATCH 13/75] specs passing --- app/actions/add_ons/apply_template_to_values.rb | 2 +- app/actions/add_ons/create.rb | 7 +++++-- app/actions/add_ons/save.rb | 2 +- app/actions/add_ons/set_package_details.rb | 2 +- app/actions/projects/create.rb | 2 +- app/controllers/add_ons_controller.rb | 2 +- spec/actions/add_ons/create_spec.rb | 10 +++++++--- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/actions/add_ons/apply_template_to_values.rb b/app/actions/add_ons/apply_template_to_values.rb index a388cdb6..8d508e52 100644 --- a/app/actions/add_ons/apply_template_to_values.rb +++ b/app/actions/add_ons/apply_template_to_values.rb @@ -22,4 +22,4 @@ class AddOns::ApplyTemplateToValues end end end -end \ No newline at end of file +end diff --git a/app/actions/add_ons/create.rb b/app/actions/add_ons/create.rb index 5994d24c..ef0f1d0a 100644 --- a/app/actions/add_ons/create.rb +++ b/app/actions/add_ons/create.rb @@ -27,8 +27,11 @@ class AddOns::Create extend LightService::Organizer - def self.call(add_on) - with(add_on:).reduce( + def self.call(add_on, user) + with(add_on:, user:).reduce( + AddOns::Create::ToNamespaced, + Namespaced::SetUpNamespace, + Namespaced::ValidateNamespace, AddOns::ApplyTemplateToValues, AddOns::SetPackageDetails, AddOns::Save diff --git a/app/actions/add_ons/save.rb b/app/actions/add_ons/save.rb index a3b0c522..3b0c564b 100644 --- a/app/actions/add_ons/save.rb +++ b/app/actions/add_ons/save.rb @@ -7,4 +7,4 @@ class AddOns::Save context.fail_and_return!("Failed to create add on") end end -end \ No newline at end of file +end diff --git a/app/actions/add_ons/set_package_details.rb b/app/actions/add_ons/set_package_details.rb index dc42bb10..02e2033c 100644 --- a/app/actions/add_ons/set_package_details.rb +++ b/app/actions/add_ons/set_package_details.rb @@ -15,4 +15,4 @@ class AddOns::SetPackageDetails result.response.delete('readme') add_on.metadata['package_details'] = result.response end -end \ No newline at end of file +end diff --git a/app/actions/projects/create.rb b/app/actions/projects/create.rb index cc18d813..b4394654 100644 --- a/app/actions/projects/create.rb +++ b/app/actions/projects/create.rb @@ -98,4 +98,4 @@ class Projects::Create rescue ActiveRecord::RecordNotFound raise "Provider #{provider_id} not found" end -end \ No newline at end of file +end diff --git a/app/controllers/add_ons_controller.rb b/app/controllers/add_ons_controller.rb index 3b804349..83819787 100644 --- a/app/controllers/add_ons_controller.rb +++ b/app/controllers/add_ons_controller.rb @@ -40,7 +40,7 @@ class AddOnsController < ApplicationController # POST /add_ons or /add_ons.json def create params = AddOns::Create.parse_params(params) - result = AddOns::Create.execute(add_on: AddOn.new(params)) + result = AddOns::Create.call(AddOn.new(params), user) @add_on = result.add_on # Uncomment to authorize with Pundit # authorize @add_on diff --git a/spec/actions/add_ons/create_spec.rb b/spec/actions/add_ons/create_spec.rb index ff821b1b..8cbc4a46 100644 --- a/spec/actions/add_ons/create_spec.rb +++ b/spec/actions/add_ons/create_spec.rb @@ -1,10 +1,12 @@ require 'rails_helper' RSpec.describe AddOns::Create do + let(:cluster) { create(:cluster) } let(:add_on) { build(:add_on) } let(:chart_details) { { 'name' => 'redis', 'version' => '1.0.0' } } before do + allow(Namespaced::ValidateNamespace).to receive(:execute) allow(AddOns::HelmChartDetails).to receive(:execute).and_return( double(success?: true, failure?: false, response: chart_details) ) @@ -16,7 +18,7 @@ RSpec.describe AddOns::Create do let(:add_on) { build(:add_on, namespace: 'taken') } it 'raises an error' do - result = described_class.call(add_on) + result = described_class.call(add_on, cluster.account.owner) expect(result.failure?).to be_truthy end end @@ -27,10 +29,12 @@ RSpec.describe AddOns::Create do add_on: { name: 'redis-main', chart_type: 'redis', + chart_url: 'bitnami/redis', + cluster_id: cluster.id, metadata: { redis: { template: { - 'replicas' => 3, + 'replica.replicaCount' => 3, 'master.persistence.size' => { 'type' => 'size', 'value' => '2', @@ -45,7 +49,7 @@ RSpec.describe AddOns::Create do it 'can create an add on successfully' do add_on = AddOn.new(AddOns::Create.parse_params(params)) - result = described_class.call(add_on) + result = described_class.call(add_on, cluster.account.owner) expect(result.success?).to be_truthy end end From b1dd1e95a6651414dccbaf7631fff4f5126aa943 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 27 Nov 2025 02:35:14 +0900 Subject: [PATCH 14/75] updates --- lib/devise/strategies/ldap_authenticatable.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/devise/strategies/ldap_authenticatable.rb b/lib/devise/strategies/ldap_authenticatable.rb index 35621fff..223bce8b 100644 --- a/lib/devise/strategies/ldap_authenticatable.rb +++ b/lib/devise/strategies/ldap_authenticatable.rb @@ -85,6 +85,7 @@ module Devise end def get_group_information(user) + # ldap.search(base: "ou=Groups,dc=example,dc=org", filter: Net::LDAP::Filter.eq("memberUid", user_dn)) return [ { name: "developers", From 9332f62afe48cf77c4b9c7bde857906947429b76 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 26 Nov 2025 14:57:02 -0800 Subject: [PATCH 15/75] added fix for pod_yaml --- .../projects/services/_advanced.html.erb | 2 +- config/initializers/core_extensions.rb | 8 ++++++++ resources/k8/stateless/cron_job.yaml | 2 +- resources/k8/stateless/deployment.yaml | 2 +- spec/initializers/core_extensions_spec.rb | 20 +++++++++++++++++++ 5 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 config/initializers/core_extensions.rb create mode 100644 spec/initializers/core_extensions_spec.rb diff --git a/app/views/projects/services/_advanced.html.erb b/app/views/projects/services/_advanced.html.erb index 2cec9e48..0279d8b5 100644 --- a/app/views/projects/services/_advanced.html.erb +++ b/app/views/projects/services/_advanced.html.erb @@ -40,7 +40,7 @@ placeholder: "# Example:\ncontainers:\n - name: sidecar\n image: nginx:latest\nvolumes:\n - name: cache\n emptyDir: {}", class: "textarea textarea-bordered font-mono text-sm w-full", data: { yaml_editor_target: "textarea" }, - value: @service.pod_yaml.present? ? @service.pod_yaml.to_yaml : "" %> + value: @service.pod_yaml.present? ? @service.pod_yaml.to_yaml_raw : "" %>
    -
    - - <%= bc_form.text_field( - :image_repository, - class: "input input-bordered w-full focus:outline-offset-0", - value: build_configuration.image_repository, - placeholder: "namespace/repo", - pattern: "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]/[a-zA-Z0-9._-]+", - title: "Must be in the format 'namespace/repo'" - ) %> - + +
    + + +
    +
    + + <%= bc_form.text_field( + :image_repository, + class: "input input-bordered w-full focus:outline-offset-0", + value: build_configuration.image_repository, + placeholder: "namespace/repo", + pattern: "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]/[a-zA-Z0-9._-]+", + title: "Must be in the format 'namespace/repo'" + ) %> + +
    +
    +
    <% end %> - <%= render(FormFieldComponent.new( - label: "Namespace", - description: "The namespace your application is deployed to." - )) do %> - <%= form.text_field :namespace, current_account.clusters.running, :id, :name, {}, { class: "select select-bordered w-full" } %> - - <% end %> - <%= render(FormFieldComponent.new( label: "Git Credentials", description: "The credentials to use to connect to your Git repository" diff --git a/app/views/projects/create/_select_credentials.html.erb b/app/views/projects/create/_select_credentials.html.erb index aa0dfca8..85f73013 100644 --- a/app/views/projects/create/_select_credentials.html.erb +++ b/app/views/projects/create/_select_credentials.html.erb @@ -10,11 +10,12 @@ }, { class: "select select-bordered w-full", + required: true, data: { "new-project-target": "provider", action: "change->new-project#selectProvider", } - } + }, ) %> <% end %> -
    +
    <%= render(FormFieldComponent.new( label: "Namespace", description: "The namespace your application is deployed to." )) do %> - <%= form.text_field :namespace, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %> + <%= form.text_field :namespace, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true %> @@ -19,7 +19,7 @@
    Automatically create namespace
    - <%= form.check_box :managed_namespace, class: "checkbox" %> + <%= form.check_box :managed_namespace, class: "checkbox", data: { action: "namespace-input-group#toggleManagedNamespace" } %>
    <%= render "static/landing_page/announcement" %> Canine -

    A modern, open source alternative to Heroku

    +

    A Developer-friendly PaaS for your Kubernetes

    Canine is an open source deployment platform that makes it easy to deploy and manage your applications.

    <%= link_to "Get started", new_user_registration_path, class: "rounded-md bg-indigo-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400" %> From 2e7010e558534d791649c2e4e50d29116cc2bb1e Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 29 Nov 2025 09:46:57 -0800 Subject: [PATCH 21/75] added some better ux --- app/actions/add_ons/filter.rb | 18 +++ app/actions/add_ons/list.rb | 14 ++ app/actions/add_ons/visible_to_user.rb | 13 +- app/actions/clusters/filter.rb | 18 +++ app/actions/clusters/list.rb | 14 ++ app/actions/clusters/visible_to_user.rb | 13 +- app/actions/projects/filter.rb | 18 +++ app/actions/projects/list.rb | 14 ++ app/actions/projects/visible_to_user.rb | 13 +- app/controllers/accounts/teams_controller.rb | 1 + app/controllers/add_ons_controller.rb | 9 +- app/controllers/application_controller.rb | 6 + app/controllers/clusters_controller.rb | 9 +- app/controllers/projects_controller.rb | 9 +- .../async_search_dropdown_controller.js | 2 +- .../team_resource_search_controller.js | 93 ++++++++++++ app/models/account_user.rb | 4 + app/views/accounts/teams/_empty.html.erb | 49 ++++++ app/views/accounts/teams/_list.html.erb | 95 +++++++----- .../accounts/teams/_resource_content.html.erb | 29 ++++ .../accounts/teams/_resource_tabs.html.erb | 11 ++ .../teams/_team_resources_table.html.erb | 37 +++++ app/views/accounts/teams/index.html.erb | 21 +-- app/views/accounts/teams/show.html.erb | 141 +++++++++++++----- app/views/layouts/_sidebar.html.erb | 12 +- app/views/shared/_head.html.erb | 4 +- spec/actions/add_ons/visible_to_user_spec.rb | 33 +++- spec/actions/clusters/visible_to_user_spec.rb | 31 +++- spec/actions/projects/visible_to_user_spec.rb | 28 +++- 29 files changed, 623 insertions(+), 136 deletions(-) create mode 100644 app/actions/add_ons/filter.rb create mode 100644 app/actions/add_ons/list.rb create mode 100644 app/actions/clusters/filter.rb create mode 100644 app/actions/clusters/list.rb create mode 100644 app/actions/projects/filter.rb create mode 100644 app/actions/projects/list.rb create mode 100644 app/javascript/controllers/team_resource_search_controller.js create mode 100644 app/views/accounts/teams/_empty.html.erb create mode 100644 app/views/accounts/teams/_resource_content.html.erb create mode 100644 app/views/accounts/teams/_resource_tabs.html.erb create mode 100644 app/views/accounts/teams/_team_resources_table.html.erb diff --git a/app/actions/add_ons/filter.rb b/app/actions/add_ons/filter.rb new file mode 100644 index 00000000..c7675878 --- /dev/null +++ b/app/actions/add_ons/filter.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module AddOns + class Filter + extend LightService::Action + + expects :params, :add_ons + promises :add_ons + + executed do |context| + query = context.params[:q].to_s.strip + + if query.present? + context.add_ons = context.add_ons.where("add_ons.name ILIKE ?", "%#{query}%") + end + end + end +end diff --git a/app/actions/add_ons/list.rb b/app/actions/add_ons/list.rb new file mode 100644 index 00000000..759518c0 --- /dev/null +++ b/app/actions/add_ons/list.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module AddOns + class List + extend LightService::Organizer + + def self.call(account_user:, params: {}) + with(account_user: account_user, params: params).reduce( + AddOns::VisibleToUser, + AddOns::Filter + ) + end + end +end diff --git a/app/actions/add_ons/visible_to_user.rb b/app/actions/add_ons/visible_to_user.rb index a63ded32..dc02602e 100644 --- a/app/actions/add_ons/visible_to_user.rb +++ b/app/actions/add_ons/visible_to_user.rb @@ -4,12 +4,19 @@ module AddOns class VisibleToUser extend LightService::Action - expects :user, :account + expects :account_user promises :add_ons executed do |context| - user = context.user - account = context.account + account_user = context.account_user + user = account_user.user + account = account_user.account + + # Admins can see all add_ons in the account + if account_user.admin? + context.add_ons = AddOn.joins(:cluster).where(clusters: { account_id: account.id }) + next context + end # If account has no teams, user can see all add_ons if account.teams.empty? diff --git a/app/actions/clusters/filter.rb b/app/actions/clusters/filter.rb new file mode 100644 index 00000000..181583d5 --- /dev/null +++ b/app/actions/clusters/filter.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Clusters + class Filter + extend LightService::Action + + expects :params, :clusters + promises :clusters + + executed do |context| + query = context.params[:q].to_s.strip + + if query.present? + context.clusters = context.clusters.where("clusters.name ILIKE ?", "%#{query}%") + end + end + end +end diff --git a/app/actions/clusters/list.rb b/app/actions/clusters/list.rb new file mode 100644 index 00000000..d96c4a54 --- /dev/null +++ b/app/actions/clusters/list.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Clusters + class List + extend LightService::Organizer + + def self.call(account_user:, params: {}) + with(account_user: account_user, params: params).reduce( + Clusters::VisibleToUser, + Clusters::Filter + ) + end + end +end diff --git a/app/actions/clusters/visible_to_user.rb b/app/actions/clusters/visible_to_user.rb index 245a701e..e52bb0d4 100644 --- a/app/actions/clusters/visible_to_user.rb +++ b/app/actions/clusters/visible_to_user.rb @@ -4,12 +4,19 @@ module Clusters class VisibleToUser extend LightService::Action - expects :user, :account + expects :account_user promises :clusters executed do |context| - user = context.user - account = context.account + account_user = context.account_user + user = account_user.user + account = account_user.account + + # Admins can see all clusters in the account + if account_user.admin? + context.clusters = Cluster.where(account_id: account.id) + next context + end # If account has no teams, user can see all clusters if account.teams.empty? diff --git a/app/actions/projects/filter.rb b/app/actions/projects/filter.rb new file mode 100644 index 00000000..fdd43ee3 --- /dev/null +++ b/app/actions/projects/filter.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Projects + class Filter + extend LightService::Action + + expects :params, :projects + promises :projects + + executed do |context| + query = context.params[:q].to_s.strip + + if query.present? + context.projects = context.projects.where("projects.name ILIKE ?", "%#{query}%") + end + end + end +end diff --git a/app/actions/projects/list.rb b/app/actions/projects/list.rb new file mode 100644 index 00000000..95df0b1e --- /dev/null +++ b/app/actions/projects/list.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Projects + class List + extend LightService::Organizer + + def self.call(account_user:, params: {}) + with(account_user: account_user, params: params).reduce( + Projects::VisibleToUser, + Projects::Filter + ) + end + end +end diff --git a/app/actions/projects/visible_to_user.rb b/app/actions/projects/visible_to_user.rb index 87e8d8ad..4ecdde56 100644 --- a/app/actions/projects/visible_to_user.rb +++ b/app/actions/projects/visible_to_user.rb @@ -4,12 +4,19 @@ module Projects class VisibleToUser extend LightService::Action - expects :user, :account + expects :account_user promises :projects executed do |context| - user = context.user - account = context.account + account_user = context.account_user + user = account_user.user + account = account_user.account + + # Admins can see all projects in the account + if account_user.admin? + context.projects = Project.joins(:cluster).where(clusters: { account_id: account.id }) + next context + end # If account has no teams, user can see all projects if account.teams.empty? diff --git a/app/controllers/accounts/teams_controller.rb b/app/controllers/accounts/teams_controller.rb index f87b59a3..2ade0fd3 100644 --- a/app/controllers/accounts/teams_controller.rb +++ b/app/controllers/accounts/teams_controller.rb @@ -8,6 +8,7 @@ class Accounts::TeamsController < ApplicationController def show @pagy, @team_memberships = pagy(@team.team_memberships) + @tab = params[:tab] || "clusters" end def new diff --git a/app/controllers/add_ons_controller.rb b/app/controllers/add_ons_controller.rb index b8105f14..6c3d187c 100644 --- a/app/controllers/add_ons_controller.rb +++ b/app/controllers/add_ons_controller.rb @@ -4,9 +4,14 @@ class AddOnsController < ApplicationController # GET /add_ons def index - add_ons = AddOns::VisibleToUser.execute(user: current_user, account: current_account).add_ons + add_ons = AddOns::List.call(account_user: current_account_user, params: params).add_ons @pagy, @add_ons = pagy(add_ons) + respond_to do |format| + format.html + format.json { render json: @add_ons.map { |a| { id: a.id, name: a.name } } } + end + # Uncomment to authorize with Pundit # authorize @add_ons end @@ -120,7 +125,7 @@ class AddOnsController < ApplicationController # Use callbacks to share common setup or constraints between actions. def set_add_on - add_ons = AddOns::VisibleToUser.execute(user: current_user, account: current_account).add_ons + add_ons = AddOns::VisibleToUser.execute(account_user: current_account_user).add_ons @add_on = add_ons.find(params[:id]) @service = K8::Helm::Service.create_from_add_on(K8::Connection.new(@add_on, current_user)) rescue ActiveRecord::RecordNotFound diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 77580c27..2fdbe252 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -23,6 +23,12 @@ class ApplicationController < ActionController::Base end helper_method :current_account + def current_account_user + return nil unless user_signed_in? && current_account + @current_account_user ||= AccountUser.find_by(user: current_user, account: current_account) + end + helper_method :current_account_user + def time_ago(t) if t.present? "#{time_ago_in_words(t)} ago" diff --git a/app/controllers/clusters_controller.rb b/app/controllers/clusters_controller.rb index ed66fc65..0d733228 100644 --- a/app/controllers/clusters_controller.rb +++ b/app/controllers/clusters_controller.rb @@ -8,9 +8,14 @@ class ClustersController < ApplicationController # GET /clusters def index sortable_column = params[:sort] || "created_at" - clusters = Clusters::VisibleToUser.execute(user: current_user, account: current_account).clusters + clusters = Clusters::List.call(account_user: current_account_user, params: params).clusters @pagy, @clusters = pagy(clusters.order(sortable_column => "asc")) + respond_to do |format| + format.html + format.json { render json: @clusters.map { |c| { id: c.id, name: c.name } } } + end + # Uncomment to authorize with Pundit # authorize @clusters end @@ -172,7 +177,7 @@ class ClustersController < ApplicationController # Use callbacks to share common setup or constraints between actions. def set_cluster - clusters = Clusters::VisibleToUser.execute(user: current_user, account: current_account).clusters + clusters = Clusters::VisibleToUser.execute(account_user: current_account_user).clusters @cluster = clusters.find(params[:id]) # Uncomment to authorize with Pundit diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 7bd0bc42..e32b0cb5 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -6,9 +6,14 @@ class ProjectsController < ApplicationController # GET /projects def index sortable_column = params[:sort] || "created_at" - projects = Projects::VisibleToUser.execute(user: current_user, account: current_account).projects + projects = Projects::List.call(account_user: current_account_user, params: params).projects @pagy, @projects = pagy(projects.order(sortable_column => "asc")) + respond_to do |format| + format.html + format.json { render json: @projects.map { |p| { id: p.id, name: p.name } } } + end + # Uncomment to authorize with Pundit # authorize @projects end @@ -87,7 +92,7 @@ class ProjectsController < ApplicationController # Use callbacks to share common setup or constraints between actions. def set_project - projects = Projects::VisibleToUser.execute(user: current_user, account: current_account).projects + projects = Projects::VisibleToUser.execute(account_user: current_account_user).projects @project = projects.find(params[:id]) # Uncomment to authorize with Pundit diff --git a/app/javascript/controllers/components/async_search_dropdown_controller.js b/app/javascript/controllers/components/async_search_dropdown_controller.js index 1aea762e..82d1abfe 100644 --- a/app/javascript/controllers/components/async_search_dropdown_controller.js +++ b/app/javascript/controllers/components/async_search_dropdown_controller.js @@ -48,7 +48,7 @@ export default class extends Controller { createDropdown() { const dropdown = document.createElement('ul') - dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-base-200 block rounded-box shadow-lg max-h-[300px] overflow-y-auto' + dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-neutral block rounded-box shadow-lg max-h-[300px] overflow-y-auto' return dropdown } diff --git a/app/javascript/controllers/team_resource_search_controller.js b/app/javascript/controllers/team_resource_search_controller.js new file mode 100644 index 00000000..45de99b3 --- /dev/null +++ b/app/javascript/controllers/team_resource_search_controller.js @@ -0,0 +1,93 @@ +import AsyncSearchDropdownController from "./components/async_search_dropdown_controller" + +export default class extends AsyncSearchDropdownController { + static values = { + url: String, + addUrl: String, + resourceType: String, + turboFrame: String + } + + async fetchResults(query) { + const url = `${this.urlValue}.json?q=${encodeURIComponent(query)}` + const response = await fetch(url, { + headers: { + 'Accept': 'application/json' + } + }) + + if (!response.ok) { + throw new Error('Failed to search resources') + } + + return await response.json() + } + + renderItem(resource) { + return ` +
    +
    +
    ${this.escapeHtml(resource.name)}
    +
    +
    + ` + } + + async onItemSelect(resource, itemElement) { + try { + itemElement.classList.add('opacity-50') + itemElement.innerHTML = ` +
    + + Adding ${this.escapeHtml(resource.name)}... +
    + ` + + const response = await fetch(this.addUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.getCsrfToken() + }, + body: JSON.stringify({ + resourceable_type: this.resourceTypeValue, + resourceable_id: resource.id + }) + }) + + if (!response.ok) { + throw new Error('Failed to add resource') + } + + // Close the modal + const modal = this.element.closest('dialog') + if (modal) { + modal.close() + } + + // Reload the turbo frame + if (this.hasTurboFrameValue) { + const frame = document.getElementById(this.turboFrameValue) + if (frame) { + frame.reload() + } + } else { + window.location.reload() + } + } catch (error) { + console.error('Error adding resource:', error) + alert('Failed to add resource. Please try again.') + itemElement.classList.remove('opacity-50') + } + } + + escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } + + getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.content || '' + } +} diff --git a/app/models/account_user.rb b/app/models/account_user.rb index 9d9c74a9..c818d409 100644 --- a/app/models/account_user.rb +++ b/app/models/account_user.rb @@ -21,4 +21,8 @@ class AccountUser < ApplicationRecord belongs_to :user belongs_to :account + + def admin? + account.owner_id == user_id + end end diff --git a/app/views/accounts/teams/_empty.html.erb b/app/views/accounts/teams/_empty.html.erb new file mode 100644 index 00000000..591e4e32 --- /dev/null +++ b/app/views/accounts/teams/_empty.html.erb @@ -0,0 +1,49 @@ +
    +
    +
    +

    Organize access with teams

    +

    + Teams let you group members and control which resources they can access. Perfect for managing permissions as your organization scales. +

    + +
    +
    +
    + +
    +

    Granular permissions

    +

    + Assign clusters, projects, and add-ons to specific teams. Members only see what they need. +

    +
    + +
    +
    + +
    +

    Simple onboarding

    +

    + Add someone to a team and they instantly get access to all the right resources. No manual setup required. +

    +
    + +
    +
    + +
    +

    Scale with confidence

    +

    + Structure teams by role, project, or however works best. Easily adjust as your organization evolves. +

    +
    +
    + +
    + <%= link_to new_team_path, class: "btn btn-primary" do %> + + Create your first team + <% end %> +
    +
    +
    +
    diff --git a/app/views/accounts/teams/_list.html.erb b/app/views/accounts/teams/_list.html.erb index c667322a..784a9ccf 100644 --- a/app/views/accounts/teams/_list.html.erb +++ b/app/views/accounts/teams/_list.html.erb @@ -1,40 +1,55 @@ -
    - - - - - - - - - - - <% teams.each do |team| %> - - - - - - - <% end %> - -
    - Name - - Members - - Resources -
    -
    <%= team.name %>
    -
    -
    <%= team.users.count %>
    -
    -
    <%= team.team_resources.count %>
    -
    -
    - <%= button_to team_path(team), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %> - - <% end %> -
    -
    -
    +<% if teams.empty? %> + <%= render "accounts/teams/empty" %> +<% else %> +
    +
    +
    + <%= link_to new_team_path, class: "btn btn-primary btn-sm" do %> + + + <% end %> +
    + +
    + + + + + + + + + + + <% teams.each do |team| %> + + + + + + + <% end %> + +
    + Name + + Members + + Resources +
    +
    <%= team.name %>
    +
    +
    <%= team.users.count %>
    +
    +
    <%= team.team_resources.count %>
    +
    +
    + <%= button_to team_path(team), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %> + + <% end %> +
    +
    +
    +
    +
    +<% end %> diff --git a/app/views/accounts/teams/_resource_content.html.erb b/app/views/accounts/teams/_resource_content.html.erb new file mode 100644 index 00000000..44c07471 --- /dev/null +++ b/app/views/accounts/teams/_resource_content.html.erb @@ -0,0 +1,29 @@ +<%= turbo_frame_tag("team_resources_#{team.id}", data: { turbo_tabs_target: "content" }) do %> +
    + <% if tab == "clusters" %> +
    + +
    + <%= render "accounts/teams/team_resources_table", team: team, resource_type: "Cluster" %> + <% elsif tab == "projects" %> +
    + +
    + <%= render "accounts/teams/team_resources_table", team: team, resource_type: "Project" %> + <% elsif tab == "addons" %> +
    + +
    + <%= render "accounts/teams/team_resources_table", team: team, resource_type: "AddOn" %> + <% end %> +
    +<% end %> diff --git a/app/views/accounts/teams/_resource_tabs.html.erb b/app/views/accounts/teams/_resource_tabs.html.erb new file mode 100644 index 00000000..ba96d0dc --- /dev/null +++ b/app/views/accounts/teams/_resource_tabs.html.erb @@ -0,0 +1,11 @@ +
    + <%= link_to team_path(team, tab: 'clusters'), class: "tab #{'tab-active' if tab == 'clusters'}" do %> + Clusters + <% end %> + <%= link_to team_path(team, tab: 'projects'), class: "tab #{'tab-active' if tab == 'projects'}" do %> + Projects + <% end %> + <%= link_to team_path(team, tab: 'addons'), class: "tab #{'tab-active' if tab == 'addons'}" do %> + Add-ons + <% end %> +
    diff --git a/app/views/accounts/teams/_team_resources_table.html.erb b/app/views/accounts/teams/_team_resources_table.html.erb new file mode 100644 index 00000000..c261f6a2 --- /dev/null +++ b/app/views/accounts/teams/_team_resources_table.html.erb @@ -0,0 +1,37 @@ +<% resources = team.team_resources.where(resourceable_type: resource_type) %> +
    + + + + + + + + + <% if resources.empty? %> + + + + <% else %> + <% resources.each do |team_resource| %> + + + + + <% end %> + <% end %> + +
    + Name +
    + No <%= resource_type.underscore.humanize.downcase.pluralize %> assigned to this team yet. +
    +
    <%= team_resource.resourceable.name %>
    +
    +
    + <%= button_to team_team_resource_path(team, team_resource), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %> + + <% end %> +
    +
    +
    diff --git a/app/views/accounts/teams/index.html.erb b/app/views/accounts/teams/index.html.erb index 45d7b9cf..57d97774 100644 --- a/app/views/accounts/teams/index.html.erb +++ b/app/views/accounts/teams/index.html.erb @@ -32,24 +32,9 @@
    -
    -
    -
    - <%= link_to new_team_path, class: "btn btn-primary btn-sm" do %> - - - <% end %> -
    - -
    - <%= tag.div id: ("teams" if @pagy.page == 1) do %> - <%= render "accounts/teams/list", teams: @teams, cached: true %> - <% end %> -
    -
    -
    + <%= tag.div id: "teams" do %> + <%= render "accounts/teams/list", teams: @teams %> + <% end %>
    - - <%= render 'shared/pagination', pagy: @pagy %>
    <% end %> diff --git a/app/views/accounts/teams/show.html.erb b/app/views/accounts/teams/show.html.erb index 3146c6f2..44f27718 100644 --- a/app/views/accounts/teams/show.html.erb +++ b/app/views/accounts/teams/show.html.erb @@ -3,6 +3,18 @@ <%= turbo_stream_from [:team_memberships, @team] %>
    + +
    @@ -54,16 +66,10 @@
    -
    -

    Team Resources

    - -
    - -
    - <%= render "accounts/teams/team_resources_list", team: @team, cached: true %> +
    +

    Team Resources

    + <%= render "accounts/teams/resource_tabs", team: @team, tab: @tab %> + <%= render "accounts/teams/resource_content", team: @team, tab: @tab %>
    @@ -103,45 +109,104 @@ - - + + -
    - - <%= form.select :resourceable_id, - [], - { prompt: "Select a resource" }, - class: "select select-bordered w-full focus:outline-offset-0", - id: "resourceable_id_select" %> - + + + + + + +
    diff --git a/app/views/devise/sessions/portainer.html.erb b/app/views/devise/sessions/portainer.html.erb deleted file mode 100644 index c3fe3699..00000000 --- a/app/views/devise/sessions/portainer.html.erb +++ /dev/null @@ -1,75 +0,0 @@ -
    -
    -
    -
    -

    - Log in to <%= @account.name %> -

    -
    - <%= render "devise/sessions/portainer_badge", stack_manager: @account.stack_manager %> -
    -
    -

    - Sign in to access your account -

    - - <%= form_with( - model: resource, - as: resource_name, - url: account_sign_in_path(@account.slug), - method: :post, - data: { turbo: false }, - html: { class: "space-y-4" }) do |f| - %> - <% if flash[:alert] %> -
    - - <%= flash[:alert] %> -
    - <% end %> - - <%= render 'shared/error_messages', resource: resource %> - -
    - <%= f.label :username, class: "label" do %> - Portainer Username - <% end %> - <%= f.text_field( - :username, - autofocus: true, - placeholder: "Enter your username", - class: "input input-bordered w-full", - required: true, - ) %> -
    - -
    - <%= f.label :password, class: "label" do %> - Portainer Password - <% end %> - <%= f.password_field( - :password, - autocomplete: "current-password", - placeholder: "Enter your password", - class: "input input-bordered w-full", - required: true, - ) %> -
    - -
    - <%= f.submit "Sign in to #{@account.name}", class: "btn btn-primary w-full" %> -
    - <% end %> - -
    New to <%= @account.name %>?
    - -

    - Contact your account administrator to get access -

    - -
    - <%= link_to "Back to main login", new_user_session_path, class: "link link-primary text-sm" %> -
    -
    -
    -
    \ No newline at end of file diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 957d6efa..df0ae075 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -8,9 +8,7 @@ <% if current_account.stack_manager.present? %>
    <%= render "devise/sessions/portainer_badge", - stack_manager: current_account.stack_manager, - verification_method: "authentication", - logout_on_unauthorized: true %> + stack_manager: current_account.stack_manager %>
    <% end %>
    diff --git a/app/views/providers/_portainer_token.html.erb b/app/views/providers/_portainer_token.html.erb new file mode 100644 index 00000000..9293cbf0 --- /dev/null +++ b/app/views/providers/_portainer_token.html.erb @@ -0,0 +1,48 @@ +<%= turbo_frame_tag "portainer_token" do %> +
    +

    Portainer Access Token

    +

    + This account is configured to use Portainer for cluster management. +

    + + <% if current_user.portainer_jwt.present? %> +
    + + Access token configured. +
    + <% else %> +
    + + No Access token configured. You won't be able to access cluster features until you add one. +
    + <% end %> + + <%= form_with url: portainer_token_path, method: :patch, class: "space-y-4", data: { turbo_frame: "portainer_token" } do |f| %> +
    + + <%= f.text_field :portainer_token, + value: "", + placeholder: current_user.portainer_jwt.present? ? "••••••••••••••••" : "Enter your Portainer Access token", + class: "input input-bordered w-full" %> + +
    +
    + <%= f.submit current_user.portainer_jwt.present? ? "Update Token" : "Save Token", class: "btn btn-primary" %> + <% if current_user.portainer_jwt.present? %> + <%= button_to portainer_token_path, + method: :delete, + class: "btn btn-outline btn-error", + data: { turbo_confirm: "Are you sure you want to remove your Portainer Access token?" } do %> + Remove Token + <% end %> + <% end %> +
    + <% end %> +
    +<% end %> diff --git a/app/views/providers/index.html.erb b/app/views/providers/index.html.erb index 3a1e5197..e30d068d 100644 --- a/app/views/providers/index.html.erb +++ b/app/views/providers/index.html.erb @@ -1,6 +1,7 @@ <%= settings_layout do %>

    Credentials


    + <%= turbo_frame_tag "provider" do %> <%= render "providers/index" %>
    @@ -16,4 +17,9 @@ <% end %>
    <% end %> + + <% if current_account.stack_manager&.portainer? && current_account.stack_manager.enable_role_based_access_control? %> +
    + <%= render "providers/portainer_token" %> + <% end %> <% end %> \ No newline at end of file diff --git a/app/views/settings/_layout.html.erb b/app/views/settings/_layout.html.erb index 4e1d47e3..51e3516a 100644 --- a/app/views/settings/_layout.html.erb +++ b/app/views/settings/_layout.html.erb @@ -1,6 +1,6 @@
    - <% if (current_account.stack_manager.present? && current_account.stack_manager.stack.provides_authentication?) || current_account.sso_provider.present? %> + <% if current_account.sso_provider.present? %>

    Account Login URL

    diff --git a/config/routes.rb b/config/routes.rb index e678cac9..edb14ea8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,8 +34,8 @@ Rails.application.routes.draw do resource :stack_manager, only: %i[show new create edit update destroy], controller: 'accounts/stack_managers' do collection do post :verify_url - post :verify_login post :check_reachable + post :verify_connectivity post :sync_clusters post :sync_registries end @@ -74,6 +74,7 @@ Rails.application.routes.draw do end resources :providers, only: %i[index new create destroy] + resource :portainer_token, only: %i[update destroy], controller: 'providers/portainer_tokens' resources :projects do member do post :restart diff --git a/lib/portainer/client.rb b/lib/portainer/client.rb index 97a7c45d..19599673 100644 --- a/lib/portainer/client.rb +++ b/lib/portainer/client.rb @@ -18,6 +18,7 @@ module Portainer class ConnectionError < StandardError; end class PermissionDeniedError < StandardError; end class AuthenticationError < StandardError; end + class MissingCredentialError < StandardError; end def initialize(provider_url, jwt) @jwt = jwt diff --git a/lib/portainer/login.rb b/lib/portainer/login.rb deleted file mode 100644 index f35a6c2c..00000000 --- a/lib/portainer/login.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Portainer::Login - extend LightService::Action - - expects :username, :password, :account - promises :user, :account - - executed do |context| - provider_url = context.account.stack_manager.provider_url - hostname = context.account.stack_manager.domain_host - context.user = User.find_or_initialize_by( - email: context.username + "@#{hostname}", - ) - portainer_user = Portainer::Client.authenticate( - username: context.username, - auth_code: context.password, - provider_url: provider_url - ) - - password = Devise.friendly_token - context.user.assign_attributes( - password:, - password_confirmation: password, - ) - context.user.save! - provider = context.user.providers.find_or_initialize_by(provider: "portainer") - provider.auth = { - info: { - username: portainer_user.username - } - }.to_json - provider.access_token = portainer_user.jwt - provider.save! - - unless context.account.users.include?(context.user) - context.account.account_users.create!(user: context.user) - end - rescue Portainer::Client::AuthenticationError => e - context.user.errors.add(:base, "Invalid username or password") - context.fail_and_return! - rescue Portainer::Client::ConnectionError => e - context.fail_and_return!(e.message) - rescue StandardError => e - context.user ||= User.new(email: context.username) - context.fail_and_return!("Authentication failed: #{e.message}") - end -end diff --git a/lib/portainer/stack.rb b/lib/portainer/stack.rb index d4d3b657..af5bf3f9 100644 --- a/lib/portainer/stack.rb +++ b/lib/portainer/stack.rb @@ -20,7 +20,7 @@ class Portainer::Stack elsif user.nil? && allow_anonymous && stack_manager.access_token.present? Portainer::Client::AccessToken.new(stack_manager.access_token) else - raise "No access token found for user or stack manager. Please check your configuration." + raise Portainer::Client::MissingCredentialError, "Please add your Portainer API token in the Credentials settings." end end @@ -42,7 +42,7 @@ class Portainer::Stack end def provides_authentication? - true + false end def provides_registries? diff --git a/spec/lib/portainer/login_spec.rb b/spec/lib/portainer/login_spec.rb deleted file mode 100644 index 1bb48a67..00000000 --- a/spec/lib/portainer/login_spec.rb +++ /dev/null @@ -1,77 +0,0 @@ -require 'rails_helper' - -RSpec.describe Portainer::Login do - let(:account) { create(:account) } - let(:stack_manager) { create(:stack_manager, account: account, provider_url: 'https://portainer.example.com') } - let(:username) { 'testuser' } - let(:password) { 'testpassword' } - let(:jwt) { 'valid-jwt-token' } - - let(:context) do - LightService::Context.make( - username: username, - password: password, - account: account - ) - end - - before do - account.stack_manager = stack_manager - end - - describe '#execute' do - context 'when authentication succeeds' do - before do - allow(Portainer::Client).to receive(:authenticate).and_return(Portainer::Data::User.new(id: 1, username:, jwt:)) - end - - it 'creates or finds a user' do - described_class.execute(context) - - expect(context).to be_success - expect(context.user).to be_persisted - expect(context.user.email).to eq('testuser@portainer.example.com') - end - - it 'stores the JWT token in provider' do - described_class.execute(context) - - provider = context.user.providers.find_by(provider: 'portainer') - expect(provider.access_token).to eq(jwt) - end - end - - context 'when authentication fails' do - before do - allow(Portainer::Client).to receive(:authenticate).and_raise(Portainer::Client::AuthenticationError.new('Invalid username or password')) - end - - it 'fails with error message' do - described_class.execute(context) - - expect(context).to be_failure - expect(context.user.errors[:base]).to include('Invalid username or password') - end - - it 'does not create a user' do - expect { - described_class.execute(context) - }.not_to change(User, :count) - end - end - - context 'when connection error occurs' do - before do - allow(Portainer::Client).to receive(:authenticate) - .and_raise(Portainer::Client::ConnectionError.new('Connection timeout')) - end - - it 'fails with connection error message' do - described_class.execute(context) - - expect(context).to be_failure - expect(context.message).to eq('Connection timeout') - end - end - end -end From dceefb418453b580be599529adcc2f8d686c3463 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 4 Dec 2025 00:16:39 -0800 Subject: [PATCH 31/75] clean up code a bit --- app/views/providers/_portainer_token.html.erb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/providers/_portainer_token.html.erb b/app/views/providers/_portainer_token.html.erb index 9293cbf0..1aef7d0e 100644 --- a/app/views/providers/_portainer_token.html.erb +++ b/app/views/providers/_portainer_token.html.erb @@ -6,9 +6,9 @@

    <% if current_user.portainer_jwt.present? %> -
    - - Access token configured. +
    + + Access token configured
    <% else %>
    @@ -17,7 +17,7 @@
    <% end %> - <%= form_with url: portainer_token_path, method: :patch, class: "space-y-4", data: { turbo_frame: "portainer_token" } do |f| %> + <%= form_with url: portainer_token_path, method: :patch, class: "space-y-4", data: { turbo: false } do |f| %>
    diff --git a/public/500.html b/public/500.html index d0e67f6d..44501ab2 100644 --- a/public/500.html +++ b/public/500.html @@ -43,14 +43,6 @@
    -
    From 0c21dc9caf42fddee4b719519a43453a74e804b0 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 11 Dec 2025 14:07:46 -0800 Subject: [PATCH 64/75] handle more healthcheck errors --- app/jobs/scheduled/check_health_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/jobs/scheduled/check_health_job.rb b/app/jobs/scheduled/check_health_job.rb index 26878d7c..07f976a0 100644 --- a/app/jobs/scheduled/check_health_job.rb +++ b/app/jobs/scheduled/check_health_job.rb @@ -15,7 +15,7 @@ class Scheduled::CheckHealthJob < ApplicationJob else service.status = :unhealthy end - rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError, HTTParty::Error, OpenSSL::SSL::SSLError => e + rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError, HTTParty::Error, OpenSSL::SSL::SSLError => e Rails.logger.warn("Health check failed for #{service.name}: #{e.class} - #{e.message}") service.status = :unhealthy end From 6f702daf5edaf850628c520db7e1b40fb67ec334 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 11 Dec 2025 15:03:58 -0800 Subject: [PATCH 65/75] use floating ui for coplex dropdowns --- .../teams/team_members_search_controller.rb | 3 ++- .../async_search_dropdown_controller.js | 4 ++-- .../team_member_search_controller.js | 16 +++++++++++---- .../team_resource_search_controller.js | 6 +++--- app/javascript/utils/helm_charts/index.js | 4 ++-- app/views/accounts/teams/show.html.erb | 20 +++++++------------ 6 files changed, 28 insertions(+), 25 deletions(-) diff --git a/app/controllers/accounts/teams/team_members_search_controller.rb b/app/controllers/accounts/teams/team_members_search_controller.rb index df88087c..e1828963 100644 --- a/app/controllers/accounts/teams/team_members_search_controller.rb +++ b/app/controllers/accounts/teams/team_members_search_controller.rb @@ -21,7 +21,8 @@ class Accounts::Teams::TeamMembersSearchController < ApplicationController email: user.email, name: user.name, first_name: user.first_name, - last_name: user.last_name + last_name: user.last_name, + avatar_url: helpers.avatar_path(user, size: 64) } } end diff --git a/app/javascript/controllers/components/async_search_dropdown_controller.js b/app/javascript/controllers/components/async_search_dropdown_controller.js index 68389ea9..0b726b19 100644 --- a/app/javascript/controllers/components/async_search_dropdown_controller.js +++ b/app/javascript/controllers/components/async_search_dropdown_controller.js @@ -57,7 +57,7 @@ export default class extends Controller { createDropdown() { const dropdown = document.createElement('ul') - dropdown.className = 'hidden z-50 menu bg-neutral rounded-box shadow-lg max-h-[300px] overflow-y-auto' + dropdown.className = 'hidden z-50 bg-neutral rounded-box shadow-lg max-h-[300px] overflow-y-auto' dropdown.style.position = 'absolute' dropdown.style.top = '0' dropdown.style.left = '0' @@ -130,7 +130,7 @@ export default class extends Controller { } this.dropdown.innerHTML = results.map((item, index) => ` -
  • +
  • ${this.renderItem(item)}
  • `).join('') diff --git a/app/javascript/controllers/team_member_search_controller.js b/app/javascript/controllers/team_member_search_controller.js index a42087fc..f8220d74 100644 --- a/app/javascript/controllers/team_member_search_controller.js +++ b/app/javascript/controllers/team_member_search_controller.js @@ -22,11 +22,19 @@ export default class extends AsyncSearchDropdownController { } renderItem(user) { + const displayName = user.name || user.first_name || user.email.split('@')[0] + const showEmail = user.name || user.first_name + return ` -
    -
    -
    ${this.escapeHtml(user.name || user.email)}
    - ${user.email !== user.name ? `
    ${this.escapeHtml(user.email)}
    ` : ''} +
    +
    +
    + ${this.escapeHtml(displayName)} +
    +
    +
    +
    ${this.escapeHtml(displayName)}
    + ${showEmail ? `
    ${this.escapeHtml(user.email)}
    ` : ''}
    ` diff --git a/app/javascript/controllers/team_resource_search_controller.js b/app/javascript/controllers/team_resource_search_controller.js index 45de99b3..b46942c9 100644 --- a/app/javascript/controllers/team_resource_search_controller.js +++ b/app/javascript/controllers/team_resource_search_controller.js @@ -25,9 +25,9 @@ export default class extends AsyncSearchDropdownController { renderItem(resource) { return ` -
    -
    -
    ${this.escapeHtml(resource.name)}
    +
    +
    +
    ${this.escapeHtml(resource.name)}
    ` diff --git a/app/javascript/utils/helm_charts/index.js b/app/javascript/utils/helm_charts/index.js index c48a6498..b87f9cc0 100644 --- a/app/javascript/utils/helm_charts/index.js +++ b/app/javascript/utils/helm_charts/index.js @@ -23,7 +23,7 @@ export async function getDefaultValues( export function helmChartHeader(packageData) { const logoImageUrl = getLogoImageUrl(packageData); return ` -
    +
    ${packageData.name}
    @@ -41,7 +41,7 @@ export function helmChartHeader(packageData) { ${packageData.stars} ` : ''}
    -

    ${packageData.description}

    +

    ${packageData.description}

    diff --git a/app/views/accounts/teams/show.html.erb b/app/views/accounts/teams/show.html.erb index 44f27718..c416c4e9 100644 --- a/app/views/accounts/teams/show.html.erb +++ b/app/views/accounts/teams/show.html.erb @@ -3,22 +3,16 @@ <%= turbo_stream_from [:team_memberships, @team] %>
    - -
    -

    <%= @team.name %>

    +

    + <%= link_to teams_path, class: "text-base-content/60 hover:text-base-content" do %> + Teams + <% end %> + / + <%= @team.name %> +

    From d302172904345c7f85c99d5568a9e1afbc213987 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 12 Dec 2025 12:59:47 -0800 Subject: [PATCH 66/75] added oidc impementation --- .../sso_providers/build_sso_configuration.rb | 9 +- app/avo/resources/oidc_configuration.rb | 20 ++ app/controllers/accounts/oidc_controller.rb | 111 ++++++++++ .../accounts/sso_providers_controller.rb | 50 ++++- .../avo/oidc_configurations_controller.rb | 4 + app/controllers/users/sessions_controller.rb | 6 + app/models/oidc_configuration.rb | 36 ++++ app/models/sso_provider.rb | 4 + app/services/oidc/authenticator.rb | 147 ++++++++++++++ .../accounts/sso_providers/edit.html.erb | 61 +++--- .../sso_providers/ldap/_form_fields.html.erb | 192 +++++++++--------- app/views/accounts/sso_providers/new.html.erb | 70 ++++--- .../sso_providers/oidc/_form_fields.html.erb | 121 +++++++++++ .../oidc/_redirect_uri_info.html.erb | 8 + .../accounts/sso_providers/show.html.erb | 63 ++++-- app/views/devise/sessions/oidc.html.erb | 40 ++++ config/initializers/inflections.rb | 1 + config/routes.rb | 4 + ...251211151950_create_oidc_configurations.rb | 19 ++ db/schema.rb | 18 +- docker-compose.yml | 14 +- install/portainer/docker-compose.yml | 14 +- spec/factories/oidc_configurations.rb | 34 ++++ spec/models/oidc_configuration_spec.rb | 64 ++++++ spec/models/provider_spec.rb | 27 +++ 25 files changed, 953 insertions(+), 184 deletions(-) create mode 100644 app/avo/resources/oidc_configuration.rb create mode 100644 app/controllers/accounts/oidc_controller.rb create mode 100644 app/controllers/avo/oidc_configurations_controller.rb create mode 100644 app/models/oidc_configuration.rb create mode 100644 app/services/oidc/authenticator.rb create mode 100644 app/views/accounts/sso_providers/oidc/_form_fields.html.erb create mode 100644 app/views/accounts/sso_providers/oidc/_redirect_uri_info.html.erb create mode 100644 app/views/devise/sessions/oidc.html.erb create mode 100644 db/migrate/20251211151950_create_oidc_configurations.rb create mode 100644 spec/factories/oidc_configurations.rb create mode 100644 spec/models/oidc_configuration_spec.rb diff --git a/app/actions/sso_providers/build_sso_configuration.rb b/app/actions/sso_providers/build_sso_configuration.rb index 0a1eb602..985d2f1e 100644 --- a/app/actions/sso_providers/build_sso_configuration.rb +++ b/app/actions/sso_providers/build_sso_configuration.rb @@ -7,7 +7,14 @@ module SSOProviders promises :configuration executed do |context| - context.configuration = LDAPConfiguration.new(context.configuration_params) + context.configuration = case context.provider_type + when "ldap" + LDAPConfiguration.new(context.configuration_params) + when "oidc" + OIDCConfiguration.new(context.configuration_params) + else + context.fail_and_return!("Unknown provider type: #{context.provider_type}") + end end end end diff --git a/app/avo/resources/oidc_configuration.rb b/app/avo/resources/oidc_configuration.rb new file mode 100644 index 00000000..3a4d2cb7 --- /dev/null +++ b/app/avo/resources/oidc_configuration.rb @@ -0,0 +1,20 @@ +class Avo::Resources::OIDCConfiguration < Avo::BaseResource + self.includes = [] + + def fields + field :id, as: :id + field :issuer, as: :text, required: true, help: "OIDC provider issuer URL (e.g., https://auth.example.com)" + field :client_id, as: :text, required: true, help: "OAuth 2.0 client ID" + field :client_secret, as: :password, required: true, help: "OAuth 2.0 client secret" + field :authorization_endpoint, as: :text, help: "Authorization endpoint URL (leave blank to use discovery)" + field :token_endpoint, as: :text, help: "Token endpoint URL (leave blank to use discovery)" + field :userinfo_endpoint, as: :text, help: "UserInfo endpoint URL (leave blank to use discovery)" + field :jwks_uri, as: :text, help: "JWKS URI for token verification (leave blank to use discovery)" + field :scopes, as: :text, help: "Space-separated scopes to request", default: "openid email profile" + field :uid_claim, as: :text, required: true, help: "Claim to use as user identifier", default: "sub" + field :email_claim, as: :text, help: "Claim for email address", default: "email" + field :name_claim, as: :text, help: "Claim for full name", default: "name" + + field :sso_provider, as: :has_one + end +end diff --git a/app/controllers/accounts/oidc_controller.rb b/app/controllers/accounts/oidc_controller.rb new file mode 100644 index 00000000..9cd2501d --- /dev/null +++ b/app/controllers/accounts/oidc_controller.rb @@ -0,0 +1,111 @@ +module Accounts + class OIDCController < ApplicationController + skip_before_action :authenticate_user! + before_action :load_account + + def authorize + oidc_config = @account.sso_provider&.configuration + unless oidc_config.is_a?(OIDCConfiguration) + redirect_to account_sign_in_path(@account.slug), alert: "OIDC is not configured for this account" + return + end + + # Store state in session for CSRF protection + state = SecureRandom.hex(32) + session[:oidc_state] = state + + # Build authorization URL + auth_url = build_authorization_url(oidc_config, state) + redirect_to auth_url, allow_other_host: true + end + + def callback + # Verify state for CSRF protection + unless params[:state].present? && params[:state] == session[:oidc_state] + redirect_to root_path, alert: "Invalid state parameter" + return + end + + oidc_config = @account.sso_provider&.configuration + + unless oidc_config.is_a?(OIDCConfiguration) + redirect_to root_path, alert: "OIDC is not configured" + return + end + + if params[:error].present? + redirect_to account_sign_in_path(@account.slug), alert: "Authentication failed: #{params[:error_description] || params[:error]}" + return + end + + # Exchange code for tokens + result = OIDC::Authenticator.new(oidc_config).authenticate( + code: params[:code], + redirect_uri: oidc_callback_url(slug: @account.slug) + ) + + unless result.success? + redirect_to account_sign_in_path(@account.slug), alert: result.error_message + return + end + + # Create or find user + sso_provider = @account.sso_provider + if sso_provider.just_in_time_team_provisioning_mode? + ar_result = ActiveRecord::Base.transaction do + SSO::SyncUserTeams.call(result.email, result.groups || [], @account) + end + else + ar_result = SSO::CreateUserInAccount.execute( + email: result.email, + account: @account + ) + end + + if ar_result.failure? + redirect_to account_sign_in_path(@account.slug), alert: "Failed to create user account" + return + end + + # Clear session state + session.delete(:oidc_state) + + # Sign in user + sign_in(ar_result.user) + session[:account_id] = @account.id + redirect_to after_sign_in_path_for(ar_result.user), notice: "Signed in successfully" + end + + private + + def load_account + @account = Account.friendly.find(params[:slug]) + rescue ActiveRecord::RecordNotFound + redirect_to root_path, alert: "Account not found" + end + + def build_authorization_url(oidc_config, state) + params = { + response_type: "code", + client_id: oidc_config.client_id, + redirect_uri: oidc_callback_url(slug: @account.slug), + scope: oidc_config.scopes, + state: state + } + + auth_endpoint = oidc_config.authorization_endpoint.presence || discover_authorization_endpoint(oidc_config) + "#{auth_endpoint}?#{params.to_query}" + end + + def discover_authorization_endpoint(oidc_config) + # Fetch from OIDC discovery document + discovery_url = oidc_config.discovery_url + response = HTTP.get(discovery_url) + if response.status.success? + JSON.parse(response.body.to_s)["authorization_endpoint"] + else + raise "Failed to discover OIDC endpoints" + end + end + end +end diff --git a/app/controllers/accounts/sso_providers_controller.rb b/app/controllers/accounts/sso_providers_controller.rb index b2b39dff..de0b2e96 100644 --- a/app/controllers/accounts/sso_providers_controller.rb +++ b/app/controllers/accounts/sso_providers_controller.rb @@ -6,23 +6,29 @@ module Accounts end def new + @provider_type = params[:provider_type] || "ldap" @sso_provider = current_account.build_sso_provider @ldap_configuration = LDAPConfiguration.new + @oidc_configuration = OIDCConfiguration.new end def create + provider_type = params[:provider_type] || "ldap" + result = SSOProviders::Create.call( account: current_account, sso_provider_params: sso_provider_params, - configuration_params: ldap_configuration_params, - provider_type: "ldap" + configuration_params: configuration_params_for(provider_type), + provider_type: provider_type ) if result.success? redirect_to sso_provider_path, notice: "SSO provider created successfully" else + @provider_type = provider_type @sso_provider = result.sso_provider - @ldap_configuration = result.configuration + @ldap_configuration = provider_type == "ldap" ? result.configuration : LDAPConfiguration.new + @oidc_configuration = provider_type == "oidc" ? result.configuration : OIDCConfiguration.new render :new, status: :unprocessable_entity end end @@ -30,22 +36,27 @@ module Accounts def edit @sso_provider = current_account.sso_provider redirect_to new_sso_provider_path, alert: "No SSO provider configured" unless @sso_provider - @ldap_configuration = @sso_provider&.configuration + @provider_type = @sso_provider&.oidc? ? "oidc" : "ldap" + @ldap_configuration = @sso_provider&.ldap? ? @sso_provider.configuration : LDAPConfiguration.new + @oidc_configuration = @sso_provider&.oidc? ? @sso_provider.configuration : OIDCConfiguration.new end def update @sso_provider = current_account.sso_provider + provider_type = @sso_provider.oidc? ? "oidc" : "ldap" result = SSOProviders::Update.call( sso_provider: @sso_provider, sso_provider_params: sso_provider_params, - configuration_params: ldap_configuration_params + configuration_params: configuration_params_for(provider_type) ) if result.success? redirect_to sso_provider_path, notice: "SSO provider updated successfully" else - @ldap_configuration = @sso_provider.configuration + @provider_type = provider_type + @ldap_configuration = @sso_provider.ldap? ? @sso_provider.configuration : LDAPConfiguration.new + @oidc_configuration = @sso_provider.oidc? ? @sso_provider.configuration : OIDCConfiguration.new render :edit, status: :unprocessable_entity end end @@ -84,6 +95,17 @@ module Accounts params.require(:sso_provider).permit(:name, :enabled, :team_provisioning_mode) end + def configuration_params_for(provider_type) + case provider_type + when "ldap" + ldap_configuration_params + when "oidc" + oidc_configuration_params + else + {} + end + end + def ldap_configuration_params params.require(:ldap_configuration).permit( :host, @@ -98,5 +120,21 @@ module Accounts :encryption ) end + + def oidc_configuration_params + params.require(:oidc_configuration).permit( + :issuer, + :client_id, + :client_secret, + :authorization_endpoint, + :token_endpoint, + :userinfo_endpoint, + :jwks_uri, + :scopes, + :uid_claim, + :email_claim, + :name_claim + ) + end end end diff --git a/app/controllers/avo/oidc_configurations_controller.rb b/app/controllers/avo/oidc_configurations_controller.rb new file mode 100644 index 00000000..08b8968b --- /dev/null +++ b/app/controllers/avo/oidc_configurations_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::OIDCConfigurationsController < Avo::ResourcesController +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index a881a9b1..b1848487 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -36,8 +36,11 @@ class Users::SessionsController < Devise::SessionsController self.resource = resource_class.new(sign_in_params) clean_up_passwords(resource) @sso_provider = @account.sso_provider if @account.sso_enabled? + if @account.sso_provider&.ldap? render "devise/sessions/ldap" + elsif @account.sso_provider&.oidc? + render "devise/sessions/oidc" else render :new end @@ -59,6 +62,9 @@ class Users::SessionsController < Devise::SessionsController clean_up_passwords(self.resource) render "devise/sessions/ldap" end + elsif @account.sso_provider&.oidc? + # OIDC uses a redirect flow, so this shouldn't be called directly + redirect_to account_sign_in_path(@account.slug) else redirect_to new_user_session_path end diff --git a/app/models/oidc_configuration.rb b/app/models/oidc_configuration.rb new file mode 100644 index 00000000..65bdc47d --- /dev/null +++ b/app/models/oidc_configuration.rb @@ -0,0 +1,36 @@ +# == Schema Information +# +# Table name: oidc_configurations +# +# id :bigint not null, primary key +# issuer :string not null +# client_id :string not null +# client_secret :string not null +# authorization_endpoint :string +# token_endpoint :string +# userinfo_endpoint :string +# jwks_uri :string +# scopes :string default("openid email profile") +# uid_claim :string default("sub"), not null +# email_claim :string default("email") +# name_claim :string default("name") +# created_at :datetime not null +# updated_at :datetime not null +# +class OIDCConfiguration < ApplicationRecord + has_one :sso_provider, as: :configuration, dependent: :destroy + has_one :account, through: :sso_provider + + validates :issuer, presence: true + validates :client_id, presence: true + validates :client_secret, presence: true + validates :uid_claim, presence: true + + def discovery_url + "#{issuer.chomp('/')}/.well-known/openid-configuration" + end + + def uses_discovery? + authorization_endpoint.blank? && token_endpoint.blank? + end +end diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index 51311213..ed1cbfac 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -36,4 +36,8 @@ class SSOProvider < ApplicationRecord def ldap? configuration_type == "LDAPConfiguration" end + + def oidc? + configuration_type == "OIDCConfiguration" + end end diff --git a/app/services/oidc/authenticator.rb b/app/services/oidc/authenticator.rb new file mode 100644 index 00000000..40bd09f3 --- /dev/null +++ b/app/services/oidc/authenticator.rb @@ -0,0 +1,147 @@ +# app/services/oidc/authenticator.rb +module OIDC + class Authenticator + Result = Struct.new( + :success?, + :email, + :name, + :uid, + :groups, + :id_token, + :access_token, + :error_message, + keyword_init: true + ) + + def initialize(oidc_configuration, logger: Rails.logger) + @config = oidc_configuration + @logger = logger + @discovery_cache = nil + end + + def authenticate(code:, redirect_uri:) + # Exchange authorization code for tokens + token_result = exchange_code_for_tokens(code, redirect_uri) + return token_result if token_result.failure? + + # Parse and validate ID token claims + claims = extract_claims(token_result.id_token, token_result.access_token) + return claims if claims.is_a?(Result) && claims.failure? + + email = claims[config.email_claim] || claims["email"] + name = claims[config.name_claim] || claims["name"] + uid = claims[config.uid_claim] || claims["sub"] + + if email.blank? + return Result.new(success?: false, error_message: "Email claim not found in token") + end + + Result.new( + success?: true, + email: email, + name: name, + uid: uid, + groups: extract_groups(claims), + id_token: token_result.id_token, + access_token: token_result.access_token, + error_message: nil + ) + rescue => e + @logger.error "OIDC auth: unexpected error - #{e.class}: #{e.message}" + Result.new(success?: false, error_message: e.message) + end + + private + + attr_reader :config, :logger + + def exchange_code_for_tokens(code, redirect_uri) + token_endpoint = config.token_endpoint.presence || discover_endpoint("token_endpoint") + + response = HTTP.post(token_endpoint, form: { + grant_type: "authorization_code", + code: code, + redirect_uri: redirect_uri, + client_id: config.client_id, + client_secret: config.client_secret + }) + + unless response.status.success? + error_body = JSON.parse(response.body.to_s) rescue {} + error_msg = error_body["error_description"] || error_body["error"] || "Token exchange failed" + return Result.new(success?: false, error_message: error_msg) + end + + token_data = JSON.parse(response.body.to_s) + + OpenStruct.new( + success?: true, + id_token: token_data["id_token"], + access_token: token_data["access_token"], + refresh_token: token_data["refresh_token"] + ) + rescue HTTP::Error => e + Result.new(success?: false, error_message: "Failed to exchange code: #{e.message}") + end + + def extract_claims(id_token, access_token) + if id_token.present? + # Decode JWT without verification for now (verification should be added for production) + # The ID token contains the user claims + payload = decode_jwt(id_token) + return payload if payload.is_a?(Result) + payload + elsif access_token.present? + # Fallback to userinfo endpoint + fetch_userinfo(access_token) + else + Result.new(success?: false, error_message: "No tokens received") + end + end + + def decode_jwt(token) + # Simple JWT decode (without signature verification - should add JWKS verification for production) + parts = token.split(".") + return Result.new(success?: false, error_message: "Invalid JWT format") if parts.length < 2 + + payload = Base64.urlsafe_decode64(parts[1] + "=" * (4 - parts[1].length % 4)) + JSON.parse(payload) + rescue => e + Result.new(success?: false, error_message: "Failed to decode JWT: #{e.message}") + end + + def fetch_userinfo(access_token) + userinfo_endpoint = config.userinfo_endpoint.presence || discover_endpoint("userinfo_endpoint") + + response = HTTP.auth("Bearer #{access_token}").get(userinfo_endpoint) + + unless response.status.success? + return Result.new(success?: false, error_message: "Failed to fetch user info") + end + + JSON.parse(response.body.to_s) + rescue => e + Result.new(success?: false, error_message: "Failed to fetch user info: #{e.message}") + end + + def extract_groups(claims) + # Common group claims from various OIDC providers + groups = claims["groups"] || claims["roles"] || claims["cognito:groups"] || [] + groups = [ groups ] unless groups.is_a?(Array) + groups.map { |g| { name: g.to_s } } + end + + def discover_endpoint(endpoint_name) + discovery_doc[endpoint_name] + end + + def discovery_doc + return @discovery_cache if @discovery_cache + + response = HTTP.get(config.discovery_url) + raise "Failed to fetch OIDC discovery document" unless response.status.success? + + @discovery_cache = JSON.parse(response.body.to_s) + end + end +end diff --git a/app/views/accounts/sso_providers/edit.html.erb b/app/views/accounts/sso_providers/edit.html.erb index 5ff26094..130bebc1 100644 --- a/app/views/accounts/sso_providers/edit.html.erb +++ b/app/views/accounts/sso_providers/edit.html.erb @@ -5,42 +5,49 @@
    - Update your LDAP provider configuration. + <% if @provider_type == "oidc" %> + Update your OIDC provider configuration. + <% else %> + Update your LDAP provider configuration. + <% end %>
    <%= form_with model: @sso_provider, url: sso_provider_path do |form| %> <%= render "shared/error_messages", resource: form.object %> -
    - - <%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %> - -
    +
    + <%= render(FormFieldComponent.new( + label: "Provider Name", + description: "A friendly name for this SSO provider" + )) do %> + <%= form.text_field :name, class: "input input-bordered w-full", required: true, placeholder: "e.g., Company SSO" %> + + <% end %> -
    - + <% end %> + + <%= render(FormFieldComponent.new( + label: "Team Provisioning Mode", + description: "How teams are provisioned for SSO users" + )) do %> + <%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered w-full" %> + <% end %>
    -
    - - <%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered" %> - -
    - -
    LDAP Configuration
    - - <%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %> + <% if @provider_type == "oidc" %> +
    OIDC Configuration
    + <%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %> + <% else %> +
    LDAP Configuration
    + <%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %> + <% end %>
    <% end %>
    \ No newline at end of file diff --git a/app/views/projects/services/new.html.erb b/app/views/projects/services/new.html.erb index d0b33652..aa6be6e6 100644 --- a/app/views/projects/services/new.html.erb +++ b/app/views/projects/services/new.html.erb @@ -124,7 +124,7 @@
    <% end %> diff --git a/app/views/projects/services/resource_constraints/_show.html.erb b/app/views/projects/services/resource_constraints/_show.html.erb index 2b0b2bcb..1bfcb0c3 100644 --- a/app/views/projects/services/resource_constraints/_show.html.erb +++ b/app/views/projects/services/resource_constraints/_show.html.erb @@ -10,12 +10,12 @@ <%= render "projects/services/resource_constraints/form", form: form, resource_constraint: %> <% if resource_constraint.persisted? %> <% else %> <% end %> <% end %> diff --git a/app/views/projects/update/_edit_form_container_registry.html.erb b/app/views/projects/update/_edit_form_container_registry.html.erb index 2643b9c5..ba8bc60e 100644 --- a/app/views/projects/update/_edit_form_container_registry.html.erb +++ b/app/views/projects/update/_edit_form_container_registry.html.erb @@ -19,7 +19,7 @@