diff --git a/.rubocop.yml b/.rubocop.yml index 824c97eb3..752902541 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -83,6 +83,9 @@ Naming/FileName: Rails/EnumHash: Enabled: false +Rails/SkipsModelValidations: + AllowedMethods: ["touch"] + # Keep for now, easier with superclass definitions Style/ClassAndModuleChildren: Enabled: false diff --git a/Gemfile b/Gemfile index 0691a0464..38ea0c793 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,7 @@ gem 'faker' gem 'haml' gem 'maxmind-db' gem 'openid_connect' +gem 'paper_trail', '~> 12.3' gem 'password_strength' gem 'pg' gem 'pundit' diff --git a/Gemfile.lock b/Gemfile.lock index 2f9d7485b..832b6dbee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -195,6 +195,9 @@ GEM validate_email validate_url webfinger (>= 1.0.1) + paper_trail (12.3.0) + activerecord (>= 5.2) + request_store (~> 1.1) parallel (1.21.0) parser (3.1.1.0) ast (~> 2.4.1) @@ -263,6 +266,8 @@ GEM rainbow (3.1.1) rake (13.0.6) regexp_parser (2.2.1) + request_store (1.5.1) + rack (>= 1.4) rexml (3.2.5) rspec-core (3.11.0) rspec-support (~> 3.11.0) @@ -384,6 +389,7 @@ DEPENDENCIES mysql2 net-ldap openid_connect + paper_trail (~> 12.3) password_strength pg pry diff --git a/app/controllers/api/encryptables_controller.rb b/app/controllers/api/encryptables_controller.rb index 7438ab22f..bf8d8eaf5 100644 --- a/app/controllers/api/encryptables_controller.rb +++ b/app/controllers/api/encryptables_controller.rb @@ -5,6 +5,8 @@ class Api::EncryptablesController < ApiController self.permitted_attrs = [:name, :description, :tag] + before_action :set_paper_trail_whodunnit + helper_method :team # GET /api/encryptables @@ -19,6 +21,7 @@ def index def show authorize entry entry.decrypt(decrypted_team_password(team)) + log_read_access render_entry end @@ -69,6 +72,16 @@ def model_class end # rubocop:enable Metrics/MethodLength + def fetch_entries + if encryptable_file? + super + elsif tag_param.present? + user_encryptables.find_by(tag: tag_param) + else + Encryptables::FilteredList.new(current_user, params).fetch_entries + end + end + def build_entry return build_encryptable_file if encryptable_file? @@ -128,4 +141,11 @@ def permitted_attrs [] end end + + def log_read_access + v = encryptable.paper_trail.save_with_version + v.event = :viewed + v.created_at = DateTime.now + v.save! + end end diff --git a/app/controllers/api/logs_controller.rb b/app/controllers/api/logs_controller.rb new file mode 100644 index 000000000..3f1f1fcce --- /dev/null +++ b/app/controllers/api/logs_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::LogsController < ApiController + + def index(options = {}) + authorize(team, :index?, policy_class: LogPolicy) + render({ json: fetch_entries, + each_serializer: @model_serializer ||= LogsSerializer } + .merge(render_options) + .merge(options.fetch(:render_options, {}))) + end + + def fetch_entries + PaperTrail.serializer = JSON + limit = params[:load] || 20 + offset = params[:offset] || 0 + Version + .includes(:user) + .where(item_id: params[:encryptable_id]) + .order(created_at: :desc) + .offset(offset) + .limit(limit) + end + + def team + @team ||= Encryptable.find(params[:encryptable_id]).folder.team + end +end diff --git a/app/models/encryptable.rb b/app/models/encryptable.rb index 6df73e7ba..67471cd34 100644 --- a/app/models/encryptable.rb +++ b/app/models/encryptable.rb @@ -17,6 +17,12 @@ class Encryptable < ApplicationRecord + has_paper_trail on: [:touch, :update], ignore: [:tag, :type], versions: { + class_name: 'Version' + } + + before_destroy :destroy_versions + serialize :encrypted_data, ::EncryptedData attr_readonly :type @@ -71,4 +77,8 @@ def decrypt_attr(attr, team_password) instance_variable_set("@cleartext_#{attr}", cleartext_value) end + def destroy_versions + versions.destroy_all + end + end diff --git a/app/models/user.rb b/app/models/user.rb index e3236556e..327bcd736 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -31,6 +31,8 @@ class User < ApplicationRecord validates :username, uniqueness: :username validates :username, presence: true + has_many :versions, foreign_key: :whodunnit, dependent: :destroy, class_name: 'Version' + def update_password(old, new) return unless auth_db? diff --git a/app/models/version.rb b/app/models/version.rb new file mode 100644 index 000000000..8ab1f54d3 --- /dev/null +++ b/app/models/version.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Version < PaperTrail::Version + belongs_to :user, class_name: 'User', foreign_key: :whodunnit + belongs_to :encryptable, class_name: 'Encryptable', foreign_key: :item_id +end diff --git a/app/policies/log_policy.rb b/app/policies/log_policy.rb new file mode 100644 index 000000000..93eabd6cb --- /dev/null +++ b/app/policies/log_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class LogPolicy < TeamPolicy + def index? + team_member? + end + + def show? + @team.teammember?(@user.id) && !@user.is_a?(User::Api) + end +end diff --git a/app/presenters/encryptables/filtered_list.rb b/app/presenters/encryptables/filtered_list.rb new file mode 100644 index 000000000..785dd0f47 --- /dev/null +++ b/app/presenters/encryptables/filtered_list.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module ::Encryptables + class FilteredList < ::FilteredList + + def fetch_entries + filtered_encryptables = encryptables + + filtered_encryptables = filter_by_recent if recent? + filtered_encryptables = filter_by_query(filtered_encryptables) if query + + + filtered_encryptables + end + + private + + def query + @params[:q]&.strip&.downcase + end + + def recent? + true?(@params[:recent]) + end + + def encryptables + @current_user.encryptables + end + + def filter_by_query(encryptables) + encryptables.where( + 'lower(encryptables.description) LIKE :query + OR lower(encryptables.name) LIKE :query', + query: "%#{query}%" + ) + end + + def filter_by_recent + Version + .includes(:encryptable, encryptable: [:folder]) + .where(whodunnit: @current_user) + .order(created_at: :desc) + .group(:item_id, :item_type) + .select(:item_id, :item_type) + .limit(5) + .map(&:encryptable) + end + end +end diff --git a/app/presenters/filtered_list.rb b/app/presenters/filtered_list.rb index 92b3a2b62..fb3dce893 100644 --- a/app/presenters/filtered_list.rb +++ b/app/presenters/filtered_list.rb @@ -15,4 +15,8 @@ def fetch_entries def list_param(key) @params[key].to_a.map(&:to_i) end + + def true?(value) + %w[1 yes true].include?(value.to_s.downcase) + end end diff --git a/app/serializers/logs_serializer.rb b/app/serializers/logs_serializer.rb new file mode 100644 index 000000000..4fd6c298b --- /dev/null +++ b/app/serializers/logs_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class LogsSerializer < ApplicationSerializer + attributes :id, :item_type, :event, :user_id, :username, :created_at + + belongs_to :encryptable + + def user_id + object.whodunnit + end + + def encryptable + object.item + end + + def username + object.user.username + end +end diff --git a/config/routes/api.rb b/config/routes/api.rb index d5c0cd70c..554c3c50a 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -11,7 +11,9 @@ end end - resources :encryptables, except: [:new, :edit] + resources :encryptables, except: [:new, :edit] do + resources :logs, only: [:index] + end resources :api_users, except: [:new, :edit] do member do diff --git a/db/migrate/20220401130939_create_versions.rb b/db/migrate/20220401130939_create_versions.rb new file mode 100644 index 000000000..f6e7ba97a --- /dev/null +++ b/db/migrate/20220401130939_create_versions.rb @@ -0,0 +1,38 @@ +# This migration creates the `versions` table, the only schema PT requires. +# All other migrations PT provides are optional. +class CreateVersions < ActiveRecord::Migration[7.0] + + # The largest text column available in all supported RDBMS is + # 1024^3 - 1 bytes, roughly one gibibyte. We specify a size + # so that MySQL will use `longtext` instead of `text`. Otherwise, + # when serializing very large objects, `text` might not be big enough. + TEXT_BYTES = 1_073_741_823 + + def change + create_table :versions do |t| + t.string :item_type, null: false + t.bigint :item_id, null: false + t.string :event, null: false + t.string :whodunnit + t.text :object, limit: TEXT_BYTES + + # Known issue in MySQL: fractional second precision + # ------------------------------------------------- + # + # MySQL timestamp columns do not support fractional seconds unless + # defined with "fractional seconds precision". MySQL users should manually + # add fractional seconds precision to this migration, specifically, to + # the `created_at` column. + # (https://dev.mysql.com/doc/refman/5.6/en/fractional-seconds.html) + # + # MySQL users should also upgrade to at least rails 4.2, which is the first + # version of ActiveRecord with support for fractional seconds in MySQL. + # (https://github.com/rails/rails/pull/14359) + # + # MySQL users should use the following line for `created_at` + t.datetime :created_at, limit: 6 + #t.datetime :created_at + end + add_index :versions, %i(item_type item_id) + end +end diff --git a/db/schema.rb b/db/schema.rb index d1398a757..6371a11bd 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.0].define(version: 2022_03_29_122335) do +ActiveRecord::Schema[7.0].define(version: 2022_04_01_130939) do create_table "encryptables", force: :cascade do |t| t.string "name", limit: 255, default: "", null: false t.integer "folder_id" @@ -92,4 +92,14 @@ t.index ["default_ccli_user_id"], name: "index_users_on_default_ccli_user_id" end + create_table "versions", force: :cascade do |t| + t.string "item_type", null: false + t.bigint "item_id", null: false + t.string "event", null: false + t.string "whodunnit" + t.text "object", limit: 1073741823 + t.datetime "created_at" + t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" + end + end diff --git a/frontend/app/adapters/version.js b/frontend/app/adapters/version.js new file mode 100644 index 000000000..a0dcb368e --- /dev/null +++ b/frontend/app/adapters/version.js @@ -0,0 +1,19 @@ +import ApplicationAdapter from "./application"; + +export default ApplicationAdapter.extend({ + namespace: "api/encryptables", + + pathForType() { + return "logs"; + }, + + urlForQuery(query, modelName) { + if (query.encryptableId) { + let url = `/${this.namespace}/${query.encryptableId}/logs`; + + delete query.encryptableId; + return url; + } + return super.urlForQuery(query, modelName); + } +}); diff --git a/frontend/app/components/encryptable/show.js b/frontend/app/components/encryptable/show.js index 9f326df3f..e5959bb65 100644 --- a/frontend/app/components/encryptable/show.js +++ b/frontend/app/components/encryptable/show.js @@ -9,9 +9,14 @@ export default class ShowComponent extends Component { @service intl; @service notify; + @tracked logs = []; + loadAmount = 10; + offset = 0; + constructor() { super(...arguments); + this.getLogs(); window.scrollTo(0, 0); } @@ -24,9 +29,13 @@ export default class ShowComponent extends Component { @tracked isPasswordVisible = false; + @tracked + canLoadMore = true; + @action toggleEncryptableEdit() { this.isEncryptableEditing = !this.isEncryptableEditing; + this.getLogs(); } @action @@ -57,4 +66,33 @@ export default class ShowComponent extends Component { onCopied(attribute) { this.notify.info(this.intl.t(`flashes.encryptables.${attribute}_copied`)); } + + @action + getLogs() { + this.store + .query("version", { + encryptableId: this.args.encryptable.id, + load: this.loadAmount, + offset: this.offset + }) + .then((res) => { + this.logs = res + .toArray() + .filter((log) => { + return !this.logs.includes(log); + }) + .concat(this.logs); + this.toggleLoadMore(); + }); + } + + @action + loadMore() { + this.offset += this.loadAmount; + this.getLogs(); + } + + toggleLoadMore() { + this.canLoadMore = this.loadAmount <= this.logs.length; + } } diff --git a/frontend/app/components/encryptable/table-row.js b/frontend/app/components/encryptable/table-row.js new file mode 100644 index 000000000..8847ef24c --- /dev/null +++ b/frontend/app/components/encryptable/table-row.js @@ -0,0 +1,7 @@ +import Component from "@glimmer/component"; + +export default class TableRowComponent extends Component { + constructor() { + super(...arguments); + } +} diff --git a/frontend/app/components/encryptable/table.js b/frontend/app/components/encryptable/table.js new file mode 100644 index 000000000..b028d289f --- /dev/null +++ b/frontend/app/components/encryptable/table.js @@ -0,0 +1,13 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class TableComponent extends Component { + @service store; + @service router; + @service intl; + @service notify; + + constructor() { + super(...arguments); + } +} diff --git a/frontend/app/models/encryptable.js b/frontend/app/models/encryptable.js index ad08fd894..9ad2be73f 100644 --- a/frontend/app/models/encryptable.js +++ b/frontend/app/models/encryptable.js @@ -1,4 +1,4 @@ -import Model, { attr, belongsTo } from "@ember-data/model"; +import Model, { attr, hasMany, belongsTo } from "@ember-data/model"; export default class Encryptable extends Model { @attr("string") name; @@ -6,6 +6,7 @@ export default class Encryptable extends Model { @attr("string") createdAt; @attr("string") updatedAt; @belongsTo("folder") folder; + @hasMany("version") versions; get isOseSecret() { return this.constructor.modelName === "encryptable-ose-secret"; diff --git a/frontend/app/models/version.js b/frontend/app/models/version.js new file mode 100644 index 000000000..3fb94e1a9 --- /dev/null +++ b/frontend/app/models/version.js @@ -0,0 +1,9 @@ +import Model, { attr, belongsTo } from "@ember-data/model"; + +export default class Version extends Model { + @attr("string") event; + @attr("number") userId; + @attr("string") username; + @attr("string") createdAt; + @belongsTo("encryptable") encryptable; +} diff --git a/frontend/app/router.js b/frontend/app/router.js index f75906592..eabcf5eba 100644 --- a/frontend/app/router.js +++ b/frontend/app/router.js @@ -11,6 +11,7 @@ Router.map(function () { this.route("new"); this.route("edit", { path: "/edit/:id" }); this.route("show", { path: "/:id" }); + this.route("logs", { path: "/:encryptable_id/logs" }); this.route( "file-entries", { path: "/:encryptable_id/file-entries" }, diff --git a/frontend/app/routes/dashboard.js b/frontend/app/routes/dashboard.js index 3094f7e35..a09592dba 100644 --- a/frontend/app/routes/dashboard.js +++ b/frontend/app/routes/dashboard.js @@ -12,22 +12,31 @@ export default class DashboardRoute extends BaseRoute { }; async model(params) { - params["limit"] = 20; const favouriteTeams = await this.getFavouriteTeams(params); - const teams = this.getTeams(params); + const teams = await this.getTeams(params); + const recentCredentials = await this.getRecentCredentials(params); + return RSVP.hash({ favouriteTeams, - teams + teams, + recentCredentials }); } async getFavouriteTeams(params) { + params["limit"] = 20; params["favourite"] = true; return await this.store.query("team", params); } async getTeams(params) { + params["limit"] = 20; params["favourite"] = false; return await this.store.query("team", params); } + + async getRecentCredentials(params) { + params["recent"] = true; + return await this.store.query("encryptable", params); + } } diff --git a/frontend/app/templates/components/dashboard-card.hbs b/frontend/app/templates/components/dashboard-card.hbs index 3ce9f241e..03dbcde89 100644 --- a/frontend/app/templates/components/dashboard-card.hbs +++ b/frontend/app/templates/components/dashboard-card.hbs @@ -9,6 +9,6 @@ {{#if this.team.isPersonalTeam }}

{{this.userService.username}}

{{else}} -

{{this.team.name}}

+

{{this.content.name}}

{{/if}} \ No newline at end of file diff --git a/frontend/app/templates/components/encryptable/show.hbs b/frontend/app/templates/components/encryptable/show.hbs index 7c1772ca3..671aa86ab 100644 --- a/frontend/app/templates/components/encryptable/show.hbs +++ b/frontend/app/templates/components/encryptable/show.hbs @@ -34,59 +34,77 @@

{{t "encryptable/credentials.show.last_update"}}: {{moment-format @encryptable.updatedAt "DD.MM.YYYY hh:mm"}}

- - {{#if @encryptable.description}} -
-
-

{{@encryptable.description}}

-
-
- {{/if}}
- {{#if @encryptable.isOseSecret}} - {{t "encryptable/credentials.show.show_secret"}} -
- - - clip - -
- {{else}} -
- - - clip - -
- {{t "encryptable/credentials.show.show_password"}} -
- - - clip - -
- {{/if}} -
-
-
-

{{t "encryptable/credentials.show.attachments"}}

-
-
- - {{t "encryptable/credentials.show.add_attachment"}} - -
+ + + {{#if @encryptable.description}} +
+
+

{{@encryptable.description}}

+
+
+ {{/if}} +
+ {{#if @encryptable.isOseSecret}} + {{t "encryptable/credentials.show.show_secret"}} +
+ + + clip + +
+ {{else}} +
+ + + clip + +
+ {{t "encryptable/credentials.show.show_password"}} +
+ + + clip + +
+ {{/if}} +
+
+
+

{{t "encryptable/credentials.show.attachments"}}

+
+
+ + {{t "encryptable/credentials.show.add_attachment"}} + +
+
+
+ + + + + + + + {{#each @encryptable.encryptableFiles as |encryptableFile|}} + + {{/each}} + +
{{t "encryptable/credentials.show.file"}}{{t "description"}}{{t "actions"}}
+
+
+ +
+
+ +
+ {{#if this.canLoadMore}} + + {{t "encryptable/credentials.show.load_more"}} + + {{/if}} +
+
- - - - - - - - {{#each @encryptable.encryptableFiles as |encryptableFile|}} - - {{/each}} - -
{{t "encryptable/credentials.show.file"}}{{t "description"}}{{t "actions"}}
diff --git a/frontend/app/templates/components/encryptable/table-row.hbs b/frontend/app/templates/components/encryptable/table-row.hbs new file mode 100644 index 000000000..cd49c1eb7 --- /dev/null +++ b/frontend/app/templates/components/encryptable/table-row.hbs @@ -0,0 +1,5 @@ + + {{t (concat "encryptable/credentials.log." @paperTrailVersion.event)}} + {{@paperTrailVersion.username}} + {{moment-format @paperTrailVersion.createdAt "DD.MM.YYYY hh:mm"}} + diff --git a/frontend/app/templates/components/encryptable/table.hbs b/frontend/app/templates/components/encryptable/table.hbs new file mode 100644 index 000000000..bfe687565 --- /dev/null +++ b/frontend/app/templates/components/encryptable/table.hbs @@ -0,0 +1,14 @@ + + + + + + + + + + {{#each @logs as |paperTrailVersion|}} + + {{/each}} + +
{{t "encryptable/credentials.show.action"}} {{t "user" }} {{t "encryptable/credentials.show.date_time" }}
diff --git a/frontend/app/templates/dashboard.hbs b/frontend/app/templates/dashboard.hbs index 1046fa61a..bfc3e46c8 100644 --- a/frontend/app/templates/dashboard.hbs +++ b/frontend/app/templates/dashboard.hbs @@ -1,20 +1,32 @@
+ {{#if @model.recentCredentials}} +

Recent Credentials

+
+ {{#each this.model.recentCredentials as |encryptable|}} + + + + {{/each}} +
+ {{/if}} + {{#if @model.favouriteTeams}}

Favourites

{{#each this.model.favouriteTeams as |team|}} - + {{/each}}
{{/if}} + {{#if this.model.teams}}

Teams

{{#each this.model.teams as |team|}} - + {{/each}}
diff --git a/frontend/config/environment.js b/frontend/config/environment.js index 23ee2b6da..8709564cf 100644 --- a/frontend/config/environment.js +++ b/frontend/config/environment.js @@ -9,6 +9,7 @@ module.exports = function (environment) { sentryDsn: "", appVersion: undefined, currentUserId: undefined, + encryptable_id: undefined, "changeset-validations": { rawOutput: true }, EmberENV: { FEATURES: { diff --git a/frontend/tests/integration/components/account/show-test.js b/frontend/tests/integration/components/account/show-test.js new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/tests/integration/components/dashboard-card-test.js b/frontend/tests/integration/components/dashboard-card-test.js index 42a1806c1..316b4b05a 100644 --- a/frontend/tests/integration/components/dashboard-card-test.js +++ b/frontend/tests/integration/components/dashboard-card-test.js @@ -20,7 +20,7 @@ module("Integration | Component | dashboard-card", function (hooks) { // Template block usage: await render(hbs` - + `); diff --git a/frontend/tests/integration/components/encryptable/show-test.js b/frontend/tests/integration/components/encryptable/show-test.js index c4ffafd7d..776c16bf7 100644 --- a/frontend/tests/integration/components/encryptable/show-test.js +++ b/frontend/tests/integration/components/encryptable/show-test.js @@ -9,7 +9,32 @@ import { isPresent } from "@ember/utils"; const storeStub = Service.extend({ query(modelName, params) { if (params) { - return []; + return Promise.all([ + { + userId: 1, + username: "alice", + event: "viewed", + createdAt: "2021-06-14 09:23:02.750627", + encryptable: { + get() { + return 1; + }, + id: 1 + } + }, + { + userId: 2, + username: "bob", + event: "update", + createdAt: "2021-06-15 09:23:02.750627", + encryptable: { + get() { + return 1; + }, + id: 1 + } + } + ]); } } }); @@ -21,9 +46,6 @@ module("Integration | Component | encryptable/show", function (hooks) { this.owner.unregister("service:store"); this.owner.register("service:store", storeStub); setLocale("en"); - }); - - test("it renders with data and shows edit buttons credentials encryptable entry", async function (assert) { this.set("encryptable", { id: 1, name: "Ninjas test encryptable", @@ -49,9 +71,37 @@ module("Integration | Component | encryptable/show", function (hooks) { return 1; } } + ], + versions: [ + { + userId: 1, + username: "alice", + event: "viewed", + createdAt: "2021-06-14 09:23:02.750627", + encryptable: { + get() { + return 1; + }, + id: 1 + } + }, + { + userId: 2, + username: "bob", + event: "update", + createdAt: "2021-06-15 09:23:02.750627", + encryptable: { + get() { + return 1; + }, + id: 1 + } + } ] }); + }); + test("it renders with data and shows edit buttons credentials encryptable entry", async function (assert) { await render( hbs`` ); @@ -71,4 +121,14 @@ module("Integration | Component | encryptable/show", function (hooks) { assert.ok(isPresent(deleteButton)); assert.ok(isPresent(editButton)); }); + + test("log and credentials tabs ase present", async function (assert) { + await render( + hbs`` + ); + let credTab = document.getElementById("credentials-tab"); + let logTab = document.getElementById("log-tab"); + assert.ok(isPresent(credTab)); + assert.ok(isPresent(logTab)); + }); }); diff --git a/frontend/translations/ch_be.yml b/frontend/translations/ch_be.yml index 94c3ed9ba..fff3ab3df 100644 --- a/frontend/translations/ch_be.yml +++ b/frontend/translations/ch_be.yml @@ -87,8 +87,14 @@ ch_be: copy_password: Passwort kopierä copy_username: Benutzername kopierä add_attachment: Anhang hinzufüegä + load_more: Meh aazeige + date_time: Datum/Zyt + action: Aktion new: title: Nöi Zuägangsdate + log: + update: Bearbeitet + viewed: Aagluegt admin: title: Admin diff --git a/frontend/translations/de.yml b/frontend/translations/de.yml index 7015ce14b..b54645c83 100644 --- a/frontend/translations/de.yml +++ b/frontend/translations/de.yml @@ -87,8 +87,14 @@ de: copy_password: Passwort kopieren copy_username: Benutzername kopieren add_attachment: Anhang hinzufügen + load_more: Mehr anzeigen + date_time: Datum/Uhrzeit + action: Aktion new: title: Neue Zugangsdaten + log: + update: Bearbeitet + viewed: Angesehen admin: title: Admin diff --git a/frontend/translations/en.yml b/frontend/translations/en.yml index 4b2db4238..420a0cb0e 100644 --- a/frontend/translations/en.yml +++ b/frontend/translations/en.yml @@ -87,8 +87,14 @@ en: copy_password: Copy password copy_username: Copy username add_attachment: Add Attachment + load_more: Load More + date_time: Date/Time + action: Action new: title: New Credentials + log: + update: edited + viewed: viewed admin: title: Admin diff --git a/frontend/translations/fr.yml b/frontend/translations/fr.yml index 535cb46bf..18c974556 100644 --- a/frontend/translations/fr.yml +++ b/frontend/translations/fr.yml @@ -87,8 +87,15 @@ fr: copy_password: Copier le mot de passe copy_username: Copier le nom d'utilisateur add_attachment: Ajouter un attachement + load_more: Charger plus + date_time: Date/Temps + action: Action new: title: Nouveau compte + log: + update: modifié + viewed: regardé + admin: title: Admin diff --git a/spec/controllers/api/encryptables_controller_spec.rb b/spec/controllers/api/encryptables_controller_spec.rb index 986164f1e..87396fc39 100644 --- a/spec/controllers/api/encryptables_controller_spec.rb +++ b/spec/controllers/api/encryptables_controller_spec.rb @@ -13,6 +13,7 @@ let(:attributes) { %w[name cleartext_password cleartext_username] } let!(:ose_secret) { create_ose_secret } let(:credentials1) { encryptables(:credentials1) } + let(:encryptable) { encryptables.first } context 'GET index' do it 'returns encryptable with matching name' do @@ -36,7 +37,7 @@ expect_json_object_includes_keys(credentials1_json_relationships, nested_models) end - it 'returns all enncryptables if empty query param given' do + it 'returns all encryptables if empty query param given' do login_as(:alice) get :index, params: { 'q': '' }, xhr: true @@ -128,6 +129,87 @@ expect(response.status).to eq(403) end + + context 'recent Credentials' do + let!(:recent_credentials) do + folder = teams(:team1).folders.first + private_key = decrypt_private_key(bob) + team_password = folder.team.decrypt_team_password(bob, private_key) + Fabricate.times( + 6, + :credential, + folder: folder, + team_password: team_password + ) + end + + it 'returns most recent credentials' do + login_as(:alice) + + recent_credentials.each do |credential| + log_read_access(alice.id, credential) + end + + get :index, params: { recent: true }, xhr: true + + expect(response.status).to be(200) + expect(data.size).to eq(5) + attributes = data.first['attributes'] + expect(attributes['name']).to eq recent_credentials.last.name + expect(attributes['description']).to eq recent_credentials.last.description + end + + it 'shows most recently used credential first in list' do + login_as(:alice) + + + recent_credentials.each do |credential| + log_read_access(alice.id, credential) + end + log_read_access(alice.id, credentials1) + + get :index, params: { recent: true }, xhr: true + + expect(response.status).to be(200) + expect(data.size).to eq(5) + attributes = data.first['attributes'] + expect(attributes['name']).to eq credentials1.name + expect(attributes['description']).to eq credentials1.description + + end + it 'does not show credentials with no access' do + login_as(:bob) + + recent_credentials1 = recent_credentials.first + log_read_access(alice.id, recent_credentials1) + + get :index, params: { recent: true }, xhr: true + + expect(response.status).to be(200) + expect(data.size).to eq(0) + end + + it 'does not show deleted credentials' do + login_as(:alice) + + recent_credentials1 = recent_credentials.first + log_read_access(alice.id, recent_credentials1) + + get :index, params: { recent: true }, xhr: true + + expect(data.size).to eq(1) + attributes = data.first['attributes'] + expect(attributes['name']).to eq recent_credentials1.name + expect(attributes['description']).to eq recent_credentials1.description + + recent_credentials1.destroy! + + get :index, params: { recent: true }, xhr: true + + expect(response.status).to be(200) + expect(data.size).to eq(0) + end + end end context 'GET show' do @@ -700,6 +782,56 @@ end end + context 'papertrail', versioning: true do + context 'delete' do + it 'deletes log history if encryptable is deleted' do + encryptable2 = encryptables(:credentials2) + + 1000.times do + encryptable.touch + end + + 500.times do + encryptable2.touch + end + + expect(encryptable.versions.size).to eq(1000) + expect(encryptable2.versions.size).to eq(500) + + encryptable.destroy + + expect(encryptable.versions.size).to be(0) + expect(encryptable2.versions.size).to eq(500) + end + end + + context 'touch' do + it 'creates a log entry' do + login_as(:bob) + + encryptable.touch + expect(encryptable.versions.count).to eq(1) + end + + it 'contains user in whodunnit' do + login_as(:bob) + + encryptable.touch + expect(encryptable.versions.last.whodunnit).to eq(bob.id.to_s) + end + end + + context 'show' do + it 'logs show access' do + login_as(:bob) + + get :show, params: { id: encryptable.id } + + expect(encryptable.versions.last.event).to eq('viewed') + end + end + end + private def create_ose_secret @@ -734,4 +866,12 @@ def set_auth_headers request.headers['Authorization-User'] = bob.username request.headers['Authorization-Password'] = Base64.encode64('password') end + + def log_read_access(user_id, credential) + v = credential.paper_trail.save_with_version + v.whodunnit = user_id + v.event = :viewed + v.created_at = DateTime.now + v.save! + end end diff --git a/spec/controllers/api/logs_controller_spec.rb b/spec/controllers/api/logs_controller_spec.rb new file mode 100644 index 000000000..7e58f9fc7 --- /dev/null +++ b/spec/controllers/api/logs_controller_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Api::LogsController do + include ControllerHelpers + + let(:bob) { users(:bob) } + let(:alice) { users(:alice) } + let(:api_user) { bob.api_users.create } + let(:credentials1) { encryptables(:credentials1) } + + context 'GET index' do + it 'returns right amount of logs' do + login_as(:alice) + PaperTrail.request(whodunnit: alice.id) do + credentials1.touch + credentials1.touch + end + + get :index, params: { encryptable_id: credentials1.id } + expect(data.count).to eq 2 + expect(data.first['attributes']['username']).to eq 'alice' + end + + it 'returns sorted results' do + login_as(:bob) + PaperTrail.request(whodunnit: bob.id) do + credentials1.touch + credentials1.touch + end + get :index, params: { encryptable_id: credentials1.id } + expect(data.first['attributes']['created_at']).to be > data.second['attributes']['created_at'] + end + + it 'denies access if not in team' do + login_as(:alice) + + team2 = teams(:team2) + encryptable = team2.folders.first.encryptables.first + + get :index, params: { encryptable_id: encryptable.id } + expect(response.status).to be 403 + end + end +end diff --git a/spec/fabricators/encryptables/credentials_fabricator.rb b/spec/fabricators/encryptables/credentials_fabricator.rb index dd4cd5d7a..8797270d9 100644 --- a/spec/fabricators/encryptables/credentials_fabricator.rb +++ b/spec/fabricators/encryptables/credentials_fabricator.rb @@ -7,7 +7,7 @@ Fabricator(:credential, from: 'Encryptable::Credentials') do transient :team_password - name { Faker::Team.creature } + name { sequence(:name) { |i| Faker::Team.creature + " #{i}" } } cleartext_username { Faker::Internet.user_name } cleartext_password { Faker::Internet.password } before_save do |account, attrs| diff --git a/spec/models/encryptable_spec.rb b/spec/models/encryptable_spec.rb index ad5b6bb09..531ae2a0f 100644 --- a/spec/models/encryptable_spec.rb +++ b/spec/models/encryptable_spec.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true require 'spec_helper' - describe Encryptable do - let(:bob) { users(:bob) } let(:bobs_private_key) { bob.decrypt_private_key('password') } let(:encryptable) { encryptables(:credentials1) } diff --git a/spec/system/dashboard_system_spec.rb b/spec/system/dashboard_system_spec.rb index a65cccb5a..a43404113 100644 --- a/spec/system/dashboard_system_spec.rb +++ b/spec/system/dashboard_system_spec.rb @@ -5,13 +5,17 @@ describe 'Dashboard', type: :system, js: true do include SystemHelpers + let(:credentials) { encryptables(:credentials1) } + let(:team) { teams(:team1) } + + it 'renders dashboard grid' do - login_as_user(:alice) + create_recent_credentials expect(page.current_path).to eq('/dashboard') - expect(page).to have_selector('pzsh-hero', visible: true) expect(page).to have_selector('pzsh-banner', visible: true) + expect(page).to have_text('Recent Credentials', count: 1) expect(page).to have_text('Favourites', count: 1) expect(page).to have_text('Teams', count: 1) @@ -21,14 +25,35 @@ end it 'navigates to team on team card click' do - login_as_user(:alice) + create_recent_credentials + expect(page.current_path).to eq('/dashboard') expect(page).to have_selector('pzsh-hero', visible: true) - all('div.dashboard-grid-card').first.click - expect(page.current_path).to eq('/teams/542659241') + first('div.dashboard-grid-card', text: team.name).click + + expect(page.current_path).to eq("/teams/#{team.id}") expect(page).to have_selector 'div.content' end + + it 'lists recently accessed encryptable' do + create_recent_credentials + + expect(page.current_path).to eq('/dashboard') + + expect(page).to have_selector('pzsh-hero', visible: true) + + first('div.dashboard-grid-card', text: credentials.name).click + + expect(page.current_path).to eq("/encryptables/#{credentials.id}") + expect(page).to have_selector 'div.content' + end + + def create_recent_credentials + login_as_user(:alice) + visit("/encryptables/#{credentials.id}") + visit('/dashboard') + end end diff --git a/spec/system/encryptable_log_system_spec.rb b/spec/system/encryptable_log_system_spec.rb new file mode 100644 index 000000000..755015047 --- /dev/null +++ b/spec/system/encryptable_log_system_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'pry' + +describe 'encryptable log', type: :system, js: true do + include SystemHelpers + + describe 'logs view access' do + it 'contains credentials and logs table in page' do + login_as_user(:bob) + visit("/encryptables/#{encryptables(:credentials1).id}") + expect(page).to have_text('Credentials') + click_link('Logs') + + expect(page).to have_css('table') + within 'table' do + table_rows = all('tr') + + expect(table_rows.length).to eq(2) + top_row = table_rows[1] + + within top_row do + expect(page).to have_text('viewed') + expect(page).to have_text('bob') + end + end + end + + it 'contains log for update' do + login_as_user(:bob) + visit("/encryptables/#{encryptables(:credentials1).id}") + edit_encryptable + click_link('Logs') + + within 'table' do + table_rows = all('tr') + + expect(table_rows.length).to eq(3) + + top_row = table_rows[1] + + within top_row do + expect(page).to have_text('edited') + expect(page).to have_text('bob') + end + end + end + end + + private + + def edit_encryptable + find('#edit_account_button').click + within('form.ember-view[role="form"]', visible: false) do + find("input[name='cleartextUsername']", visible: false).set 'username2' + end + click_button('Save', visible: false) + end +end