mirror of
https://github.com/czhu12/canine.git
synced 2025-12-21 10:49:49 -06:00
remove portainer stack manager based authentication
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
44
app/controllers/providers/portainer_tokens_controller.rb
Normal file
44
app/controllers/providers/portainer_tokens_controller.rb
Normal 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_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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -55,6 +55,6 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def custom_login?
|
||||
stack_manager&.stack&.provides_authentication? || sso_enabled?
|
||||
sso_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
20
app/policies/account_policy.rb
Normal file
20
app/policies/account_policy.rb
Normal 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
|
||||
@@ -26,21 +26,21 @@
|
||||
|
||||
<% if @configuration %>
|
||||
<div class="divider"></div>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-gray-500">Host:</span>
|
||||
<div class="text-sm font-medium text-base-content/70 mb-1">Host</div>
|
||||
<div class="font-mono"><%= @configuration.host %>:<%= @configuration.port %></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Base DN:</span>
|
||||
<div class="text-sm font-medium text-base-content/70 mb-1">Base DN</div>
|
||||
<div class="font-mono"><%= @configuration.base_dn %></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">UID Attribute:</span>
|
||||
<div class="text-sm font-medium text-base-content/70 mb-1">UID Attribute</div>
|
||||
<div class="font-mono"><%= @configuration.uid_attribute %></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Encryption:</span>
|
||||
<div class="text-sm font-medium text-base-content/70 mb-1">Encryption</div>
|
||||
<div class="font-mono"><%= @configuration.encryption.titleize %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<% verification_method ||= "url" %>
|
||||
<div
|
||||
class="border-t border-base-300/50 transition-all duration-300 group-hover:border-base-300"
|
||||
data-controller="stack-manager--badge"
|
||||
data-stack-manager--badge-verification-method-value="<%= verification_method %>"
|
||||
data-stack-manager--badge-verify-url-value="<%= stack_manager.provider_url %>"
|
||||
data-stack-manager--badge-credentials-path-value="<%= providers_path %>"
|
||||
data-stack-manager--badge-rbac-enabled-value="<%= stack_manager.enable_role_based_access_control? %>"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<%= 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"
|
||||
>
|
||||
<iconify-icon icon="lucide:external-link" width="12" height="12" class="opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
|
||||
<iconify-icon icon="lucide:check-circle" width="12" height="12" class="text-green-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
|
||||
</div>
|
||||
<div
|
||||
class="tooltip tooltip-bottom flex hidden"
|
||||
@@ -24,19 +24,19 @@
|
||||
>
|
||||
<iconify-icon icon="lucide:alert-circle" width="12" height="12" class="text-red-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
|
||||
</div>
|
||||
<div
|
||||
class="tooltip tooltip-bottom flex hidden"
|
||||
data-tip="Your current login does not have access to this stack manager. Please logout and login with your portainer username and password."
|
||||
data-stack-manager--badge-target="verifyUrlNotAllowed"
|
||||
>
|
||||
<iconify-icon icon="lucide:ban" width="12" height="12" class="text-red-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
|
||||
</div>
|
||||
<iconify-icon
|
||||
icon="lucide:loader-2"
|
||||
width="12"
|
||||
height="12"
|
||||
class="opacity-80 animate-spin"
|
||||
data-stack-manager--badge-target="verifyUrlLoading"></iconify-icon>
|
||||
<div
|
||||
class="tooltip tooltip-bottom flex hidden"
|
||||
data-tip="Access token is invalid. Please add or update your Portainer access token to continue"
|
||||
data-stack-manager--badge-target="verifyUrlUnauthorized"
|
||||
>
|
||||
<iconify-icon icon="lucide:alert-triangle" width="12" height="12" class="text-red-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<div class="flex justify-center items-center min-h-screen bg-base-200">
|
||||
<div class="card w-96 bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-center mb-0">
|
||||
Log in to <%= @account.name %>
|
||||
</h1>
|
||||
<div class="flex justify-center mt-3">
|
||||
<%= render "devise/sessions/portainer_badge", stack_manager: @account.stack_manager %>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-sm text-gray-500 mt-2">
|
||||
Sign in to access your account
|
||||
</p>
|
||||
|
||||
<%= 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] %>
|
||||
<div class="alert alert-error mb-4">
|
||||
<iconify-icon icon="lucide:alert-triangle" class="mr-2 text-white"></iconify-icon>
|
||||
<span><%= flash[:alert] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render 'shared/error_messages', resource: resource %>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :username, class: "label" do %>
|
||||
<span class="label-text">Portainer Username</span>
|
||||
<% end %>
|
||||
<%= f.text_field(
|
||||
:username,
|
||||
autofocus: true,
|
||||
placeholder: "Enter your username",
|
||||
class: "input input-bordered w-full",
|
||||
required: true,
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :password, class: "label" do %>
|
||||
<span class="label-text">Portainer Password</span>
|
||||
<% end %>
|
||||
<%= f.password_field(
|
||||
:password,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Enter your password",
|
||||
class: "input input-bordered w-full",
|
||||
required: true,
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-6">
|
||||
<%= f.submit "Sign in to #{@account.name}", class: "btn btn-primary w-full" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="divider">New to <%= @account.name %>?</div>
|
||||
|
||||
<p class="text-center text-sm">
|
||||
Contact your account administrator to get access
|
||||
</p>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<%= link_to "Back to main login", new_user_session_path, class: "link link-primary text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,9 +8,7 @@
|
||||
<% if current_account.stack_manager.present? %>
|
||||
<div class="flex items-center justify-center">
|
||||
<%= render "devise/sessions/portainer_badge",
|
||||
stack_manager: current_account.stack_manager,
|
||||
verification_method: "authentication",
|
||||
logout_on_unauthorized: true %>
|
||||
stack_manager: current_account.stack_manager %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
48
app/views/providers/_portainer_token.html.erb
Normal file
48
app/views/providers/_portainer_token.html.erb
Normal file
@@ -0,0 +1,48 @@
|
||||
<%= turbo_frame_tag "portainer_token" do %>
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold mb-2">Portainer Access Token</h3>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
This account is configured to use Portainer for cluster management.
|
||||
</p>
|
||||
|
||||
<% if current_user.portainer_jwt.present? %>
|
||||
<div class="alert alert-success mb-4">
|
||||
<iconify-icon icon="lucide:check-circle" height="20"></iconify-icon>
|
||||
<span>Access token configured.</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="alert alert-warning mb-4">
|
||||
<iconify-icon icon="lucide:alert-triangle" height="20"></iconify-icon>
|
||||
<span>No Access token configured. You won't be able to access cluster features until you add one.</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: portainer_token_path, method: :patch, class: "space-y-4", data: { turbo_frame: "portainer_token" } do |f| %>
|
||||
<div class="form-control w-full max-w-md">
|
||||
<label class="label">
|
||||
<span class="label-text">Token</span>
|
||||
</label>
|
||||
<%= f.text_field :portainer_token,
|
||||
value: "",
|
||||
placeholder: current_user.portainer_jwt.present? ? "••••••••••••••••" : "Enter your Portainer Access token",
|
||||
class: "input input-bordered w-full" %>
|
||||
<label class="label flex justify-between" for="portainer_token">
|
||||
<span class="label-text-alt">
|
||||
<%= link_to "Get your access token in Portainer →", "#{current_account.stack_manager.provider_url}/#!/account", target: "_blank", class: "link link-primary" %>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,6 +1,7 @@
|
||||
<%= settings_layout do %>
|
||||
<h2 class="text-2xl font-bold">Credentials</h2>
|
||||
<hr class="mt-3 mb-4 border-t border-base-300" />
|
||||
|
||||
<%= turbo_frame_tag "provider" do %>
|
||||
<%= render "providers/index" %>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -16,4 +17,9 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if current_account.stack_manager&.portainer? && current_account.stack_manager.enable_role_based_access_control? %>
|
||||
<hr class="my-6 border-t border-base-300" />
|
||||
<%= render "providers/portainer_token" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="flex flex-row gap-x-4 items-stretch">
|
||||
<div class="w-64 space-y-4">
|
||||
<% if (current_account.stack_manager.present? && current_account.stack_manager.stack.provides_authentication?) || current_account.sso_provider.present? %>
|
||||
<% if current_account.sso_provider.present? %>
|
||||
<div class="rounded bg-base-200 pl-4 pr-2 py-4">
|
||||
<h3 class="text-sm font-semibold mb-3">Account Login URL</h3>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user