Skip to content

Commit

Permalink
feat: add credentials UI
Browse files Browse the repository at this point in the history
Data model is:
- `CredentialSet` <--many-to-one --> `Credential`
  • Loading branch information
mromulus committed Nov 25, 2024
1 parent f331028 commit ba4516d
Show file tree
Hide file tree
Showing 33 changed files with 636 additions and 2 deletions.
54 changes: 54 additions & 0 deletions app/controllers/credential_sets_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

class CredentialSetsController < ApplicationController
before_action :get_exercise
before_action :get_credential_sets, only: %i[show update destroy]

def index
@credential_sets = authorized_scope(@exercise.credential_sets).order(:name)
end

def new
@credential_set = @exercise.credential_sets.build
authorize! @credential_set
end

def create
@credential_set = @exercise.credential_sets.build(credential_set_params)
authorize! @credential_set

if @credential_set.save
redirect_to [@credential_set.exercise, @credential_set], notice: 'Credential was successfully created.'
else
render :new, status: 400
end
end

def show; end

def update
if @credential_set.update credential_set_params
redirect_to [@credential_set.exercise, @credential_set], notice: 'Credential was successfully updated.'
else
render :show, status: 400
end
end

def destroy
if @credential_set.destroy
redirect_to [@exercise, :credential_sets], notice: 'Credential was successfully destroyed.'
else
redirect_to [@exercise, :credential_set], flash: { error: @credential_set.errors.full_messages.join(', ') }
end
end

private
def credential_set_params
params.require(:credential_set).permit(:name, :description, :network_id)
end

def get_credential_sets
@credential_set = @exercise.credential_sets.friendly.find(params[:id])
authorize! @credential_set
end
end
87 changes: 87 additions & 0 deletions app/controllers/credentials_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

class CredentialsController < ApplicationController
before_action :get_exercise, :get_credential_set
before_action :get_credential, only: %i[show update destroy]

respond_to :turbo_stream

def new
@credential = @credential_set.credentials.build
authorize! @credential
end

def create
@credential = @credential_set.credentials.build(credential_params)
@credential.password = Credential.generate_password
authorize! @credential
@credential.save
end

def update
if params[:cm]
config_map_update
elsif randomize_param.present?
@credential.update(password: Credential.generate_password)
else
@credential.update(credential_params)
end
end

def destroy
@credential.destroy
end

def import
parsed_creds = Psych.safe_load(params[:import_yaml], symbolize_names: true)
Credential.transaction do
parsed_creds[:credentials].each do |cred|
next unless cred[:name].to_s.strip.present?
cred in {password:}
cred in {custom_fields: Hash => custom_fields}

@credential_set.credentials
.where(name: cred[:name].to_s.strip)
.first_or_create(password: password || Credential.generate_password)
.tap {
_1.password = password if password
_1.config_map.merge!(custom_fields) if custom_fields
}
.save
end
end
rescue Psych::SyntaxError
render status: 400
end


private
def get_credential_set
@credential_set = authorized_scope(@exercise.credential_sets).friendly.find(params[:credential_set_id])
end

def get_credential
@credential = authorized_scope(@credential_set.credentials).find(params[:id])
authorize! @credential
end

def randomize_param
@randomize_param ||= params[:credential].extract!(:randomize_password)
end

def credential_params
params.require(:credential).permit(:name, :password, :email_override, :username_override, :read_only)
end

def config_map_update
@form = ConfigMapForm.new(@credential, params[:cm])
if @form.save
render turbo_stream: turbo_stream.remove('config_map_errors')
else
render turbo_stream: turbo_stream.append(
helpers.dom_id(@credential, 'config_map_form'),
FormErrorBoxComponent.new(@form, id: 'config_map_errors').render_in(view_context)
)
end
end
end
6 changes: 5 additions & 1 deletion app/javascript/controllers/editor_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { cobalt } from "thememirror";
import { defaultKeymap } from "@codemirror/commands";

export default class extends Controller {
static values = {
live: { type: Boolean, default: true },
};

editor;

connect() {
Expand All @@ -20,7 +24,7 @@ export default class extends Controller {
updatehandler = EditorView.updateListener.of((v) => {
if (v.docChanged) {
textarea.value = v.state.doc.toString();
throttled_submit();
this.liveValue && throttled_submit();
}
});
}
Expand Down
34 changes: 34 additions & 0 deletions app/models/credential.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

class Credential < ApplicationRecord
has_paper_trail
belongs_to :credential_set, touch: true

validates :name, :password, presence: true

def self.to_icon
'fa-key'
end

def self.generate_password
SecureRandom.alphanumeric(12)
end

def email
[config_map['email'].presence || username, domain_from_network].join('@')
end

def username
config_map['username'].presence || ActiveSupport::Inflector.parameterize(name, separator: '.')
end

def config_map_as_yaml
config_map.to_yaml
end

private
def domain_from_network
return unless credential_set.network
credential_set.network.full_domain
end
end
5 changes: 5 additions & 0 deletions app/models/credential_binding.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class CredentialBinding < ApplicationRecord
belongs_to :credential_set
end
22 changes: 22 additions & 0 deletions app/models/credential_set.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class CredentialSet < ApplicationRecord
extend FriendlyId
friendly_id :name, use: [:slugged, :scoped], scope: :exercise
has_paper_trail

belongs_to :exercise
belongs_to :network, optional: true
has_many :credentials, dependent: :destroy
has_many :credential_bindings, dependent: :destroy

validates :name, uniqueness: { scope: :exercise }, presence: true

def self.to_icon
'fa-key'
end

def network_domain_prefix
network.full_domain.split('.').first.upcase if network
end
end
1 change: 1 addition & 0 deletions app/models/exercise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Exercise < ApplicationRecord
has_many :services, dependent: :destroy
has_many :service_subjects, through: :services
has_many :capabilities, dependent: :destroy
has_many :credential_sets, dependent: :destroy
has_many :address_pools, through: :networks
has_many :addresses, through: :virtual_machines
has_many :customization_specs, through: :virtual_machines
Expand Down
27 changes: 27 additions & 0 deletions app/policies/credential_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

class CredentialPolicy < ApplicationPolicy
def index?
true
end

def show?
true
end

def create?
allowed_to?(:update?, record.credential_set)
end

def update?
create?
end

def destroy?
create?
end

relation_scope do |relation|
next relation
end
end
29 changes: 29 additions & 0 deletions app/policies/credential_set_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

class CredentialSetPolicy < ApplicationPolicy
include EnvironmentAssociatedPolicy

def show?
can_read_exercise?
end

def create?
false
end

def update?
create?
end

def destroy?
create?
end

relation_scope do |relation|
next relation if user.super_admin?
relation
.joins(:exercise)
.merge(Exercise.for_user(user))
.distinct
end
end
9 changes: 9 additions & 0 deletions app/views/application/_header.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@
- if @exercise.capabilities.size > 0
%span.text-xs.text-white.bg-indigo-500.px-2.rounded-sm= authorized_scope(@exercise.capabilities).size


- if Rails.configuration.x.features.dig(:credentials)
%li
= link_to [@exercise, :credential_sets], class: 'block transition-colors hover:text-sky-400 p-2 px-3 rounded hover:bg-gray-100 dark:hover:bg-gray-700' do
.flex.items-center.justify-between
.flex.flex-grow.items-center.gap-x-1
%i.fas{class: Credential.to_icon}
%span.text-sm Credentials

- if content_for?(:new_url)
= link_to content_for(:new_url), class: 'form-submit-add h-6' do
%i.fas.fa-plus.self-center
Expand Down
19 changes: 19 additions & 0 deletions app/views/credential_sets/_credential_list_row.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
%li.px-4.my-2.group{id: dom_id(credential, 'list_item')}
.flex.items-center
= link_to credential.name, [@exercise, @credential_set, credential], class: 'grow text-lg font-bold', data: { turbo_frame: "editable_cred" }
- if allowed_to?(:destroy?, credential)
.opacity-0.group-hover:opacity-100.transition-opacity
= link_to [@exercise, @credential_set, credential], data: { turbo_method: 'delete', turbo_confirm: 'Are you sure?' } do
%i.fas.fa-times-circle.text-red-600

.flex.items-center.gap-1
.grow.opacity-60.text-sm= credential.email

- %i(username password email).each do |cred_attr|
%div{data: { controller: 'clipboard', clipboard_success_content_value: '<i>✓</i>'}}
%span.hidden{data: {clipboard_target: 'source'}}<= credential.public_send(cred_attr)
= link_to 'javascript:;', data: {action: "clipboard#copy"} do
%span.inline-flex.items-center.gap-1.bg-blue-100.text-blue-800.text-xs.font-medium.rounded.dark:bg-blue-900.dark:text-blue-300{class: "px-2.5 py-0.5"}
%span{data: {clipboard_target: 'button'}}
%i.fas.fa-clipboard
= Credential.human_attribute_name(cred_attr)
35 changes: 35 additions & 0 deletions app/views/credential_sets/_credentials_section.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
= render SubResourceSectionComponent.new(header: 'Credentials list') do |section|
- if allowed_to?(:create?, Credential.new(credential_set: @credential_set))
- section.with_button do
= link_to [:new, @exercise, @credential_set, :credential], data: { turbo_frame: "editable_cred" }, class: 'form-submit-add' do
%i.fas.fa-plus.self-center
Add credential

- section.with_button do
= render ModalComponent.new(header: 'Bulk import credentials') do |c|
- c.with_body do
= form_with(url: import_exercise_credential_set_credentials_path(@exercise, @credential_set), data: { action: "modal#close"}) do |f|
%p Importer is using following YAML format
%code
%pre
:plain
---
credentials:
- name: User Name
password: My.Password
custom_fields: # freeform import to configmap

.my-1= f.text_area :import_yaml, data: {controller: 'editor', editor_live_value: false}
.my-2= f.submit :save, class: 'form-submit'

= link_to 'javascript:;', class: 'form-submit', data: { action: "click->modal#open" } do
Bulk import

.grid.grid-cols-12.mb-8
%aside.col-span-6
%ul#credential-list.py-3.overflow-y-auto{class: 'h-[50vh]'}
- @credential_set.credentials.order(:name).each do |credential|
= render 'credential_list_row', credential:

= turbo_frame_tag 'editable_cred', class: 'col-span-6 border-l-4 border-double border-zinc-500 bg-zinc-100 dark:bg-zinc-800' do
= render 'empty'
6 changes: 6 additions & 0 deletions app/views/credential_sets/_empty.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.flex.h-full.items-center.justify-center
.text-3xl.text-gray-700.dark:text-gray-500.text-center
%i.fas.fa-3x{class: Credential.to_icon}

%p.py-6
Select a cred from the list or add a new one
Loading

0 comments on commit ba4516d

Please sign in to comment.