├── .rspec ├── lib ├── gdpr_exporter │ └── version.rb └── gdpr_exporter.rb ├── .travis.yml ├── Rakefile ├── bin ├── setup └── console ├── .gitignore ├── Gemfile ├── spec ├── rails │ └── gdpr │ │ └── export_spec.rb └── spec_helper.rb ├── Gemfile.lock ├── LICENSE.txt ├── rails-gdpr-export.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/gdpr_exporter/version.rb: -------------------------------------------------------------------------------- 1 | module Exts 2 | module Gdpr 3 | VERSION = "2.0.4" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.2.0 5 | before_install: gem install bundler -v 1.16.1 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in rails-gdpr-export.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /spec/rails/gdpr/export_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Rails::Gdpr::Export do 2 | it "has a version number" do 3 | expect(Rails::Gdpr::Export::VERSION).not_to be nil 4 | end 5 | 6 | it "does something useful" do 7 | expect(false).to eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | RSpec.configure do |config| 4 | # Enable flags like --only-failures and --next-failure 5 | config.example_status_persistence_file_path = ".rspec_status" 6 | 7 | # Disable RSpec exposing methods globally on `Module` and `main` 8 | config.disable_monkey_patching! 9 | 10 | config.expect_with :rspec do |c| 11 | c.syntax = :expect 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "rails/gdpr/export" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rails-gdpr-export (2.0.2) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | diff-lcs (1.3) 10 | rake (10.5.0) 11 | rspec (3.7.0) 12 | rspec-core (~> 3.7.0) 13 | rspec-expectations (~> 3.7.0) 14 | rspec-mocks (~> 3.7.0) 15 | rspec-core (3.7.1) 16 | rspec-support (~> 3.7.0) 17 | rspec-expectations (3.7.0) 18 | diff-lcs (>= 1.2.0, < 2.0) 19 | rspec-support (~> 3.7.0) 20 | rspec-mocks (3.7.0) 21 | diff-lcs (>= 1.2.0, < 2.0) 22 | rspec-support (~> 3.7.0) 23 | rspec-support (3.7.1) 24 | 25 | PLATFORMS 26 | ruby 27 | 28 | DEPENDENCIES 29 | bundler (~> 1.16) 30 | rails-gdpr-export! 31 | rake (< 11.0) 32 | rspec (~> 3.0) 33 | 34 | BUNDLED WITH 35 | 1.16.1 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Chrislain Razafimahefa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /rails-gdpr-export.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "gdpr_exporter/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "rails-gdpr-export" 8 | spec.version = Exts::Gdpr::VERSION 9 | spec.authors = ["Chrislain Razafimahefa"] 10 | spec.email = ["razafima@gmail.com"] 11 | 12 | spec.summary = %q{A rails gem to export personal data in compliance with GDPR.} 13 | spec.homepage = "https://github.com/epfl-exts/rails-gdpr-export" 14 | spec.license = "MIT" 15 | 16 | # # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 17 | # # to allow pushing to a single host or delete this section to allow pushing to any host. 18 | # if spec.respond_to?(:metadata) 19 | # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" 20 | # else 21 | # raise "RubyGems 2.0 or newer is required to protect against " \ 22 | # "public gem pushes." 23 | # end 24 | 25 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 26 | f.match(%r{^(test|spec|features)/}) 27 | end 28 | spec.bindir = "exe" 29 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 30 | spec.require_paths = ["lib"] 31 | 32 | spec.add_development_dependency "bundler", "~> 1.16" 33 | spec.add_development_dependency "rake", "< 11.0" 34 | spec.add_development_dependency "rspec", "~> 3.0" 35 | end 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rails-gdpr-export 2 | 3 | A gem for exporting user personal data in compliance with GDPR. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'rails-gdpr-export' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install rails-gdpr-export 20 | 21 | ## Usage 22 | 23 | This gem allows you to specify fields that you want to retrieve from your models and to export them in a csv format. 24 | 25 | ### Initialization 26 | 27 | First start by importing `gdpr_exporter` into your application, i.e., add `require "gdpr_exporter"` to your `Application.rb` file. 28 | 29 | ### Data collection 30 | 31 | In order to specify the fields you want to collect you need to call `gdpr_collect`. 32 | The call target is a rails model and its arguments are: 33 | * a set of simple fields, i.e. fields that will be output as is, 34 | * followed by a hash of params: 35 | 36 | ```ruby 37 | { user_id: 38 | renamed_fields: { => } 39 | table_name: 40 | description: 41 | joins: [] } 42 | ``` 43 | 44 | When `joins` is specified, the fields of an association should be defined as ` `. 45 | 46 | For `user_id`, you can also use a string with a chain of associations. For instance, if my model is indirectly linked to user through an `belongs_to: :account` association, you can specify `user_id: "account user_id"`. Currently, the gem support only to levels of nested associations. 47 | 48 | #### Example 49 | 50 | Suppose you have a `User` model, then in its class you should `include Gdprexporter` and call `gdpr_collect`. 51 | And you should do something similar for all other models you are interested in in your application. 52 | 53 | ```ruby 54 | class User 55 | include GdprExporter 56 | 57 | gdpr_collect :email, :last_sign_in_at, :type, :forward_mailbox, 58 | "program title", 59 | { user_id: :id, 60 | renamed_fields: { sign_in_count: "sign in count", 61 | current_sign_in_at: "time of current sign in", 62 | chosen_program_id: "chosen program", 63 | current_sign_in_ip: "current IP address", 64 | last_sign_in_ip: "previously used IP address" }, 65 | joins: [:program] } 66 | end 67 | ``` 68 | 69 | Here from your `User` model, you want to retrieve the values of the fields `email, last_sign_in_at, 70 | type, forward_mailbox`, in addition to the fields `sign_in_count, current_sign_in_at, chosen_program_id, current_sign_in_ip, last_sign_in_ip`. However for the latter you want their csv header to be renamed. And the field representing the user in the `User` model is `id`. 71 | `User` has also an association with `program` and you want to value of its field `title` (hence the presence of `"program title"` in the list of fields). 72 | 73 | ### Data export 74 | 75 | Finally, call `GdprExporter.export()` (from a controller in your application) to return a csv formatted output of all the fields you specified previously. 76 | 77 | 78 | ## Contributing 79 | 80 | Bug reports and pull requests are welcome on GitHub at https://github.com/epfl-exts/rails-gdpr-export. 81 | 82 | ## License 83 | 84 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 85 | -------------------------------------------------------------------------------- /lib/gdpr_exporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "gdpr_exporter/version" 4 | require "csv" 5 | require "set" 6 | 7 | module GdprExporter 8 | # Stores all the classes that have been tagged for gdpr collection 9 | @@klasses = Set[] 10 | 11 | def self.get_klasses 12 | @@klasses 13 | end 14 | 15 | def self.add_klass(klass) 16 | @@klasses << klass 17 | end 18 | 19 | # Collects data through all the tagged models and generates a csv 20 | # formatted output 21 | def self.export(user_id) 22 | CSV.generate(force_quotes: true) do |csv| 23 | get_klasses.each do |klass| 24 | rows = klass.gdpr_query(user_id) 25 | klass.gdpr_export(rows, csv) 26 | end 27 | end 28 | end 29 | 30 | # Instruments the classes implementing this module with instance and class 31 | # methods. 32 | def self.included base 33 | base.send :include, InstanceMethods 34 | base.extend ClassMethods 35 | end 36 | 37 | module InstanceMethods 38 | end 39 | 40 | module ClassMethods 41 | # Declared in each model class with interest in collecting gdpr data. 42 | # Instruments the singleton of those classes so that gdpr data can be 43 | # collected and exported to csv. 44 | # 45 | # Arguments are: 46 | # - set of simple fields: i.e. fields that will be output as is 47 | # - a hash of params: 48 | # {renamed_fields: { => } 49 | # table_name: 50 | # description: 51 | # join: } 52 | def gdpr_collect(*args) 53 | # Params handling 54 | if args.class == Hash # when user provides the hash_params only 55 | simple_fields, hash_params = [[], args] 56 | else 57 | simple_fields, hash_params = [args[0..-2], args.last] 58 | end 59 | 60 | unless hash_params.class == Hash 61 | raise ArgumentError.new("Gdpr fields collection error: last argument must be a hash!") 62 | end 63 | 64 | unless hash_params.key?(:user_id) 65 | raise ArgumentError.new("Gdpr fields collection error: the field aliasing user_id is not declared for '#{self}'!") 66 | end 67 | 68 | # Adds the eigen class to the set of classes eligible for gdpr data collection. 69 | GdprExporter.add_klass(self) 70 | 71 | # Adds instance fields to the eigenclass. They store 72 | # all the fields and info we are interested in. 73 | @gdpr_simple_fields = simple_fields 74 | @gdpr_hash_params = hash_params 75 | # Add readers for the instance vars declared above (for testing reasons) 76 | self.class.send :attr_reader, :gdpr_simple_fields 77 | self.class.send :attr_reader, :gdpr_hash_params 78 | 79 | # Build the csv header and prepare the fields used for querying 80 | # 81 | user_id_field = hash_params[:user_id] 82 | # csv_headers = [:user_id].concat @gdpr_simple_fields # Uncomment if user_id needed 83 | # query_fields = [user_id_field].concat @gdpr_simple_fields # Uncomment if user_id needed 84 | csv_headers = [].concat @gdpr_simple_fields 85 | query_fields = [].concat @gdpr_simple_fields 86 | 87 | if hash_params[:renamed_fields] 88 | csv_headers.concat hash_params[:renamed_fields].values 89 | query_fields.concat hash_params[:renamed_fields].keys 90 | end 91 | 92 | # Adds the class method 'gdpr_query' to the eigenclass. 93 | # It will execute the query. 94 | self.define_singleton_method(:gdpr_query) do |_user_id| 95 | decomposed_user_id_field = user_id_field.to_s.split(" ") 96 | result = case 97 | when decomposed_user_id_field.size == 3 98 | self 99 | .includes(decomposed_user_id_field.first.to_sym => decomposed_user_id_field.second.to_sym) 100 | .where(decomposed_user_id_field.second.pluralize.to_sym => { decomposed_user_id_field.last.to_sym => _user_id }) 101 | when decomposed_user_id_field.size == 2 102 | self 103 | .includes(decomposed_user_id_field.first.to_sym) 104 | .where(decomposed_user_id_field.first.pluralize.to_sym => { decomposed_user_id_field.last.to_sym => _user_id }) 105 | else 106 | self.where(user_id_field => _user_id) 107 | end 108 | 109 | # When there are multiple joins defined, just keep calling 'joins' 110 | # for each association. 111 | if hash_params[:joins] 112 | result = hash_params[:joins].inject(result) do | query, assoc | 113 | query.send(:joins, assoc) 114 | end 115 | end 116 | 117 | result 118 | end 119 | 120 | # Adds a method to export to csv to the eigenclass. 121 | self.define_singleton_method(:gdpr_export) do |rows, csv| 122 | return unless !rows.empty? 123 | 124 | csv << (hash_params[:table_name] ? [hash_params[:table_name]] : 125 | [self.to_s]) 126 | 127 | if hash_params[:desc] 128 | csv << ['Description:', hash_params[:desc]] 129 | end 130 | 131 | csv << csv_headers 132 | rows.each do |r| 133 | csv << query_fields.map do |f| 134 | f_splitted = f.to_s.split(' ') 135 | if (f_splitted.size == 2) 136 | # field f is coming from an assoc, i.e. field has been defined 137 | # as " " in gdpr_collect then to get its value 138 | # do r.. 139 | f_splitted.inject(r) { |result, method| result.send(method) } 140 | elsif (f_splitted.size > 2) 141 | raise ArgumentError.new("Field #{f} is made of more than 2 words!?") 142 | else 143 | # No association involved, simply retrieve the field value. 144 | r.send(f) 145 | end 146 | end 147 | end 148 | csv << [] 149 | end 150 | end 151 | end 152 | end 153 | --------------------------------------------------------------------------------