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 %>
-
+
- Host: +
Host
<%= @configuration.host %>:<%= @configuration.port %>
- Base DN: +
Base DN
<%= @configuration.base_dn %>
- UID Attribute: +
UID Attribute
<%= @configuration.uid_attribute %>
- Encryption: +
Encryption
<%= @configuration.encryption.titleize %>
diff --git a/app/views/devise/sessions/_portainer_badge.html.erb b/app/views/devise/sessions/_portainer_badge.html.erb index 5cd92b6c..c77db734 100644 --- a/app/views/devise/sessions/_portainer_badge.html.erb +++ b/app/views/devise/sessions/_portainer_badge.html.erb @@ -1,9 +1,9 @@ -<% verification_method ||= "url" %>
<%= link_to stack_manager.provider_url, target: "_blank", rel: "noopener", class: "group/badge inline-flex" do %> @@ -15,7 +15,7 @@ data-tip="Portainer is reachable" data-stack-manager--badge-target="verifyUrlSuccess" > - +
- +
<% end %>
- \ No newline at end of file + diff --git a/app/views/devise/sessions/portainer.html.erb b/app/views/devise/sessions/portainer.html.erb deleted file mode 100644 index c3fe3699..00000000 --- a/app/views/devise/sessions/portainer.html.erb +++ /dev/null @@ -1,75 +0,0 @@ -
-
-
-
-

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

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

- Sign in to access your account -

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

- Contact your account administrator to get access -

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

Portainer Access Token

+

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

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

Credentials


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

Account Login URL

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