From ba4516de4b4a64eb700a3a13e797bfb89d992d4b Mon Sep 17 00:00:00 2001 From: Mikk Romulus Date: Fri, 18 Oct 2024 14:44:30 +0300 Subject: [PATCH] feat: add credentials UI Data model is: - `CredentialSet` <--many-to-one --> `Credential` --- app/controllers/credential_sets_controller.rb | 54 ++++++++++++ app/controllers/credentials_controller.rb | 87 +++++++++++++++++++ .../controllers/editor_controller.js | 6 +- app/models/credential.rb | 34 ++++++++ app/models/credential_binding.rb | 5 ++ app/models/credential_set.rb | 22 +++++ app/models/exercise.rb | 1 + app/policies/credential_policy.rb | 27 ++++++ app/policies/credential_set_policy.rb | 29 +++++++ app/views/application/_header.html.haml | 9 ++ .../_credential_list_row.html.haml | 19 ++++ .../_credentials_section.html.haml | 35 ++++++++ app/views/credential_sets/_empty.html.haml | 6 ++ app/views/credential_sets/_form.html.haml | 47 ++++++++++ app/views/credential_sets/index.html.haml | 24 +++++ app/views/credential_sets/new.html.haml | 1 + app/views/credential_sets/show.html.haml | 8 ++ app/views/credentials/_credential.html.haml | 33 +++++++ .../credentials/create.turbo_stream.haml | 5 ++ .../credentials/destroy.turbo_stream.haml | 4 + .../credentials/import.turbo_stream.haml | 3 + app/views/credentials/new.html.haml | 11 +++ app/views/credentials/show.html.haml | 1 + .../credentials/update.turbo_stream.haml | 5 ++ config/routes.rb | 9 ++ .../20241115093737_create_credential_sets.rb | 15 ++++ .../20241115093808_create_credentials.rb | 14 +++ ...241115104725_create_credential_bindings.rb | 12 +++ db/schema.rb | 38 +++++++- spec/factories.rb | 17 ++++ spec/models/credential_binding_spec.rb | 7 ++ spec/models/credential_set_spec.rb | 7 ++ spec/models/credential_spec.rb | 43 +++++++++ 33 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 app/controllers/credential_sets_controller.rb create mode 100644 app/controllers/credentials_controller.rb create mode 100644 app/models/credential.rb create mode 100644 app/models/credential_binding.rb create mode 100644 app/models/credential_set.rb create mode 100644 app/policies/credential_policy.rb create mode 100644 app/policies/credential_set_policy.rb create mode 100644 app/views/credential_sets/_credential_list_row.html.haml create mode 100644 app/views/credential_sets/_credentials_section.html.haml create mode 100644 app/views/credential_sets/_empty.html.haml create mode 100644 app/views/credential_sets/_form.html.haml create mode 100644 app/views/credential_sets/index.html.haml create mode 100644 app/views/credential_sets/new.html.haml create mode 100644 app/views/credential_sets/show.html.haml create mode 100644 app/views/credentials/_credential.html.haml create mode 100644 app/views/credentials/create.turbo_stream.haml create mode 100644 app/views/credentials/destroy.turbo_stream.haml create mode 100644 app/views/credentials/import.turbo_stream.haml create mode 100644 app/views/credentials/new.html.haml create mode 100644 app/views/credentials/show.html.haml create mode 100644 app/views/credentials/update.turbo_stream.haml create mode 100644 db/migrate/20241115093737_create_credential_sets.rb create mode 100644 db/migrate/20241115093808_create_credentials.rb create mode 100644 db/migrate/20241115104725_create_credential_bindings.rb create mode 100644 spec/models/credential_binding_spec.rb create mode 100644 spec/models/credential_set_spec.rb create mode 100644 spec/models/credential_spec.rb diff --git a/app/controllers/credential_sets_controller.rb b/app/controllers/credential_sets_controller.rb new file mode 100644 index 00000000..73ac13f8 --- /dev/null +++ b/app/controllers/credential_sets_controller.rb @@ -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 diff --git a/app/controllers/credentials_controller.rb b/app/controllers/credentials_controller.rb new file mode 100644 index 00000000..9a3ac727 --- /dev/null +++ b/app/controllers/credentials_controller.rb @@ -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 diff --git a/app/javascript/controllers/editor_controller.js b/app/javascript/controllers/editor_controller.js index 6f411598..420c6567 100644 --- a/app/javascript/controllers/editor_controller.js +++ b/app/javascript/controllers/editor_controller.js @@ -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() { @@ -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(); } }); } diff --git a/app/models/credential.rb b/app/models/credential.rb new file mode 100644 index 00000000..2bad84b1 --- /dev/null +++ b/app/models/credential.rb @@ -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 diff --git a/app/models/credential_binding.rb b/app/models/credential_binding.rb new file mode 100644 index 00000000..7128d9f9 --- /dev/null +++ b/app/models/credential_binding.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CredentialBinding < ApplicationRecord + belongs_to :credential_set +end diff --git a/app/models/credential_set.rb b/app/models/credential_set.rb new file mode 100644 index 00000000..e7b15762 --- /dev/null +++ b/app/models/credential_set.rb @@ -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 diff --git a/app/models/exercise.rb b/app/models/exercise.rb index f3f67892..bdae6e56 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -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 diff --git a/app/policies/credential_policy.rb b/app/policies/credential_policy.rb new file mode 100644 index 00000000..55c2d0e6 --- /dev/null +++ b/app/policies/credential_policy.rb @@ -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 diff --git a/app/policies/credential_set_policy.rb b/app/policies/credential_set_policy.rb new file mode 100644 index 00000000..8e21522e --- /dev/null +++ b/app/policies/credential_set_policy.rb @@ -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 diff --git a/app/views/application/_header.html.haml b/app/views/application/_header.html.haml index 5bd2048f..9223f209 100644 --- a/app/views/application/_header.html.haml +++ b/app/views/application/_header.html.haml @@ -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 diff --git a/app/views/credential_sets/_credential_list_row.html.haml b/app/views/credential_sets/_credential_list_row.html.haml new file mode 100644 index 00000000..a5c22092 --- /dev/null +++ b/app/views/credential_sets/_credential_list_row.html.haml @@ -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: ''}} + %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) diff --git a/app/views/credential_sets/_credentials_section.html.haml b/app/views/credential_sets/_credentials_section.html.haml new file mode 100644 index 00000000..a25a3b22 --- /dev/null +++ b/app/views/credential_sets/_credentials_section.html.haml @@ -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' diff --git a/app/views/credential_sets/_empty.html.haml b/app/views/credential_sets/_empty.html.haml new file mode 100644 index 00000000..20e13ffd --- /dev/null +++ b/app/views/credential_sets/_empty.html.haml @@ -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 diff --git a/app/views/credential_sets/_form.html.haml b/app/views/credential_sets/_form.html.haml new file mode 100644 index 00000000..6c7008eb --- /dev/null +++ b/app/views/credential_sets/_form.html.haml @@ -0,0 +1,47 @@ +-# frozen_string_literal: true + +- data = { controller: 'model', action: "change->model#update" } if @credential_set.persisted? += simple_form_for([@credential_set.exercise, @credential_set], html: { autocomplete: "off", data: }) do |f| + = f.error_notification + = f.error_notification message: f.object.errors[:base].to_sentence if f.object.errors[:base].present? + + = render ColumnFormSectionComponent.new do |c| + - c.with_description do + %h3.text-lg.leading-6.text-gray-900.dark:text-gray-50 Identity + %p.mt-1.text-sm.text-gray-600.dark:text-gray-400 + Name, network and description for this set of credentials. + + - c.with_main do + .bg-white.dark:bg-gray-800.space-y-3 + .px-5.py-3= f.input :name + .border-t.border-gray-200.dark:border-gray-600 + .px-5.py-3= f.input :description + .border-t.border-gray-200.dark:border-gray-600 + .px-5.py-3 + = render ModalComponent.new(header: 'Network for credentials') do |c| + - c.with_body do + %p The network will be used to generate the e-mail addresses for each credential. + %p The first portion of domain will be used as the name of AD domain - this can be overridden, if this does not match the actual configuration. + %div + = f.label :network_id, class: 'block font-bold text-gray-700 dark:text-gray-200' do + = f.object.class.human_attribute_name(:network_id) + = link_to 'javascript:;', class: 'text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-200', data: { action: "click->modal#open" } do + %i.fas.fa-circle-question + = f.collection_select :network_id, authorized_scope(@exercise.networks).order(:name), :id, :name, {include_blank: true}, data: { controller: 'select' } + + - if @credential_set.network + .grid.grid-cols-2.gap-4.mt-3 + %div + %dt.text-sm.text-gray-500.dark:text-gray-400 Domain user prefix + %dd.mt-1.text-sm.text-gray-900.dark:text-white.mt-0= @credential_set.network_domain_prefix + + %div + %dt.text-sm.text-gray-500.dark:text-gray-400 Credential e-mail format + %dd.mt-1.text-sm.text-gray-900.dark:text-white.mt-0 + = render LiquidTextComponent.new({ text: @credential_set.network.full_domain }, object: @credential_set.network) + + + + - if allowed_to?(:update?, @credential_set) && !@credential_set.persisted? + .px-4.py-3.bg-slate-200.dark:bg-gray-500.text-right.sm:px-6 + = f.button :submit, 'Save', class: 'form-submit' diff --git a/app/views/credential_sets/index.html.haml b/app/views/credential_sets/index.html.haml new file mode 100644 index 00000000..b9fbdbd0 --- /dev/null +++ b/app/views/credential_sets/index.html.haml @@ -0,0 +1,24 @@ +- if allowed_to?(:create?, CredentialSet.new(exercise: @exercise)) + - provide :new_url do + = url_for [:new, @exercise, :credential_set] + +- if @credential_sets.any? + = render TableComponent.new do |c| + - c.with_column { CredentialSet.human_attribute_name(:name) } + - c.with_column { Network.model_name.human } + - c.with_column {} + + - @credential_sets.each do |cred_set| + - c.with_table_row(classes: 'group') do + %td.px-6.py-4= link_to cred_set.name, [@exercise, cred_set], class: 'text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-200' + %td.px-6.py-4 + - if cred_set.network + = link_to [@exercise, cred_set.network] do + = render ActorChipComponent.new(actor: cred_set.network.actor, text: cred_set.network.name) + %td.px-6.py-4.whitespace-nowrap.text-right + - if allowed_to?(:destroy?, cred_set) + = link_to [cred_set.exercise, cred_set], data: { turbo_method: 'delete', turbo_confirm: 'Are you sure?' }, class: 'opacity-0 group-hover:opacity-100 transition-opacity' do + %i.fas.fa-times-circle.text-red-600 + +- else + = render 'shared/empty', klass: CredentialSet diff --git a/app/views/credential_sets/new.html.haml b/app/views/credential_sets/new.html.haml new file mode 100644 index 00000000..b1bc3ba0 --- /dev/null +++ b/app/views/credential_sets/new.html.haml @@ -0,0 +1 @@ += render "form" diff --git a/app/views/credential_sets/show.html.haml b/app/views/credential_sets/show.html.haml new file mode 100644 index 00000000..2bcee9a7 --- /dev/null +++ b/app/views/credential_sets/show.html.haml @@ -0,0 +1,8 @@ +- if allowed_to?(:create?, CredentialSet.new(exercise: @exercise)) + - provide :new_url do + = url_for [:new, @exercise, :credential_set] + +- title @credential_set.name + += render 'form' += render 'credentials_section' \ No newline at end of file diff --git a/app/views/credentials/_credential.html.haml b/app/views/credentials/_credential.html.haml new file mode 100644 index 00000000..5b95d7d1 --- /dev/null +++ b/app/views/credentials/_credential.html.haml @@ -0,0 +1,33 @@ += turbo_frame_tag 'editable_cred' do + %article[@credential] + = form_with(model: @credential, url: [@exercise, @credential_set, @credential], class: 'hidden', data: { controller: 'model', action: "change->model#update"}, id: dom_id(@credential, :secondary)) do |form| +   + = form_with(model: @credential, url: [@exercise, @credential_set, @credential], class: 'p-3', data: { controller: 'model', action: "change->model#update"}) do |form| + %fieldset + .grid.grid-cols-2.gap-x-2.gap-y-8 + %div + = form.label :name, class: 'font-bold text-gray-700 dark:text-gray-200 text-right basis-1/6' + = form.text_field :name, class: 'form-input', id: "#{dom_id(@credential)}_name" + + %div + .flex + .grow= form.label :password, class: 'font-bold text-gray-700 dark:text-gray-200 text-right basis-1/6', for: "#{dom_id(@credential)}_password" + - if @credential.persisted? + %button.text-white.bg-gradient-to-r.from-cyan-500.to-blue-500.hover:bg-gradient-to-bl.focus:ring-4.focus:outline-none.focus:ring-cyan-300.dark:focus:ring-cyan-800.font-medium.rounded-lg.text-sm.px-2.text-center{class: "py-0", type: "submit", name: "credential[randomize_password]", form: dom_id(@credential, :secondary)} + %i.fas.fa-dice + Randomize + + .mt-1= form.text_field :password, class: 'form-input', id: "#{dom_id(@credential)}_password" + + %div + = form.label :username, class: 'font-bold text-gray-700 dark:text-gray-200 text-right basis-1/6' + %p= @credential.username + + %div + = form.label :email, class: 'font-bold text-gray-700 dark:text-gray-200 text-right basis-1/6' + %p= render LiquidTextComponent.new({ text: @credential.email }, object: @credential_set.network) + + = form_with(model: ConfigMapForm.new(@credential), url: [@exercise, @credential_set, @credential], id: dom_id(@credential, 'config_map_form'), data: { controller: 'model', action: "change->model#update"}) do |form| + .px-2.py-8.max-w-full + = form.label :config_map, class: 'font-bold text-gray-700 dark:text-gray-200 text-right basis-1/6' + = form.text_area :config_map_as_yaml, data: {controller: 'editor'} diff --git a/app/views/credentials/create.turbo_stream.haml b/app/views/credentials/create.turbo_stream.haml new file mode 100644 index 00000000..9be68031 --- /dev/null +++ b/app/views/credentials/create.turbo_stream.haml @@ -0,0 +1,5 @@ += turbo_stream.prepend 'credential-list' do + = render 'credential_sets/credential_list_row', credential: @credential + += turbo_stream.replace 'credential_new' do + = render @credential diff --git a/app/views/credentials/destroy.turbo_stream.haml b/app/views/credentials/destroy.turbo_stream.haml new file mode 100644 index 00000000..9a192486 --- /dev/null +++ b/app/views/credentials/destroy.turbo_stream.haml @@ -0,0 +1,4 @@ += turbo_stream.remove dom_id(@credential, 'list_item') + += turbo_stream.replace dom_id(@credential) do + = render 'credential_sets/empty' diff --git a/app/views/credentials/import.turbo_stream.haml b/app/views/credentials/import.turbo_stream.haml new file mode 100644 index 00000000..83bdacb0 --- /dev/null +++ b/app/views/credentials/import.turbo_stream.haml @@ -0,0 +1,3 @@ += turbo_stream.update 'credential-list' do + - @credential_set.credentials.order(:name).each do |credential| + = render 'credential_sets/credential_list_row', credential: \ No newline at end of file diff --git a/app/views/credentials/new.html.haml b/app/views/credentials/new.html.haml new file mode 100644 index 00000000..35705e5f --- /dev/null +++ b/app/views/credentials/new.html.haml @@ -0,0 +1,11 @@ += turbo_frame_tag 'editable_cred' do + %article.p-2[@credential] + %h3.text-xl.text-center New credential + + = form_with(model: @credential, url: [@exercise, @credential_set, @credential], class: 'p-3 group') do |form| + %fieldset + .mb-2 + = form.label :name, class: 'font-bold text-gray-700 dark:text-gray-200 text-right basis-1/6' + = form.text_field :name, class: 'form-input', id: "#{dom_id(@credential)}_name" + + = form.submit :save, class: 'form-submit' diff --git a/app/views/credentials/show.html.haml b/app/views/credentials/show.html.haml new file mode 100644 index 00000000..7d95d7d8 --- /dev/null +++ b/app/views/credentials/show.html.haml @@ -0,0 +1 @@ += render @credential \ No newline at end of file diff --git a/app/views/credentials/update.turbo_stream.haml b/app/views/credentials/update.turbo_stream.haml new file mode 100644 index 00000000..fc21d5c3 --- /dev/null +++ b/app/views/credentials/update.turbo_stream.haml @@ -0,0 +1,5 @@ += turbo_stream.replace dom_id(@credential, 'list_item') do + = render 'credential_sets/credential_list_row', credential: @credential + += turbo_stream.update dom_id(@credential) do + = render @credential \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 220562af..d2c8f4ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,15 @@ resources :checks, only: %i[create update destroy] end resources :capabilities + if Rails.configuration.x.features.credentials + resources :credential_sets, path: :credentials do + resources :credentials, only: %i[new create show update destroy] do + collection do + post :import + end + end + end + end resource :clone, only: %i[show create] end diff --git a/db/migrate/20241115093737_create_credential_sets.rb b/db/migrate/20241115093737_create_credential_sets.rb new file mode 100644 index 00000000..7a03d35d --- /dev/null +++ b/db/migrate/20241115093737_create_credential_sets.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateCredentialSets < ActiveRecord::Migration[7.2] + def change + create_table :credential_sets do |t| + t.references :exercise, null: false, foreign_key: true + t.references :network, foreign_key: true + t.string :name, null: false + t.string :slug, null: false + t.text :description + + t.timestamps + end + end +end diff --git a/db/migrate/20241115093808_create_credentials.rb b/db/migrate/20241115093808_create_credentials.rb new file mode 100644 index 00000000..76c3d474 --- /dev/null +++ b/db/migrate/20241115093808_create_credentials.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateCredentials < ActiveRecord::Migration[8.0] + def change + create_table :credentials do |t| + t.references :credential_set, null: false, foreign_key: true + t.string :name, null: false + t.string :password, null: false + t.jsonb :config_map, null: false, default: {} + + t.timestamps + end + end +end diff --git a/db/migrate/20241115104725_create_credential_bindings.rb b/db/migrate/20241115104725_create_credential_bindings.rb new file mode 100644 index 00000000..f9cd41e8 --- /dev/null +++ b/db/migrate/20241115104725_create_credential_bindings.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateCredentialBindings < ActiveRecord::Migration[7.2] + def change + create_table :credential_bindings do |t| + t.references :credential_set, null: false, foreign_key: true + t.references :customization_spec, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index bcb58a84..b4cf1b6a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_31_091614) do +ActiveRecord::Schema[8.0].define(version: 2024_11_15_104725) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -121,6 +121,37 @@ t.index ["source_type", "source_id"], name: "index_checks_on_source" end + create_table "credential_bindings", force: :cascade do |t| + t.bigint "credential_set_id", null: false + t.bigint "customization_spec_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["credential_set_id"], name: "index_credential_bindings_on_credential_set_id" + t.index ["customization_spec_id"], name: "index_credential_bindings_on_customization_spec_id" + end + + create_table "credential_sets", force: :cascade do |t| + t.bigint "exercise_id", null: false + t.bigint "network_id" + t.string "name", null: false + t.string "slug", null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["exercise_id"], name: "index_credential_sets_on_exercise_id" + t.index ["network_id"], name: "index_credential_sets_on_network_id" + end + + create_table "credentials", force: :cascade do |t| + t.bigint "credential_set_id", null: false + t.string "name", null: false + t.string "password", null: false + t.jsonb "config_map", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["credential_set_id"], name: "index_credentials_on_credential_set_id" + end + create_table "custom_check_subjects", force: :cascade do |t| t.string "base_class", null: false t.string "meaning", null: false @@ -350,6 +381,11 @@ add_foreign_key "capabilities", "actors" add_foreign_key "capabilities", "exercises" add_foreign_key "checks", "services" + add_foreign_key "credential_bindings", "credential_sets" + add_foreign_key "credential_bindings", "customization_specs" + add_foreign_key "credential_sets", "exercises" + add_foreign_key "credential_sets", "networks" + add_foreign_key "credentials", "credential_sets" add_foreign_key "customization_specs", "virtual_machines" add_foreign_key "instance_metadata", "customization_specs" add_foreign_key "network_interfaces", "networks" diff --git a/spec/factories.rb b/spec/factories.rb index 1759e8e9..267f78b7 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -122,4 +122,21 @@ instance { 'MyString' } metadata { '' } end + + factory :credential do + credential_set + + name { Faker::Name.name } + password { Faker::Internet.password } + end + + factory :credential_set do + exercise + name { 'MyString' } + end + + factory :credential_binding do + credential_set { nil } + customization_spec { nil } + end end diff --git a/spec/models/credential_binding_spec.rb b/spec/models/credential_binding_spec.rb new file mode 100644 index 00000000..888816e6 --- /dev/null +++ b/spec/models/credential_binding_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CredentialBinding, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/credential_set_spec.rb b/spec/models/credential_set_spec.rb new file mode 100644 index 00000000..fde94e1c --- /dev/null +++ b/spec/models/credential_set_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CredentialSet, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/credential_spec.rb b/spec/models/credential_spec.rb new file mode 100644 index 00000000..00f78dbb --- /dev/null +++ b/spec/models/credential_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Credential, type: :model do + let(:model) { build(:credential, name: 'John Williams') } + + context '#username' do + subject { model.username } + + it { is_expected.to eq 'john.williams' } + + context 'with username field on config map' do + let(:model) { build(:credential, name: 'John Williams', config_map: { username: 'bigjohn' }) } + + it { is_expected.to eq 'bigjohn' } + end + end + + context '#email' do + subject { model.email } + + it { is_expected.to eq 'john.williams@' } + + context 'with username field on config map' do + let(:model) { build(:credential, name: 'John Williams', config_map: { username: 'bigjohn' }) } + + it { is_expected.to eq 'bigjohn@' } + end + + context 'with email field on config map' do + let(:model) { build(:credential, name: 'John Williams', config_map: { email: 'jarjarsux' }) } + + it { is_expected.to eq 'jarjarsux@' } + end + + context 'with email and username field on config map' do + let(:model) { build(:credential, name: 'John Williams', config_map: { email: 'jarjarsux', username: 'bigjohn' }) } + + it { is_expected.to eq 'jarjarsux@' } + end + end +end