diff --git a/app/actions/clusters/create_namespace.rb b/app/actions/clusters/create_namespace.rb index 53a27165..2e965d5d 100644 --- a/app/actions/clusters/create_namespace.rb +++ b/app/actions/clusters/create_namespace.rb @@ -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 diff --git a/app/controllers/accounts/stack_managers_controller.rb b/app/controllers/accounts/stack_managers_controller.rb index be7e404a..1e1faa62 100644 --- a/app/controllers/accounts/stack_managers_controller.rb +++ b/app/controllers/accounts/stack_managers_controller.rb @@ -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_jwt.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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2fdbe252..46053350 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 protected def current_account @@ -52,4 +53,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 diff --git a/app/controllers/providers/portainer_tokens_controller.rb b/app/controllers/providers/portainer_tokens_controller.rb new file mode 100644 index 00000000..150e6cb0 --- /dev/null +++ b/app/controllers/providers/portainer_tokens_controller.rb @@ -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_jwt on the user + current_user.instance_variable_set(:@portainer_jwt, 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_jwt, 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 diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 604242f4..d8a353b7 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -41,9 +41,7 @@ class Users::SessionsController < Devise::SessionsController self.resource = resource_class.new(sign_in_params) clean_up_passwords(resource) @sso_provider = @account.sso_provider if @account.sso_enabled? - if @account.stack_manager&.portainer? - render "devise/sessions/portainer" - elsif @account.sso_provider&.ldap? + if @account.sso_provider&.ldap? render "devise/sessions/ldap" else render :new @@ -66,26 +64,6 @@ class Users::SessionsController < Devise::SessionsController clean_up_passwords(self.resource) render "devise/sessions/ldap" end - # If account has a stack manager, use Portainer authentication - elsif @account.stack_manager.present? - result = Portainer::Login.execute( - username: params[:user][:username], - password: params[:user][:password], - account: @account, - ) - - 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" - else - flash[:alert] = result.message - self.resource = result.user || resource_class.new(sign_in_params) - clean_up_passwords(resource) - render 'devise/sessions/portainer' - end else redirect_to new_user_session_path end diff --git a/app/javascript/controllers/stack_manager/badge_controller.js b/app/javascript/controllers/stack_manager/badge_controller.js index b230ab4d..e7b64880 100644 --- a/app/javascript/controllers/stack_manager/badge_controller.js +++ b/app/javascript/controllers/stack_manager/badge_controller.js @@ -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 } } diff --git a/app/javascript/utils/portainer.js b/app/javascript/utils/portainer.js index ace2f159..7bc67665 100644 --- a/app/javascript/utils/portainer.js +++ b/app/javascript/utils/portainer.js @@ -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); } -} \ No newline at end of file +} diff --git a/app/models/account.rb b/app/models/account.rb index 9d952250..a9709551 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -55,6 +55,6 @@ class Account < ApplicationRecord end def custom_login? - stack_manager&.stack&.provides_authentication? || sso_enabled? + sso_enabled? end end diff --git a/app/models/user.rb b/app/models/user.rb index d896b989..8cf85c9a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,6 +56,12 @@ class User < ApplicationRecord @portainer_jwt = 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_jwt.blank? + end + private def downcase_email diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb new file mode 100644 index 00000000..508b468c --- /dev/null +++ b/app/policies/account_policy.rb @@ -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 diff --git a/app/views/accounts/sso_providers/show.html.erb b/app/views/accounts/sso_providers/show.html.erb index 7b6db54c..63af6a0d 100644 --- a/app/views/accounts/sso_providers/show.html.erb +++ b/app/views/accounts/sso_providers/show.html.erb @@ -26,21 +26,21 @@ <% if @configuration %>
-- Sign in to access your account -
- - <%= form_with( - model: resource, - as: resource_name, - url: account_sign_in_path(@account.slug), - method: :post, - data: { turbo: false }, - html: { class: "space-y-4" }) do |f| - %> - <% if flash[:alert] %> -- Contact your account administrator to get access -
- -+ This account is configured to use Portainer for cluster management. +
+ + <% if current_user.portainer_jwt.present? %> +