This commit is contained in:
Chris
2025-12-15 18:34:17 -08:00
270 changed files with 6450 additions and 1139 deletions

View File

@@ -53,10 +53,19 @@ jobs:
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
CI: true
run: |
bin/rails db:test:prepare
bin/bundle exec rspec --exclude-pattern spec/system/local/**/*_spec.rb
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
if-no-files-found: ignore
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
if: failure()

View File

@@ -3,13 +3,16 @@ name: Docker Build and Push
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -19,3 +22,20 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/caninehq/canine:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -80,8 +80,8 @@ gem "noticed", "~> 2.9"
gem "octokit", "~> 10.0"
gem "oj", "~> 3.16"
gem "omniauth", "~> 2.1"
gem "omniauth-digitalocean", "~> 0.3.2"
gem "omniauth-github", "~> 2.0"
gem "omniauth_openid_connect", "~> 0.8"
gem "omniauth-gitlab", "~> 4.1"
gem "omniauth-rails_csrf_protection", "~> 1.0"
gem "pagy", "~> 9.4"
@@ -101,3 +101,7 @@ gem "tailwindcss-rails", "~> 2.7"
gem 'flipper', '~> 1.2.2'
gem 'flipper-active_record', '~> 1.2.2'
gem 'flipper-ui', '~> 1.2.2'
gem "net-ldap", "~> 0.20.0"
gem "jwt", "~> 2.9"

View File

@@ -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
json-schema (5.2.2)
addressable (~> 2.8)
bigdecimal (~> 3.1)
@@ -344,6 +358,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)
@@ -385,10 +402,6 @@ GEM
logger
rack (>= 2.2.3)
rack-protection
omniauth-digitalocean (0.3.2)
multi_json (~> 1.15)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.0)
omniauth-github (2.0.1)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
@@ -401,6 +414,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)
@@ -435,6 +464,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)
@@ -594,6 +630,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)
@@ -624,6 +665,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)
@@ -635,6 +679,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)
@@ -682,18 +730,20 @@ DEPENDENCIES
importmap-rails
jbuilder
jsbundling-rails
jwt (~> 2.9)
k8s-ruby (~> 0.17.2)
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)
omniauth (~> 2.1)
omniauth-digitalocean (~> 0.3.2)
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)

View File

@@ -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

View File

@@ -1,47 +1,40 @@
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, user)
with(add_on:, user:).reduce(
AddOns::Create::ToNamespaced,
Namespaced::SetUpNamespace,
Namespaced::ValidateNamespace,
AddOns::ApplyTemplateToValues,
AddOns::SetPackageDetails,
AddOns::Save
)
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -12,7 +12,7 @@ class AddOns::UninstallHelmChart
end
client = K8::Client.new(connection)
if (namespace = client.get_namespaces.find { |n| n.metadata.name == add_on.name }).present?
if add_on.managed_namespace? && (namespace = client.get_namespaces.find { |n| n.metadata.name == add_on.namespace }).present?
client.delete_namespace(namespace.metadata.name)
end

View File

@@ -0,0 +1,54 @@
# frozen_string_literal: true
module AddOns
class VisibleToUser
extend LightService::Action
expects :account_user
promises :add_ons
executed do |context|
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?
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

View File

@@ -4,6 +4,10 @@ class Clusters::CreateNamespace
expects :kubectl
executed do |context|
context.kubectl.apply_yaml(K8::Namespace.new(Struct.new(:name).new(Clusters::Install::DEFAULT_NAMESPACE)).to_yaml)
context.kubectl.apply_yaml(
K8::Namespace.new(
Struct.new(:namespace).new(Clusters::Install::DEFAULT_NAMESPACE)
).to_yaml
)
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
module Clusters
class VisibleToUser
extend LightService::Action
expects :account_user
promises :clusters
executed do |context|
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?
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

View File

@@ -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

View File

@@ -0,0 +1,58 @@
module Namespaced
class ValidateNamespace
extend LightService::Action
expects :namespaced, :user
def self.validate_namespace_does_not_exist_or_is_managed(
context,
namespaced,
client,
existing_namespaces
)
namespace_exists = existing_namespaces.any? do |ns|
ns.metadata.name == namespaced.namespace && ns.metadata&.labels&.caninemanaged != "true"
end
if namespace_exists
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,
namespaced,
client,
existing_namespaces
)
existing_namespace = existing_namespaces.any? do |ns|
ns.metadata.name == namespaced.namespace
end
unless existing_namespace
error_message = "`#{namespaced.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable <b>auto create namespace</b>"
namespaced.errors.add(:base, error_message)
context.fail_and_return!(error_message)
end
end
executed do |context|
namespaced = context.namespaced
cluster = namespaced.cluster
begin
client = K8::Client.new(K8::Connection.new(cluster, context.user))
existing_namespaces = client.get_namespaces
if namespaced.managed_namespace
validate_namespace_does_not_exist_or_is_managed(context, namespaced, client, existing_namespaces)
else
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
Rails.logger.warn("Could not check namespace availability: #{e.message}")
end
end
end
end

View File

@@ -3,15 +3,20 @@ class Networks::CheckDns
expects :ingress, :connection
class << self
def infer_expected_ip(ingress, connection)
def infer_expected_dns(ingress, connection)
ingress.connect(connection)
ip = ingress.ip_address
dns_record = ingress.hostname
if is_private_ip?(ip)
if dns_record[:type] == :ip_address && is_private_ip?(dns_record[:value])
cluster = ingress.service.project.cluster
ip = infer_public_ip_from_cluster(connection)
# This only works if it is a single node cluster like k3s
public_ip = infer_public_ip_from_cluster(connection)
dns_record = {
value: public_ip,
type: :ip_address
}
end
ip
dns_record
end
def is_private_ip?(ip)
@@ -40,18 +45,31 @@ class Networks::CheckDns
executed do |context|
# TODO
expected_ip = infer_expected_ip(context.ingress, context.connection)
expected_dns = infer_expected_dns(context.ingress, context.connection)
context.ingress.service.domains.each do |domain|
ip_addresses = Resolv::DNS.open do |dns|
dns.getresources(domain.domain_name, Resolv::DNS::Resource::IN::A).map do |resource|
resource.address
if expected_dns[:type] == :ip_address
ip_addresses = Resolv::DNS.open do |dns|
dns.getresources(domain.domain_name, Resolv::DNS::Resource::IN::A).map do |resource|
resource.address
end
end
end
if ip_addresses.any? && ip_addresses.first.to_s == expected_ip
domain.update(status: :dns_verified)
if ip_addresses.any? && ip_addresses.first.to_s == expected_dns[:value]
domain.update(status: :dns_verified)
else
domain.update(status: :dns_incorrect, status_reason: "DNS record (#{ip_addresses.first || "empty"}) does not match expected IP address (#{expected_dns[:value]})")
end
else
domain.update(status: :dns_incorrect, status_reason: "DNS record (#{ip_addresses.first}) does not match expected IP address (#{expected_ip})")
hostnames = Resolv::DNS.open do |dns|
dns.getresources(domain.domain_name, Resolv::DNS::Resource::IN::CNAME).map do |resource|
resource.name
end
end
if hostnames.any? && hostnames.first.to_s == expected_dns[:value]
domain.update(status: :dns_verified)
else
domain.update(status: :dns_incorrect, status_reason: "DNS record (#{hostnames.first || "empty"}) does not match expected hostname (#{expected_dns[:value]})")
end
end
end
end

View File

@@ -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

View File

@@ -1,90 +1,101 @@
# frozen_string_literal: true
module Projects
class Create
extend LightService::Organizer
def self.create_params(params)
params.require(:project).permit(
:name,
: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::ValidateNamespaceAvailability
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
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Projects
class VisibleToUser
extend LightService::Action
expects :account_user
promises :projects
executed do |context|
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?
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

View File

@@ -6,7 +6,10 @@ class Providers::CreateGithubProvider
promises :provider
executed do |context|
client = Octokit::Client.new(access_token: context.provider.access_token)
client = Git::Github::Client.build_client(
access_token: context.provider.access_token,
api_base_url: context.provider.api_base_url
)
username = client.user[:login]
context.provider.auth = {
info: {
@@ -14,16 +17,23 @@ class Providers::CreateGithubProvider
}
}.to_json
if (client.scopes & EXPECTED_SCOPES).sort != EXPECTED_SCOPES.sort
message = "Invalid scopes. Please check that your personal access token has the following scopes: #{EXPECTED_SCOPES.join(", ")}"
context.fail_and_return!(message)
context.provider.errors.add(:access_token, message)
next
# Skip scope validation for enterprise (some GHE instances don't expose scopes)
unless context.provider.enterprise?
if (client.scopes & EXPECTED_SCOPES).sort != EXPECTED_SCOPES.sort
message = "Invalid scopes. Please check that your personal access token has the following scopes: #{EXPECTED_SCOPES.join(", ")}"
context.fail_and_return!(message)
context.provider.errors.add(:access_token, message)
next
end
end
context.provider.save!
rescue Octokit::Unauthorized
message = "Invalid access token"
context.provider.errors.add(:access_token, message)
context.fail_and_return!(message)
rescue Faraday::ConnectionFailed => e
message = "Could not connect to GitHub server: #{e.message}"
context.provider.errors.add(:registry_url, message)
context.fail_and_return!(message)
end
end

View File

@@ -1,14 +1,16 @@
class Providers::CreateGitlabProvider
EXPECTED_SCOPES = %w[ api read_repository read_registry write_registry ]
GITLAB_PAT_API_URL = "https://gitlab.com/api/v4/personal_access_tokens/self"
GITLAB_USER_API_URL = "https://gitlab.com/api/v4/user"
extend LightService::Action
expects :provider
promises :provider
executed do |context|
response = HTTParty.get(GITLAB_PAT_API_URL,
base_url = context.provider.api_base_url
pat_api_url = "#{base_url}/api/v4/personal_access_tokens/self"
user_api_url = "#{base_url}/api/v4/user"
response = HTTParty.get(pat_api_url,
headers: {
"Authorization" => "Bearer #{context.provider.access_token}"
},
@@ -20,15 +22,18 @@ class Providers::CreateGitlabProvider
next
end
if (response["scopes"] & EXPECTED_SCOPES).sort != EXPECTED_SCOPES.sort
message = "Invalid scopes. Please check that your personal access token has the following scopes: #{EXPECTED_SCOPES.join(", ")}"
context.provider.errors.add(:access_token, message)
context.fail_and_return!(message)
next
# Skip scope validation for enterprise (some instances may have different scope requirements)
unless context.provider.enterprise?
if (response["scopes"] & EXPECTED_SCOPES).sort != EXPECTED_SCOPES.sort
message = "Invalid scopes. Please check that your personal access token has the following scopes: #{EXPECTED_SCOPES.join(", ")}"
context.provider.errors.add(:access_token, message)
context.fail_and_return!(message)
next
end
end
# Get username data
response = HTTParty.get(GITLAB_USER_API_URL,
response = HTTParty.get(user_api_url,
headers: {
"Authorization" => "Bearer #{context.provider.access_token}"
},
@@ -43,5 +48,9 @@ class Providers::CreateGitlabProvider
context.provider.auth = body
context.provider.save!
rescue Errno::ECONNREFUSED, SocketError => e
message = "Could not connect to GitLab server: #{e.message}"
context.provider.errors.add(:registry_url, message)
context.fail_and_return!(message)
end
end

View File

@@ -6,8 +6,9 @@ class Services::Update
executed do |context|
context.service.update(Service.permitted_params(context.params))
if context.service.cron_job?
context.service.cron_schedule.update(context.params[:service][:cron_schedule].permit(:schedule))
if context.service.cron_job? && context.params[:service][:cron_schedule].present?
context.service.cron_schedule.update(
context.params[:service][:cron_schedule].permit(:schedule))
end
context.service.updated!
end

View File

@@ -0,0 +1,11 @@
class SSO::CreateTeamsInAccount
extend LightService::Action
expects :account, :team_names
promises :teams
executed do |context|
context.teams = context.team_names.map do |team_hash|
context.account.teams.find_or_create_by!(name: team_hash[:name])
end
end
end

View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
module SSO
class CreateUserInAccount
extend LightService::Action
expects :email, :account, :sso_provider, :uid
expects :name, default: nil
promises :user
executed do |context|
# First try to find user by SSO provider identity
provider = Provider.find_by(sso_provider: context.sso_provider, uid: context.uid)
if provider
user = provider.user
else
# Fall back to finding by email, or create new user
user = User.find_or_initialize_by(email: context.email.downcase)
if user.new_record?
password = SecureRandom.hex(32)
user.password = password
user.password_confirmation = password
unless user.save
context.fail_and_return!("Failed to create user", errors: user.errors)
end
end
# Create provider record to link user to SSO provider
Provider.create!(
user: user,
sso_provider: context.sso_provider,
uid: context.uid,
provider: context.sso_provider.name
)
end
# Update name from SSO provider on every login
if context.name.present?
name_parts = context.name.split(" ", 2)
user.update(first_name: name_parts.first, last_name: name_parts.second)
end
AccountUser.find_or_create_by!(account: context.account, user: user)
context.user = user
end
end
end

View File

@@ -0,0 +1,25 @@
class SSO::SyncTeams
extend LightService::Action
expects :user, :teams, :account
promises :team_memberships
executed do |context|
user = context.user
account = context.account
remote_teams = context.teams
# Add user to teams from remote source
context.team_memberships = remote_teams.map do |team|
TeamMembership.find_or_create_by!(user:, team:)
end
# Remove user from account teams they're no longer part of on the remote source
remote_team_ids = remote_teams.map(&:id)
stale_memberships = user.team_memberships
.joins(:team)
.where(teams: { account_id: account.id })
.where.not(team_id: remote_team_ids)
stale_memberships.destroy_all
end
end

View File

@@ -0,0 +1,11 @@
class SSO::SyncUserTeams
extend LightService::Organizer
def self.call(email:, team_names:, account:, sso_provider:, uid:, name: nil, create_teams: false)
actions = []
actions << SSO::CreateTeamsInAccount if create_teams
actions << SSO::CreateUserInAccount
actions << SSO::SyncTeams if create_teams
with(email:, team_names:, account:, sso_provider:, uid:, name:).reduce(actions)
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
module SSOProviders
class BuildSSOConfiguration
extend LightService::Action
expects :provider_type, :configuration_params
promises :configuration
executed do |context|
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,24 @@
class Avo::Resources::LDAPConfiguration < Avo::BaseResource
self.includes = []
def fields
field :id, as: :id
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 :allow_anonymous_reads, as: :boolean, help: "Allow anonymous LDAP reads (if disabled, bind credentials are required for group lookups)"
field :bind_dn, as: :text, help: "Bind DN for authentication (required if anonymous reads disabled)"
field :bind_password, as: :password, help: "Bind password (required if anonymous reads disabled)"
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

View File

@@ -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

View File

@@ -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

13
app/avo/resources/team.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,110 @@
module Accounts
class OIDCController < ApplicationController
skip_before_action :authenticate_user!
before_action :load_account
def authorize
oidc_configuration = @account.sso_provider&.configuration
unless oidc_configuration.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
authorization_url = build_authorization_url(oidc_configuration, state)
redirect_to authorization_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_configuration = @account.sso_provider&.configuration
unless oidc_configuration.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_configuration).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
ar_result = SSO::SyncUserTeams.call(
email: result.email,
team_names: result.groups || [],
account: @account,
sso_provider: sso_provider,
uid: result.uid,
name: result.name,
create_teams: sso_provider.just_in_time_team_provisioning_mode?
)
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_configuration, state)
params = {
response_type: "code",
client_id: oidc_configuration.client_id,
redirect_uri: oidc_callback_url(slug: @account.slug),
scope: oidc_configuration.scopes,
state: state
}
auth_endpoint = oidc_configuration.authorization_endpoint.presence || discover_authorization_endpoint(oidc_configuration)
"#{auth_endpoint}?#{params.to_query}"
end
def discover_authorization_endpoint(oidc_configuration)
# Fetch from OIDC discovery document
discovery_url = oidc_configuration.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

View File

@@ -0,0 +1,140 @@
module Accounts
class SSOProvidersController < ApplicationController
def show
@sso_provider = current_account.sso_provider
@configuration = @sso_provider&.configuration
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: 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 = provider_type == "ldap" ? result.configuration : LDAPConfiguration.new
@oidc_configuration = provider_type == "oidc" ? result.configuration : OIDCConfiguration.new
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
@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: configuration_params_for(provider_type)
)
if result.success?
redirect_to sso_provider_path, notice: "SSO provider updated successfully"
else
@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
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
def test_connection
ldap_configuration = LDAPConfiguration.new(ldap_configuration_params)
result = LDAP::Authenticator.new(ldap_configuration).test_connection
if result.success?
render turbo_stream: turbo_stream.replace(
"ldap_test_connection_result",
partial: "accounts/sso_providers/ldap/connection_success"
)
else
render turbo_stream: turbo_stream.replace(
"ldap_test_connection_result",
partial: "accounts/sso_providers/ldap/connection_failed",
locals: { error_message: result.error_message }
)
end
end
private
def sso_provider_params
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,
:port,
:base_dn,
:bind_dn,
:bind_password,
:uid_attribute,
:email_attribute,
:name_attribute,
:filter,
: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

View File

@@ -1,34 +1,11 @@
module Accounts
class StackManagersController < ApplicationController
before_action :authenticate_user!
before_action :authorize_account_admin, only: [ :show, :new, :create, :edit, :update, :destroy, :sync_clusters, :sync_registries ]
before_action :set_stack_manager, only: [ :show, :edit, :update, :destroy, :sync_clusters, :sync_registries ]
before_action :set_stack, only: [ :sync_clusters, :sync_registries ]
skip_before_action :authenticate_user!, only: [ :verify_url, :check_reachable ]
def _verify_stack(stack)
if stack.authenticated?
head :ok
else
head :unauthorized
end
end
def verify_login
stack_manager = current_account.stack_manager
if stack_manager.nil?
head :not_found
end
# If the user is not having an email domain end in the
# portainer stack url, don't log them out, just return a different unauthorized.
if !stack_manager.is_user?(current_user)
head :method_not_allowed
return
end
stack = stack_manager.stack.connect(current_user, allow_anonymous: false)
_verify_stack(stack)
end
def check_reachable
url = params[:stack_manager][:url]
unless Portainer::Client.reachable?(url)
@@ -38,6 +15,30 @@ module Accounts
head :ok
end
def verify_connectivity
stack_manager = current_account.stack_manager
if stack_manager.nil?
head :not_found
return
end
if current_user.portainer_access_token.blank?
head :unauthorized
return
end
stack = stack_manager.stack.connect(current_user, allow_anonymous: false)
if stack.authenticated?
head :ok
else
head :unauthorized
end
rescue Portainer::Client::MissingCredentialError, Portainer::Client::UnauthorizedError
head :unauthorized
rescue Portainer::Client::ConnectionError
head :bad_gateway
end
def verify_url
url = params[:stack_manager][:url]
access_token = params[:stack_manager][:access_token]
@@ -59,7 +60,6 @@ module Accounts
end
def show
@stack_manager = current_account.stack_manager
end
def new
@@ -77,13 +77,10 @@ module Accounts
end
def edit
@stack_manager = current_account.stack_manager
redirect_to new_stack_manager_path unless @stack_manager
end
def update
@stack_manager = current_account.stack_manager
if @stack_manager.update(stack_manager_params)
redirect_to stack_manager_path, notice: "Stack manager was successfully updated."
else
@@ -92,7 +89,6 @@ module Accounts
end
def destroy
@stack_manager = current_account.stack_manager
@stack_manager.destroy!
redirect_to stack_manager_path, notice: "Stack manager was successfully removed."
end
@@ -135,8 +131,24 @@ module Accounts
)
end
def set_stack_manager
@stack_manager = current_account.stack_manager
end
def authorize_account_admin
authorize current_account, :manage_stack_manager?
end
def set_stack
@stack ||= current_account.stack_manager&.stack&.connect(current_user)
@stack ||= @stack_manager&.stack&.connect(current_user)
end
def _verify_stack(stack)
if stack.authenticated?
head :ok
else
head :unauthorized
end
end
end
end

View File

@@ -0,0 +1,35 @@
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,
avatar_url: helpers.avatar_path(user, size: 64)
}
}
end
private
def set_team
@team = current_account.teams.friendly.find(params[:team_id])
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,54 @@
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)
@tab = params[:tab] || "clusters"
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

View File

@@ -4,7 +4,13 @@ class AddOnsController < ApplicationController
# GET /add_ons
def index
@pagy, @add_ons = pagy(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
@@ -39,7 +45,8 @@ class AddOnsController < ApplicationController
# POST /add_ons or /add_ons.json
def create
result = AddOns::Create.execute(add_on: AddOn.new(add_on_params))
add_on_params = AddOns::Create.parse_params(params)
result = AddOns::Create.call(AddOn.new(add_on_params), current_user)
@add_on = result.add_on
# Uncomment to authorize with Pundit
# authorize @add_on
@@ -58,7 +65,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|
@@ -118,30 +125,10 @@ 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(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
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

View File

@@ -13,6 +13,7 @@ class ApplicationController < ActionController::Base
layout :determine_layout
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
rescue_from Portainer::Client::MissingCredentialError, with: :missing_portainer_credential
def authenticate_user!(opts = {})
if request.headers["X-API-Key"].present?
@@ -27,6 +28,8 @@ class ApplicationController < ActionController::Base
api_token = ApiToken.find_by(access_token: token)
if api_token.present?
@current_user = api_token.user
else
render json: { error: "Invalid API token" }, status: :unauthorized
end
end
@@ -39,6 +42,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"
@@ -62,4 +71,8 @@ class ApplicationController < ActionController::Base
flash[:alert] = "The requested resource could not be found."
redirect_to root_path
end
def missing_portainer_credential
redirect_to providers_path, alert: "Please add your Portainer API token to continue."
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -8,7 +8,13 @@ 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::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
@@ -89,8 +95,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
@@ -171,7 +177,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(account_user: current_account_user).clusters
@cluster = clusters.find(params[:id])
# Uncomment to authorize with Pundit
# authorize @cluster

View File

@@ -1,12 +0,0 @@
class DocsController < ApplicationController
skip_before_action :authenticate_user!, only: [ :swagger, :docs ]
layout false
def swagger
render plain: File.read(Rails.root.join('swagger', 'v1', 'swagger.yaml'))
end
def index
end
end

View File

@@ -1,6 +1,10 @@
class Integrations::Github::RepositoriesController < ApplicationController
def index
client = Octokit::Client.new(access_token: current_account.github_provider.access_token)
provider = current_account.github_provider
client = Git::Github::Client.build_client(
access_token: provider.access_token,
api_base_url: provider.api_base_url
)
if params[:q].present?
client.auto_paginate = true
@repositories = client.repos(current_account.github_username)

View File

@@ -5,6 +5,12 @@ class Local::OnboardingController < ApplicationController
def index
end
def account_select
redirect_to new_user_session_path unless Rails.application.config.account_sign_in_only
@accounts = Account.all.includes(:stack_manager)
end
def create
result = Portainer::Onboarding::Create.call(params)

View File

@@ -30,7 +30,7 @@ class Projects::EnvironmentVariablesController < Projects::BaseController
@project.updated!
if @project.current_deployment.present?
Projects::DeploymentJob.perform_later(@project.current_deployment)
Projects::DeploymentJob.perform_later(@project.current_deployment, current_user)
@project.events.create(user: current_user, eventable: @project.last_build, event_action: :update)
redirect_to project_environment_variables_path(@project),
notice: "Restarting services with new environment variables."
@@ -47,7 +47,7 @@ class Projects::EnvironmentVariablesController < Projects::BaseController
@environment_variable = @project.environment_variables.find(params[:id])
@environment_variable.destroy
if @project.current_deployment.present?
Projects::DeploymentJob.perform_later(@project.current_deployment)
Projects::DeploymentJob.perform_later(@project.current_deployment, current_user)
end
render turbo_stream: turbo_stream.remove("environment_variable_#{@environment_variable.id}")
end

View File

@@ -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

View File

@@ -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

View File

@@ -6,7 +6,13 @@ 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::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
@@ -91,7 +97,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(account_user: current_account_user).projects
@project = projects.find(params[:id])
# Uncomment to authorize with Pundit
# authorize @project

View File

@@ -0,0 +1,44 @@
module Providers
class PortainerTokensController < ApplicationController
before_action :authenticate_user!
before_action :require_stack_manager
def update
token = params[:portainer_token]
if token.blank?
redirect_to providers_path, alert: "Please provide a Portainer API token"
return
end
provider = current_user.providers.find_or_initialize_by(provider: Provider::PORTAINER_PROVIDER)
provider.access_token = token
provider.save!
# Clear the cached portainer_access_token on the user
current_user.instance_variable_set(:@portainer_access_token, nil)
redirect_to providers_path, notice: "Portainer API token saved successfully"
end
def destroy
provider = current_user.providers.find_by(provider: Provider::PORTAINER_PROVIDER)
if provider&.destroy
current_user.instance_variable_set(:@portainer_access_token, nil)
redirect_to providers_path, notice: "Portainer API token removed"
else
redirect_to providers_path, alert: "No Portainer API token found"
end
end
private
def require_stack_manager
stack_manager = current_account.stack_manager
unless stack_manager&.portainer? && stack_manager.enable_role_based_access_control?
redirect_to providers_path, alert: "This account does not require individual Portainer credentials"
end
end
end
end

View File

@@ -62,4 +62,11 @@ class StaticController < ApplicationController
def calculator
@prices = JSON.parse(File.read(File.join(Rails.root, 'public', 'resources', 'prices.json')))
end
def docs
end
def swagger
render plain: File.read(Rails.root.join('swagger', 'v1', 'swagger.yaml')), layout: false
end
end

View File

@@ -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
before_action :set_user
attr_reader :provider, :user

View File

@@ -1,10 +1,9 @@
class Users::SessionsController < Devise::SessionsController
layout 'homepage', only: [ :new, :create, :account_login, :account_create, :account_select ]
before_action :require_no_authentication, only: [ :account_login, :account_select ]
layout 'homepage', only: [ :new, :create, :account_login, :account_create ]
before_action :require_no_authentication, only: [ :account_login ]
before_action :load_account_from_slug, only: [ :account_login, :account_create ]
before_action :check_if_default_sign_in_allowed, only: [ :new ]
before_action :check_if_account_select_allowed, only: [ :account_select ]
def new
super
@@ -19,7 +18,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
@@ -33,41 +32,39 @@ class Users::SessionsController < Devise::SessionsController
end
end
def account_select
@accounts = Account.all.includes(:stack_manager)
end
def account_login
self.resource = resource_class.new(sign_in_params)
clean_up_passwords(resource)
if @account.stack_manager&.portainer?
render "devise/sessions/portainer"
@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
end
def account_create
# If account has a stack manager, use Portainer authentication
if @account.stack_manager.present?
result = Portainer::Login.execute(
username: params[:user][:username],
password: params[:user][:password],
account: @account,
)
# 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 result.success?
sign_in(result.user)
# Auto-associate user with account if they sign in through account URL
session[:account_id] = result.account.id
redirect_to after_sign_in_path_for(result.user), notice: "Logged in successfully"
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] = result.message
self.resource = result.user || resource_class.new(sign_in_params)
clean_up_passwords(resource)
render 'devise/sessions/portainer'
flash[:alert] = "Invalid email or password"
self.resource = resource_class.new(sign_in_params)
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
@@ -77,13 +74,7 @@ class Users::SessionsController < Devise::SessionsController
def check_if_default_sign_in_allowed
if Rails.application.config.account_sign_in_only
redirect_to accounts_select_url
end
end
def check_if_account_select_allowed
unless Rails.application.config.account_sign_in_only
redirect_to new_user_session_path
redirect_to account_select_local_onboarding_index_path
end
end

View File

@@ -1,4 +1,5 @@
import { Controller } from "@hotwired/stimulus"
import { computePosition, autoUpdate, flip, shift, offset, size } from "@floating-ui/dom"
import { debounce } from "../../utils"
/**
@@ -26,9 +27,9 @@ export default class extends Controller {
// Disable browser autocomplete
this.input.setAttribute('autocomplete', 'off')
// Create dropdown
// Create dropdown and append to the appropriate container
this.dropdown = this.createDropdown()
this.element.appendChild(this.dropdown)
this.getDropdownContainer().appendChild(this.dropdown)
// Bind search handler with debounce
this.searchHandler = debounce(this.performSearch.bind(this), this.getDebounceDelay())
@@ -37,6 +38,8 @@ export default class extends Controller {
// Handle click outside to close dropdown
this.clickOutsideHandler = this.handleClickOutside.bind(this)
document.addEventListener('click', this.clickOutsideHandler)
this.cleanupAutoUpdate = null
}
disconnect() {
@@ -44,14 +47,52 @@ export default class extends Controller {
this.input.removeEventListener('input', this.searchHandler)
}
document.removeEventListener('click', this.clickOutsideHandler)
if (this.cleanupAutoUpdate) {
this.cleanupAutoUpdate()
}
if (this.dropdown && this.dropdown.parentNode) {
this.dropdown.parentNode.removeChild(this.dropdown)
}
}
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 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'
return dropdown
}
getDropdownContainer() {
// Check if we're inside a modal and append there to maintain stacking context
const modal = this.element.closest('.modal, [role="dialog"], dialog')
return modal || document.body
}
updatePosition() {
computePosition(this.input, this.dropdown, {
placement: 'bottom-start',
middleware: [
offset(4),
flip({ fallbackPlacements: ['top-start'] }),
shift({ padding: 8 }),
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`
})
}
})
]
}).then(({ x, y }) => {
Object.assign(this.dropdown.style, {
left: `${x}px`,
top: `${y}px`
})
})
}
getInputElement() {
return this.element.querySelector('input')
}
@@ -89,7 +130,7 @@ export default class extends Controller {
}
this.dropdown.innerHTML = results.map((item, index) => `
<li class="cursor-pointer hover:bg-base-300 p-2" data-index="${index}">
<li class="cursor-pointer p-2 hover:bg-base-300" data-index="${index}">
${this.renderItem(item)}
</li>
`).join('')
@@ -121,11 +162,26 @@ export default class extends Controller {
showDropdown() {
this.dropdown.classList.remove('hidden')
this.updatePosition()
// Set up auto-update to keep position in sync during scroll/resize
if (this.cleanupAutoUpdate) {
this.cleanupAutoUpdate()
}
this.cleanupAutoUpdate = autoUpdate(this.input, this.dropdown, () => {
this.updatePosition()
})
}
hideDropdown() {
this.dropdown.classList.add('hidden')
this.dropdown.innerHTML = ''
// Clean up auto-update listener
if (this.cleanupAutoUpdate) {
this.cleanupAutoUpdate()
this.cleanupAutoUpdate = null
}
}
showLoading() {
@@ -157,7 +213,7 @@ export default class extends Controller {
}
handleClickOutside(event) {
if (!this.element.contains(event.target)) {
if (!this.element.contains(event.target) && !this.dropdown.contains(event.target)) {
this.hideDropdown()
}
}

View File

@@ -0,0 +1,32 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container"]
connect() {
// Initial setup: hidden + transparent + transition classes
this.containerTarget.classList.add(
"hidden", // prevent layout
"opacity-0", // starting transparency
"transition-all",
"duration-500" // control speed
)
}
show(e) {
e.preventDefault()
// Hide the button
e.target.classList.add("hidden")
// Step 1: unhide but keep opacity-0
this.containerTarget.classList.remove("hidden")
// Step 2: allow browser to register this initial state
requestAnimationFrame(() => {
// Step 3: now fade to opacity-100 → animation occurs
this.containerTarget.classList.add("opacity-100")
this.containerTarget.classList.remove("opacity-0")
})
}
}

View File

@@ -0,0 +1,43 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["button", "result"]
async test(event) {
event.preventDefault()
const form = this.element.closest("form")
const formData = new FormData(form)
// Clear previous result
this.resultTarget.innerHTML = ""
// Update button to show loading state
const button = this.buttonTarget
const originalContent = button.innerHTML
button.disabled = true
button.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Testing...'
try {
const response = await fetch("/accounts/sso_provider/test_connection", {
method: "POST",
headers: {
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
"Accept": "text/vnd.turbo-stream.html"
},
body: formData
})
if (response.ok) {
const html = await response.text()
Turbo.renderStreamMessage(html)
}
} catch (error) {
console.error("LDAP test connection failed:", error)
} finally {
// Always re-enable the button so it can be clicked again
button.disabled = false
button.innerHTML = originalContent
}
}
}

View File

@@ -0,0 +1,11 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
console.log("NamespaceInputGroupController connected")
}
toggleManagedNamespace() {
}
}

View File

@@ -9,12 +9,14 @@ export default class extends Controller {
toggle() {
const selectedRadio = this.radioTargets.find(radio => radio.checked)
console.log(selectedRadio)
if (selectedRadio) {
const selectedValue = selectedRadio.value
this.partialTargets.forEach(partial => {
if (partial.dataset.value === selectedValue) {
console.log(partial)
partial.classList.remove('hidden')
} else {
partial.classList.add('hidden')

View File

@@ -1,59 +1,39 @@
import { Controller } from "@hotwired/stimulus"
import { PortainerChecker } from "../../utils/portainer"
const AUTHENTICATION_VERIFICATION_METHOD = "authentication";
const URL_VERIFICATION_METHOD = "url";
export default class extends Controller {
static targets = [ "message", "verifyUrlSuccess", "verifyUrlError", "verifyUrlLoading", "verifyUrlNotAllowed" ]
static targets = [ "verifyUrlSuccess", "verifyUrlError", "verifyUrlLoading", "verifyUrlUnauthorized" ]
static values = {
verificationMethod: String,
verifyUrl: String,
credentialsPath: { type: String, default: "/providers" },
rbacEnabled: { type: Boolean, default: false }
}
async connect() {
this.verifyUrlLoadingTarget.classList.remove('hidden')
const portainerChecker = new PortainerChecker();
let result = null;
if (this.verificationMethodValue === AUTHENTICATION_VERIFICATION_METHOD) {
result = await portainerChecker.verifyPortainerAuthentication();
} else if (this.verificationMethodValue === URL_VERIFICATION_METHOD) {
const url = this.verifyUrlValue;
result = await portainerChecker.checkReachable(url);
}
if (result === PortainerChecker.STATUS_UNAUTHORIZED) {
this.logout();
} else if (result === PortainerChecker.STATUS_OK) {
// Only verify user connectivity if RBAC is enabled, otherwise just check URL reachability
const result = this.rbacEnabledValue
? await portainerChecker.verifyConnectivity()
: await portainerChecker.checkReachable(this.verifyUrlValue);
if (result === PortainerChecker.STATUS_OK) {
this.verifyUrlSuccessTarget.classList.remove('hidden')
} else if (result === PortainerChecker.STATUS_NOT_ALLOWED) {
this.verifyUrlNotAllowedTarget.classList.remove('hidden')
} else if (result === PortainerChecker.STATUS_UNAUTHORIZED && this.rbacEnabledValue) {
if (this.hasVerifyUrlUnauthorizedTarget) {
this.verifyUrlUnauthorizedTarget.classList.remove('hidden')
} else {
this.verifyUrlErrorTarget.classList.remove('hidden')
}
} else {
this.verifyUrlErrorTarget.classList.remove('hidden')
}
this.verifyUrlLoadingTarget.classList.add('hidden')
}
async logout() {
try {
const response = await fetch('/users/sign_out', {
method: 'DELETE',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
credentials: 'same-origin'
})
if (response.ok) {
const data = await response.json()
window.location.href = data.redirect_url
} else {
window.location.href = '/users/sign_in'
}
} catch (error) {
window.location.href = '/users/sign_in'
}
navigateToCredentials() {
window.location.href = this.credentialsPathValue
}
}

View File

@@ -0,0 +1,86 @@
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) {
const displayName = user.name || user.first_name || user.email.split('@')[0]
const showEmail = user.name || user.first_name
return `
<div class="flex items-center gap-3 px-2 py-2">
<div class="avatar">
<div class="w-8 h-8 rounded">
<img src="${this.escapeHtml(user.avatar_url)}" alt="${this.escapeHtml(displayName)}" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">${this.escapeHtml(displayName)}</div>
${showEmail ? `<div class="text-sm text-base-content/60 truncate">${this.escapeHtml(user.email)}</div>` : ''}
</div>
</div>
`
}
async onItemSelect(user, itemElement) {
try {
// Add loading state
itemElement.classList.add('opacity-50')
itemElement.innerHTML = `
<div class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span>Adding ${this.escapeHtml(user.name || user.email)}...</span>
</div>
`
// 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 || ''
}
}

View File

@@ -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 `
<div class="flex items-center gap-3 px-2 py-2">
<div class="flex-1 min-w-0">
<div class="font-medium truncate">${this.escapeHtml(resource.name)}</div>
</div>
</div>
`
}
async onItemSelect(resource, itemElement) {
try {
itemElement.classList.add('opacity-50')
itemElement.innerHTML = `
<div class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span>Adding ${this.escapeHtml(resource.name)}...</span>
</div>
`
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 || ''
}
}

View File

@@ -23,7 +23,7 @@ export async function getDefaultValues(
export function helmChartHeader(packageData) {
const logoImageUrl = getLogoImageUrl(packageData);
return `
<div class="flex items-center gap-4">
<div class="flex items-center gap-4 max-w-lg">
<img src="${logoImageUrl}" alt="${packageData.name}" class="h-16 w-16">
<div class="flex-1">
<div class="flex items-center justify-between mb-1">
@@ -41,7 +41,7 @@ export function helmChartHeader(packageData) {
${packageData.stars}
</span>` : ''}
</div>
<p class="text-sm text-base-content/70 mb-2">${packageData.description}</p>
<p class="text-sm text-base-content/70 mb-2 line-clamp-2">${packageData.description}</p>
<div class="flex gap-4 text-xs text-base-content/70">
<div class="flex items-center gap-1">
<iconify-icon icon="lucide:globe"></iconify-icon>

View File

@@ -1,34 +1,17 @@
export class PortainerChecker {
static STATUS_OK = "ok";
static STATUS_UNAUTHORIZED = "unauthorized";
static STATUS_NOT_ALLOWED = "not_allowed";
static STATUS_ERROR = "error";
csrfToken() {
return document.querySelector('meta[name="csrf-token"]').content
}
async verifyPortainerAuthentication() {
const response = await fetch('/stack_manager/verify_login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken()
}
})
return this.toResult(response);
}
toResult(response) {
if (response.status === 401) {
return PortainerChecker.STATUS_UNAUTHORIZED;
}
if (response.status === 405) {
return PortainerChecker.STATUS_NOT_ALLOWED;
}
if (response.status === 502) {
return PortainerChecker.STATUS_ERROR;
}
@@ -52,6 +35,17 @@ export class PortainerChecker {
return this.toResult(response);
}
async verifyConnectivity() {
const response = await fetch('/stack_manager/verify_connectivity', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken()
}
})
return this.toResult(response);
}
async verifyPortainerUrl(url, accessToken) {
const response = await fetch('/stack_manager/verify_url', {
method: 'POST',
@@ -64,4 +58,4 @@ export class PortainerChecker {
return this.toResult(response);
}
}
}

View File

@@ -11,12 +11,14 @@ class Projects::DeploymentJob < ApplicationJob
project = deployment.project
connection = K8::Connection.new(project, user, allow_anonymous: true)
kubectl = create_kubectl(deployment, connection)
kubectl.register_after_apply do |yaml_content|
kubectl.register_before_apply do |yaml_content|
deployment.add_manifest(yaml_content)
end
# Create namespace
apply_namespace(project, kubectl)
if project.managed_namespace?
apply_namespace(project, kubectl)
end
# Upload container registry secrets
upload_registry_secrets(kubectl, deployment)
@@ -108,11 +110,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 +126,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 +134,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 +152,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)

View File

@@ -1,7 +1,9 @@
class Projects::DestroyJob < ApplicationJob
def perform(project, user)
project.destroying!
delete_namespace(project, user)
if project.managed_namespace
delete_namespace(project, user)
end
# Delete the github webhook for the project IF there are no more projects that refer to that repository
# TODO: This might have overlapping repository urls across different providers.
@@ -15,7 +17,7 @@ class Projects::DestroyJob < ApplicationJob
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?
if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.namespace }).present?
client.delete_namespace(namespace.metadata.name)
end
end

View File

@@ -3,15 +3,20 @@ 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)
Rails.logger.info("Checking health for #{service.name} at #{url}")
response = HTTParty.get(url)
if response.success?
service.status = :healthy
else
begin
response = HTTParty.get(url, timeout: 10)
if response.success?
service.status = :healthy
else
service.status = :unhealthy
end
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
service.last_health_checked_at = DateTime.current

View File

@@ -26,6 +26,8 @@ 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 :teams, dependent: :destroy
has_many :clusters, dependent: :destroy
has_many :build_clouds, through: :clusters
@@ -47,4 +49,12 @@ class Account < ApplicationRecord
def github_provider
@_github_account ||= owner.providers.find_by(provider: "github")
end
def sso_enabled?
sso_provider&.enabled?
end
def custom_login?
sso_enabled?
end
end

View File

@@ -21,4 +21,8 @@
class AccountUser < ApplicationRecord
belongs_to :user
belongs_to :account
def admin?
account.owner_id == user_id
end
end

View File

@@ -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,8 @@
#
class AddOn < ApplicationRecord
include Loggable
include TeamAccessible
include Namespaced
belongs_to :cluster
has_one :account, through: :cluster
@@ -39,9 +43,9 @@ 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?
validates_uniqueness_of :name, scope: :cluster_id
after_update_commit do
broadcast_replace_later_to [ self, :install_stage ], target: dom_id(self, :install_stage), partial: "add_ons/install_stage", locals: { add_on: self }
@@ -56,12 +60,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")

View File

@@ -22,6 +22,7 @@
#
class Cluster < ApplicationRecord
include Loggable
include TeamAccessible
broadcasts_refreshes
belongs_to :account
@@ -58,7 +59,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 = {})

View File

@@ -0,0 +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

View File

@@ -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

View File

@@ -0,0 +1,44 @@
# == Schema Information
#
# Table name: ldap_configurations
#
# id :bigint not null, primary key
# allow_anonymous_reads :boolean default(FALSE)
# 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
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_presence_of :bind_dn, if: :allow_anonymous_reads?
validates_presence_of :bind_password, if: :allow_anonymous_reads?
# Returns encryption method as symbol for Net::LDAP
# Encryption options: plain (no encryption), simple_tls (LDAPS), start_tls (STARTTLS)
def encryption_method
return nil if plain?
encryption.to_sym
end
def requires_auth?
bind_dn.present? && bind_password.present?
end
end

View File

@@ -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
URI.join(issuer, ".well-known/openid-configuration").to_s
end
def uses_discovery?
authorization_endpoint.blank? && token_endpoint.blank?
end
end

View File

@@ -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,8 @@
# fk_rails_... (project_fork_cluster_id => clusters.id)
#
class Project < ApplicationRecord
include TeamAccessible
include Namespaced
broadcasts_refreshes
belongs_to :cluster
has_one :account, through: :cluster
@@ -54,6 +58,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: {
@@ -64,8 +70,8 @@ class Project < ApplicationRecord
validates_presence_of :project_fork_cluster_id, unless: :forks_disabled?
validate :project_fork_cluster_id_is_owned_by_account
validates_presence_of :build_configuration, if: :git?
validates_uniqueness_of :name, scope: :cluster_id
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
@@ -92,12 +98,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

View File

@@ -15,21 +15,27 @@
# created_at :datetime not null
# updated_at :datetime not null
# external_id :string
# sso_provider_id :bigint
# user_id :bigint not null
#
# Indexes
#
# index_providers_on_user_id (user_id)
# index_providers_on_sso_provider_id (sso_provider_id)
# index_providers_on_sso_provider_id_and_uid (sso_provider_id,uid) UNIQUE WHERE (sso_provider_id IS NOT NULL)
# index_providers_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (sso_provider_id => sso_providers.id)
# fk_rails_... (user_id => users.id)
#
class Provider < ApplicationRecord
attr_accessor :username_param
GITHUB_PROVIDER = "github"
GITHUB_API_BASE = "https://api.github.com"
CUSTOM_REGISTRY_PROVIDER = "container_registry"
GITLAB_PROVIDER = "gitlab"
GITLAB_API_BASE = "https://gitlab.com"
GIT_TYPE = "git"
REGISTRY_TYPE = "registry"
PROVIDER_TYPES = {
@@ -42,8 +48,10 @@ class Provider < ApplicationRecord
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ].freeze
validates :registry_url, presence: true, if: :container_registry?
scope :has_container_registry, -> { where(provider: [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ]) }
scope :non_sso, -> { where(sso_provider_id: nil) }
belongs_to :user
belongs_to :sso_provider, class_name: "SSOProvider", optional: true
Devise.omniauth_configs.keys.each do |provider|
scope provider, -> { where(provider: provider) }
@@ -83,6 +91,20 @@ class Provider < ApplicationRecord
provider == GITLAB_PROVIDER
end
def enterprise?
(github? || gitlab?) && registry_url.present?
end
def api_base_url
if registry_url.present?
registry_url.chomp("/")
elsif github?
GITHUB_API_BASE
elsif gitlab?
GITLAB_API_BASE
end
end
def twitter_refresh_token!(token); end
def used!

View File

@@ -0,0 +1,48 @@
# == 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
# team_provisioning_mode :integer 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, dependent: :destroy
has_many :providers, dependent: :nullify
validates :account_id, uniqueness: true
enum :team_provisioning_mode, {
disabled: 0,
just_in_time: 1
}, suffix: true
def ldap?
configuration_type == "LDAPConfiguration"
end
def oidc?
configuration_type == "OIDCConfiguration"
end
def sso_users_count
providers.joins(:user).distinct.count(:user_id)
end
end

32
app/models/team.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
@@ -50,9 +52,15 @@ class User < ApplicationRecord
providers.find_by(provider: "github")
end
def portainer_jwt
return @portainer_jwt if @portainer_jwt
@portainer_jwt = providers.find_by(provider: "portainer")&.access_token
def portainer_access_token
return @portainer_access_token if @portainer_access_token
@portainer_access_token = providers.find_by(provider: "portainer")&.access_token
end
def needs_portainer_credential?(account)
account.stack_manager&.portainer? &&
account.stack_manager.enable_role_based_access_control? &&
portainer_access_token.blank?
end
private

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
class AccountPolicy < ApplicationPolicy
def admin?
account_admin?
end
def manage_stack_manager?
account_admin?
end
private
def account_admin?
return false unless record
account_user = AccountUser.find_by(user: user, account: record)
account_user&.admin?
end
end

View File

@@ -1,9 +1,9 @@
class Git::Client
def self.from_provider(provider:, repository_url:)
if provider.github?
Git::Github::Client.new(access_token: provider.access_token, repository_url:)
Git::Github::Client.new(access_token: provider.access_token, repository_url:, api_base_url: provider.api_base_url)
elsif provider.gitlab?
Git::Gitlab::Client.new(access_token: provider.access_token, repository_url:)
Git::Gitlab::Client.new(access_token: provider.access_token, repository_url:, api_base_url: provider.api_base_url)
else
raise "Unsupported Git provider: #{provider}"
end

Some files were not shown because too many files have changed in this diff Show More