pg_audit_log 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +9 -0
- data/.rvmrc +1 -0
- data/Gemfile +7 -0
- data/LICENSE +19 -0
- data/README.rdoc +64 -0
- data/Rakefile +6 -0
- data/init.rb +1 -0
- data/lib/generators/pg_audit_log/install_generator.rb +11 -0
- data/lib/generators/pg_audit_log/rspec_generator.rb +12 -0
- data/lib/generators/pg_audit_log/templates/lib/tasks/pg_audit_log.rake +76 -0
- data/lib/generators/pg_audit_log/templates/lib/tasks/pg_audit_log/create_audit_changes.sql +72 -0
- data/lib/generators/pg_audit_log/templates/lib/tasks/pg_audit_log/create_audit_log_table.sql +18 -0
- data/lib/generators/pg_audit_log/templates/lib/tasks/pg_audit_log/uninstall_audit_changes.sql +1 -0
- data/lib/generators/pg_audit_log/templates/spec/models/pg_audit_log_spec.rb +71 -0
- data/lib/generators/pg_audit_log/templates/spec/models/pg_audit_log_spec_helper.rb +105 -0
- data/lib/pg_audit_log.rb +8 -0
- data/lib/pg_audit_log/entry.rb +23 -0
- data/lib/pg_audit_log/extensions/postgresql_adapter.rb +54 -0
- data/lib/pg_audit_log/version.rb +3 -0
- data/pg_audit_log.gemspec +23 -0
- data/spec/configuration_spec.rb +18 -0
- data/spec/connection_adapter_spec.rb +13 -0
- data/spec/model_spec.rb +24 -0
- data/spec/pg_audit_log_spec.rb +185 -0
- data/spec/spec_helper.rb +43 -0
- metadata +117 -0
data/.gitignore
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm 1.9.2-p136@pg_audit_log
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2009-11 Case Commons, LLC
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
11
|
+
all copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
|
+
THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
= pg_audit_log
|
2
|
+
|
3
|
+
* http://github.com/casecommons/pg_audit_log/
|
4
|
+
|
5
|
+
== DESCRIPTION
|
6
|
+
|
7
|
+
PostgreSQL only database-level audit logging of all databases changes using a completely transparent stored procedure and triggers.
|
8
|
+
Comes with specs for your project and a rake task to generate the reverse SQL to undo changes logged}
|
9
|
+
|
10
|
+
All SQL INSERTs, UPDATEs, and DELETEs will be captured. Record columns that do not change do not generate an audit log entry.
|
11
|
+
|
12
|
+
== INSTALL
|
13
|
+
|
14
|
+
=== Enable custom variables in your postgresql instance
|
15
|
+
|
16
|
+
To enable auditing you need to set a variable in your postgresql.conf file:
|
17
|
+
|
18
|
+
For macports: /opt/local/var/db/postgresql84/defaultdb/postgresql.conf
|
19
|
+
For homebrew: /usr/local/var/postgres/postgresql.conf
|
20
|
+
For linux: it varies from distro to distro.
|
21
|
+
|
22
|
+
At the bottom of the file add the following:
|
23
|
+
|
24
|
+
custom_variable_classes = 'audit'
|
25
|
+
|
26
|
+
Then restart your PostgreSQL server
|
27
|
+
|
28
|
+
=== Rails 3
|
29
|
+
|
30
|
+
$ rails generate pg_audit_log:install
|
31
|
+
|
32
|
+
=== Installing the PostgreSQL function and triggers for your project
|
33
|
+
|
34
|
+
$ rake pg_audit_log:install
|
35
|
+
|
36
|
+
== Using on your project
|
37
|
+
|
38
|
+
The PgAuditLog::Entry ActiveRecord model represents a single entry in the audit log table. Each entry represents a single change to a single field of a record in a table. So if you change 3 columns of a record, that will generate 3 corresponding PgAuditLog::Entry records.
|
39
|
+
|
40
|
+
=== Migrations
|
41
|
+
|
42
|
+
TODO
|
43
|
+
|
44
|
+
=== schema.rb and development_structure.sql
|
45
|
+
|
46
|
+
Since schema.rb cannot represent TRIGGERs or FUNCTIONs you will need to set your environment to generate SQL instead of ruby for your database schema and structure. In your application environment put the following:
|
47
|
+
|
48
|
+
config.active_record.schema_format = :sql
|
49
|
+
|
50
|
+
And you can generate this sql using:
|
51
|
+
|
52
|
+
$ rake db:structure:dump
|
53
|
+
|
54
|
+
=== Uninstalling
|
55
|
+
|
56
|
+
$ rake pg_audit_log:uninstall
|
57
|
+
|
58
|
+
== REQUIREMENTS
|
59
|
+
|
60
|
+
* ActiveRecord
|
61
|
+
|
62
|
+
== LICENSE
|
63
|
+
|
64
|
+
MIT
|
data/Rakefile
ADDED
data/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "pg_audit_log"
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module PgAuditLog
|
2
|
+
module Generators
|
3
|
+
class RSpecGenerator < Rails::Generators::Base
|
4
|
+
source_root File.expand_path('../templates', __FILE__)
|
5
|
+
|
6
|
+
def install
|
7
|
+
directory "spec/models"
|
8
|
+
copy_file "spec/models/pg_audit_log_spec.rb", "spec/models/pg_audit_log_spec.rb"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
namespace :pg_audit_log do
|
2
|
+
IGNORED_TABLES = ["plugin_schema_migrations", "sessions", "schema_migrations"]
|
3
|
+
|
4
|
+
desc "Install audit_log triggers on all tables"
|
5
|
+
task :install => :environment do
|
6
|
+
unless all_tables.include?(PgAuditLog::Entry.table_name)
|
7
|
+
puts "Creating #{PgAuditLog::Entry.table_name} table..."
|
8
|
+
sql = File.read(File.join(sql_path, "create_audit_log_table.sql"))
|
9
|
+
connection.execute_without_auditing(sql)
|
10
|
+
end
|
11
|
+
puts "Installing audit_changes() function..."
|
12
|
+
sql = File.read(File.join(sql_path, "create_audit_changes.sql"))
|
13
|
+
connection.execute_without_auditing(sql)
|
14
|
+
|
15
|
+
puts "Installing all audit log triggers... "
|
16
|
+
run_on(audit_log_tables) do |table|
|
17
|
+
<<-SQL
|
18
|
+
CREATE TRIGGER audit_#{table}
|
19
|
+
AFTER INSERT OR UPDATE OR DELETE
|
20
|
+
ON #{table}
|
21
|
+
FOR EACH ROW
|
22
|
+
EXECUTE PROCEDURE audit_changes()
|
23
|
+
SQL
|
24
|
+
end
|
25
|
+
puts "Exporting development_structure.sql..."
|
26
|
+
Rake::Task["db:structure:dump"].reenable
|
27
|
+
Rake::Task["db:structure:dump"].invoke
|
28
|
+
end
|
29
|
+
|
30
|
+
desc "Uninstall audit log triggers on all tables"
|
31
|
+
task :uninstall => :environment do
|
32
|
+
puts "Dropping all audit_log triggers... "
|
33
|
+
run_on(audit_log_tables) do |table|
|
34
|
+
"DROP TRIGGER audit_#{table} ON #{table};"
|
35
|
+
end
|
36
|
+
puts "Uninstalling audit_changes() function..."
|
37
|
+
sql = File.read(File.join(sql_path, "uninstall_audit_changes.sql"))
|
38
|
+
connection.execute_without_auditing(sql)
|
39
|
+
|
40
|
+
puts "Exporting development_structure.sql..."
|
41
|
+
Rake::Task["db:structure:dump"].reenable
|
42
|
+
Rake::Task["db:structure:dump"].invoke
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def connection
|
48
|
+
ActiveRecord::Base.connection
|
49
|
+
end
|
50
|
+
|
51
|
+
def all_tables
|
52
|
+
connection.tables
|
53
|
+
end
|
54
|
+
|
55
|
+
def audit_log_tables
|
56
|
+
all_tables - (IGNORED_TABLES + [PgAuditLog::Entry.table_name])
|
57
|
+
end
|
58
|
+
|
59
|
+
def sql_path
|
60
|
+
Rails.root.join("lib/tasks/pg_audit_log")
|
61
|
+
end
|
62
|
+
|
63
|
+
def run_on(tables, &block)
|
64
|
+
tables.sort.each do |table|
|
65
|
+
puts "* #{table}"
|
66
|
+
sql = yield(table)
|
67
|
+
begin
|
68
|
+
connection.execute_without_auditing(sql)
|
69
|
+
rescue => e
|
70
|
+
puts e.to_s
|
71
|
+
connection.reconnect!
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
CREATE OR REPLACE PROCEDURAL LANGUAGE plpgsql;
|
2
|
+
CREATE OR REPLACE FUNCTION audit_changes() RETURNS trigger
|
3
|
+
LANGUAGE plpgsql
|
4
|
+
AS $_$
|
5
|
+
DECLARE
|
6
|
+
col information_schema.columns %ROWTYPE;
|
7
|
+
new_value text;
|
8
|
+
old_value text;
|
9
|
+
primary_key_column varchar;
|
10
|
+
primary_key_value varchar;
|
11
|
+
user_identifier integer;
|
12
|
+
unique_name varchar;
|
13
|
+
column_name varchar;
|
14
|
+
BEGIN
|
15
|
+
FOR col IN SELECT * FROM information_schema.columns WHERE table_name = TG_RELNAME LOOP
|
16
|
+
new_value := NULL;
|
17
|
+
old_value := NULL;
|
18
|
+
primary_key_column := NULL;
|
19
|
+
primary_key_value := NULL;
|
20
|
+
user_identifier := current_setting('audit.user_id');
|
21
|
+
unique_name := current_setting('audit.user_unique_name');
|
22
|
+
column_name := col.column_name;
|
23
|
+
|
24
|
+
EXECUTE 'SELECT pg_attribute.attname
|
25
|
+
FROM pg_index, pg_class, pg_attribute
|
26
|
+
WHERE pg_class.oid = $1::regclass
|
27
|
+
AND indrelid = pg_class.oid
|
28
|
+
AND pg_attribute.attrelid = pg_class.oid
|
29
|
+
AND pg_attribute.attnum = any(pg_index.indkey)
|
30
|
+
AND indisprimary'
|
31
|
+
INTO primary_key_column USING TG_RELNAME;
|
32
|
+
|
33
|
+
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
|
34
|
+
EXECUTE 'SELECT CAST($1 . '|| column_name ||' AS TEXT)' INTO new_value USING NEW;
|
35
|
+
IF primary_key_column IS NOT NULL THEN
|
36
|
+
EXECUTE 'SELECT CAST($1 . '|| primary_key_column ||' AS VARCHAR)' INTO primary_key_value USING NEW;
|
37
|
+
END IF;
|
38
|
+
END IF;
|
39
|
+
IF TG_OP = 'DELETE' OR TG_OP = 'UPDATE' THEN
|
40
|
+
EXECUTE 'SELECT CAST($1 . '|| column_name ||' AS TEXT)' INTO old_value USING OLD;
|
41
|
+
EXECUTE 'SELECT CAST($1 . '|| primary_key_column ||' AS VARCHAR)' INTO primary_key_value USING OLD;
|
42
|
+
END IF;
|
43
|
+
|
44
|
+
IF TG_RELNAME = 'users' AND column_name = 'last_accessed_at' THEN
|
45
|
+
NULL;
|
46
|
+
ELSE
|
47
|
+
IF TG_OP != 'UPDATE' OR new_value != old_value OR (TG_OP = 'UPDATE' AND ( (new_value IS NULL AND old_value IS NOT NULL) OR (new_value IS NOT NULL AND old_value IS NULL))) THEN
|
48
|
+
INSERT INTO audit_log("operation",
|
49
|
+
"table_name",
|
50
|
+
"primary_key",
|
51
|
+
"field_name",
|
52
|
+
"field_value_old",
|
53
|
+
"field_value_new",
|
54
|
+
"user_id",
|
55
|
+
"user_unique_name",
|
56
|
+
"occurred_at"
|
57
|
+
)
|
58
|
+
VALUES(TG_OP,
|
59
|
+
TG_RELNAME,
|
60
|
+
primary_key_value,
|
61
|
+
column_name,
|
62
|
+
old_value,
|
63
|
+
new_value,
|
64
|
+
user_identifier,
|
65
|
+
unique_name,
|
66
|
+
current_timestamp);
|
67
|
+
END IF;
|
68
|
+
END IF;
|
69
|
+
END LOOP;
|
70
|
+
RETURN NULL;
|
71
|
+
END
|
72
|
+
$_$;
|
@@ -0,0 +1,18 @@
|
|
1
|
+
CREATE SEQUENCE audit_log_id_seq
|
2
|
+
START WITH 1
|
3
|
+
INCREMENT BY 1;
|
4
|
+
|
5
|
+
CREATE TABLE audit_log (
|
6
|
+
id integer PRIMARY KEY DEFAULT nextval('audit_log_id_seq'),
|
7
|
+
user_id integer,
|
8
|
+
user_unique_name character varying(255),
|
9
|
+
operation character varying(255),
|
10
|
+
table_name character varying(255),
|
11
|
+
field_name character varying(255),
|
12
|
+
field_value_new text,
|
13
|
+
field_value_old text,
|
14
|
+
occurred_at timestamp without time zone,
|
15
|
+
primary_key character varying(255)
|
16
|
+
);
|
17
|
+
|
18
|
+
ALTER SEQUENCE audit_log_id_seq OWNED BY audit_log.id;
|
@@ -0,0 +1 @@
|
|
1
|
+
DROP FUNCTION audit_changes();
|
@@ -0,0 +1,71 @@
|
|
1
|
+
require File.join(File.dirname(__FILE), "pg_audit_log_spec_helper")
|
2
|
+
|
3
|
+
describe PgAuditLog do
|
4
|
+
include PgAuditLogSpecHelper
|
5
|
+
|
6
|
+
describe "logging for all models" do
|
7
|
+
get_all_klasses.each do |klass|
|
8
|
+
it "logs inserts for #{klass} (#{klass.table_name})" do
|
9
|
+
object, columns = create_object_for_klass(klass)
|
10
|
+
|
11
|
+
log_entries = PgAuditLog::Entry.find :all, :conditions => {:table_name => klass.table_name, :operation => "INSERT"}
|
12
|
+
columns.each do |column|
|
13
|
+
selected_entries = log_entries.select {|entry| entry.field_name == column.name}
|
14
|
+
|
15
|
+
selected_entries.size.should >= 1
|
16
|
+
|
17
|
+
entry = selected_entries.detect {|entry| entry.primary_key == object.id.to_s}
|
18
|
+
entry.field_value_old.should be_nil
|
19
|
+
entry.field_value_new.should == get_data(column, true).to_s
|
20
|
+
end
|
21
|
+
|
22
|
+
log_entries.map(&:field_name).uniq.should =~ columns.map(&:name)
|
23
|
+
end
|
24
|
+
|
25
|
+
it "logs updates for #{klass}" do
|
26
|
+
klass.delete_all
|
27
|
+
klass.count.should == 0
|
28
|
+
object, columns = create_object_for_klass(klass, [klass.primary_key, "type", "updated_at", "deleted_at"])
|
29
|
+
object.reload # needed for Rails bug around create_without_callbacks + boolean value going from true to false
|
30
|
+
columns.each do |column|
|
31
|
+
object.send("#{column.name}=", get_diff_data(column))
|
32
|
+
end
|
33
|
+
|
34
|
+
object.send(:update_without_callbacks)
|
35
|
+
log_entries = PgAuditLog::Entry.find :all, :conditions => ["table_name = ? AND operation = ? AND field_name != ?", klass.table_name, "UPDATE", "updated_at"]
|
36
|
+
columns.each do |column|
|
37
|
+
selected_entries = log_entries.select {|entry| entry.field_name == column.name}
|
38
|
+
|
39
|
+
selected_entries.size.should equal(1), "Expected to find entry for #{column.name} (#{column.type}) but found none!"
|
40
|
+
|
41
|
+
entry = selected_entries.first
|
42
|
+
entry.field_value_old.should == get_data(column, true).to_s
|
43
|
+
entry.field_value_new.should == get_diff_data(column, true).to_s
|
44
|
+
end
|
45
|
+
|
46
|
+
log_entries.map(&:field_name).should =~ columns.map(&:name)
|
47
|
+
end
|
48
|
+
|
49
|
+
it "logs deletes for #{klass}" do
|
50
|
+
object, columns = create_object_for_klass(klass)
|
51
|
+
|
52
|
+
ActiveRecord::Base.connection.execute("DELETE FROM #{klass.quoted_table_name} WHERE #{klass.primary_key} = #{object.id}")
|
53
|
+
|
54
|
+
log_entries = PgAuditLog::Entry.find :all, :conditions => {:table_name => klass.table_name, :operation => "DELETE"}
|
55
|
+
columns.each do |column|
|
56
|
+
next if column.name == "update_at"
|
57
|
+
selected_entries = log_entries.select {|entry| entry.field_name == column.name}
|
58
|
+
selected_entries.size.should equal(1), "Expected to find entry for #{column.name} (#{column.type}) but found none!"
|
59
|
+
|
60
|
+
entry = selected_entries.first
|
61
|
+
entry.field_value_old.should == get_data(column, true).to_s
|
62
|
+
entry.field_value_new.should be_nil
|
63
|
+
end
|
64
|
+
|
65
|
+
log_entries.map(&:field_name).should =~ columns.map(&:name)
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
module PgAuditLogSpecHelper
|
2
|
+
EXCLUDED_CLASSES = [ActiveRecord::Base, PgAuditLog::Entry]
|
3
|
+
EXCLUDED_COLUMNS = ["last_accessed_at"]
|
4
|
+
|
5
|
+
def included(base)
|
6
|
+
base.include(InstanceMethods)
|
7
|
+
base.extend(ClassMethods)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def self.find_all_constants(constant)
|
12
|
+
constant.constants.find_all.select do |constant|
|
13
|
+
klass = constant.is_a?(String) ? constant.constantize : constant
|
14
|
+
klass.respond_to?(:ancestors) &&
|
15
|
+
klass.ancestors.include?(ActiveRecord::Base) &&
|
16
|
+
!klass.abstract_class? &&
|
17
|
+
!EXCLUDED_CLASSES.include?(klass)
|
18
|
+
end.sort
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.get_all_klasses
|
22
|
+
return @all_klasses if @all_klasses
|
23
|
+
@all_klasses ||= find_all_constants(Module).map(&:constantize)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
module InstanceMethods
|
28
|
+
def get_data(column, format_without_timezone = false)
|
29
|
+
case column.type
|
30
|
+
when :boolean
|
31
|
+
true
|
32
|
+
when :date
|
33
|
+
Date.parse("1/1/2000")
|
34
|
+
when :datetime
|
35
|
+
if format_without_timezone
|
36
|
+
DateTime.parse("1/1/2000 1pm").utc.strftime("%Y-%m-%d %H:%M:%S")
|
37
|
+
else
|
38
|
+
DateTime.parse("1/1/2000 1pm").utc.to_s
|
39
|
+
end
|
40
|
+
when :integer
|
41
|
+
7
|
42
|
+
when :decimal
|
43
|
+
"7.1234567891"
|
44
|
+
when :float
|
45
|
+
7.1234
|
46
|
+
when :string
|
47
|
+
if column.name == "type"
|
48
|
+
"Object"
|
49
|
+
else
|
50
|
+
"Happy"
|
51
|
+
end
|
52
|
+
|
53
|
+
when :text
|
54
|
+
"Happy text"
|
55
|
+
else
|
56
|
+
raise "I don't know how to make data for '#{column.type}'!"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def get_diff_data(column, format_without_timezone = false)
|
61
|
+
case column.type
|
62
|
+
when :boolean
|
63
|
+
false
|
64
|
+
when :date
|
65
|
+
Date.parse("12/1/2000")
|
66
|
+
when :datetime
|
67
|
+
if format_without_timezone
|
68
|
+
DateTime.parse("12/1/2000 1pm").utc.strftime("%Y-%m-%d %H:%M:%S")
|
69
|
+
else
|
70
|
+
DateTime.parse("12/1/2000 1pm").utc.to_s
|
71
|
+
end
|
72
|
+
when :integer
|
73
|
+
9
|
74
|
+
when :decimal
|
75
|
+
"9.1234567891"
|
76
|
+
when :float
|
77
|
+
9.1234
|
78
|
+
when :string
|
79
|
+
if column.name == "type"
|
80
|
+
"Object"
|
81
|
+
else
|
82
|
+
"Sad"
|
83
|
+
end
|
84
|
+
when :text
|
85
|
+
"Sad text"
|
86
|
+
else
|
87
|
+
raise "I don't know how to make data for '#{column.type}'!"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def create_object_for_klass(klass, exclude_columns = [])
|
92
|
+
exclude_columns = exclude_columns | EXCLUDED_COLUMNS
|
93
|
+
|
94
|
+
object = klass.new
|
95
|
+
columns = klass.columns.reject {|column| exclude_columns.include? column.name }
|
96
|
+
columns.each do |column|
|
97
|
+
object.send("#{column.name}=", get_data(column))
|
98
|
+
end
|
99
|
+
|
100
|
+
object.send(:create_without_callbacks)
|
101
|
+
[object, columns]
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
data/lib/pg_audit_log.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
class PgAuditLog::Entry < ActiveRecord::Base
|
2
|
+
class CannotDeleteError < StandardError
|
3
|
+
def message
|
4
|
+
"Audit Logs cannot be deleted!"
|
5
|
+
end
|
6
|
+
end
|
7
|
+
set_table_name :audit_log
|
8
|
+
|
9
|
+
before_destroy do
|
10
|
+
raise CannotDeleteError
|
11
|
+
end
|
12
|
+
|
13
|
+
class << self
|
14
|
+
def delete(id)
|
15
|
+
raise CannotDeleteError
|
16
|
+
end
|
17
|
+
|
18
|
+
def delete_all
|
19
|
+
raise CannotDeleteError
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require "active_record/connection_adapters/postgresql_adapter"
|
2
|
+
|
3
|
+
# Did not want to reopen the class but sending an include seemingly is not working.
|
4
|
+
class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
5
|
+
def execute_with_auditing(sql, name = nil)
|
6
|
+
current_user = Thread.current[:current_user]
|
7
|
+
user_unique_name = current_user.try(:unique_name) || "UNKNOWN"
|
8
|
+
|
9
|
+
log_user_id = %[SET audit.user_id = #{current_user.try(:id) || "-1"}]
|
10
|
+
log_user_unique_name = %[SET audit.user_unique_name = "#{user_unique_name}"]
|
11
|
+
|
12
|
+
log([log_user_id, log_user_unique_name, sql].join("; "), name) do
|
13
|
+
if @async
|
14
|
+
@connection.async_exec(log_user_id)
|
15
|
+
@connection.async_exec(log_user_unique_name)
|
16
|
+
@connection.async_exec(sql)
|
17
|
+
else
|
18
|
+
@connection.exec(log_user_id)
|
19
|
+
@connection.exec(log_user_unique_name)
|
20
|
+
@connection.exec(sql)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
alias_method_chain :execute, :auditing
|
26
|
+
|
27
|
+
def begin_db_transaction
|
28
|
+
execute_without_auditing "BEGIN"
|
29
|
+
end
|
30
|
+
|
31
|
+
# Commits a transaction.
|
32
|
+
def commit_db_transaction
|
33
|
+
execute_without_auditing "COMMIT"
|
34
|
+
end
|
35
|
+
|
36
|
+
# Aborts a transaction.
|
37
|
+
def rollback_db_transaction
|
38
|
+
execute_without_auditing "ROLLBACK"
|
39
|
+
end
|
40
|
+
|
41
|
+
def create_savepoint
|
42
|
+
execute_without_auditing("SAVEPOINT #{current_savepoint_name}")
|
43
|
+
end
|
44
|
+
|
45
|
+
def rollback_to_savepoint
|
46
|
+
execute_without_auditing("ROLLBACK TO SAVEPOINT #{current_savepoint_name}")
|
47
|
+
end
|
48
|
+
|
49
|
+
def release_savepoint
|
50
|
+
execute_without_auditing("RELEASE SAVEPOINT #{current_savepoint_name}")
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "pg_audit_log/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "pg_audit_log"
|
7
|
+
s.version = PgAuditLog::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Case Commons, LLC"]
|
10
|
+
s.email = ["casecommons-dev@googlegroups.com"]
|
11
|
+
s.homepage = "https://github.com/Casecommons/pg_audit_log"
|
12
|
+
s.summary = %q{postgresql only database-level audit logging of all databases changes}
|
13
|
+
s.description = %q{A completely transparent audit logging component for your application using a stored procedure and triggers. Comes with specs for your project and a rake task to generate the reverse SQL to undo changes logged}
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
|
20
|
+
s.add_dependency("rails", ">=2.3")
|
21
|
+
s.add_development_dependency('rspec')
|
22
|
+
s.add_development_dependency('with_model')
|
23
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe "the PostgreSQL database" do
|
4
|
+
after do
|
5
|
+
ActiveRecord::Base.connection.reconnect!
|
6
|
+
end
|
7
|
+
|
8
|
+
it "allows custom class variables for audit" do
|
9
|
+
lambda {
|
10
|
+
ActiveRecord::Base.connection.execute('SET audit.test = 1')
|
11
|
+
}.should_not raise_error(ActiveRecord::StatementInvalid), "Your postgres is not configured for auditing. See README.rdoc"
|
12
|
+
end
|
13
|
+
|
14
|
+
it "has an audit log table" do
|
15
|
+
ActiveRecord::Base.connection.table_exists?("audit_log").should be_true
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe "the connection adapter" do
|
4
|
+
subject { ActiveRecord::Base.connection }
|
5
|
+
|
6
|
+
it { should respond_to(:execute_without_auditing) }
|
7
|
+
|
8
|
+
it "should work for both execute and execute_without_auditing" do
|
9
|
+
subject.execute("SELECT 1 = 1")
|
10
|
+
subject.execute_without_auditing("SELECT 1 = 1")
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
data/spec/model_spec.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe PgAuditLog::Entry do
|
4
|
+
|
5
|
+
subject { PgAuditLog::Entry.create! }
|
6
|
+
|
7
|
+
describe ".delete" do
|
8
|
+
it "blows up because deleting audit logs is not allowed" do
|
9
|
+
proc { PgAuditLog::Entry.delete(subject.id) }.should raise_error(PgAuditLog::Entry::CannotDeleteError)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
describe ".delete_all" do
|
14
|
+
it "blows up because deleting audit logs is not allowed" do
|
15
|
+
proc { PgAuditLog::Entry.delete_all }.should raise_error(PgAuditLog::Entry::CannotDeleteError)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe "#destroy" do
|
20
|
+
it "blows up because deleting audit logs is not allowed" do
|
21
|
+
proc { subject.destroy }.should raise_error(PgAuditLog::Entry::CannotDeleteError)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,185 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe PgAuditLog do
|
4
|
+
describe "a model that is audited" do
|
5
|
+
with_model :audited_model do
|
6
|
+
table do |t|
|
7
|
+
t.string :str
|
8
|
+
t.text :txt
|
9
|
+
t.integer :int
|
10
|
+
t.date :date
|
11
|
+
t.datetime :dt
|
12
|
+
t.boolean :bool
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
before do
|
17
|
+
AuditedModel.connection.execute(<<-SQL)
|
18
|
+
CREATE TRIGGER audit_audited_models
|
19
|
+
AFTER INSERT OR UPDATE OR DELETE
|
20
|
+
ON #{AuditedModel.quoted_table_name}
|
21
|
+
FOR EACH ROW
|
22
|
+
EXECUTE PROCEDURE audit_changes()
|
23
|
+
SQL
|
24
|
+
end
|
25
|
+
|
26
|
+
after do
|
27
|
+
AuditedModel.connection.execute("DROP TRIGGER audit_audited_models ON #{AuditedModel.quoted_table_name};")
|
28
|
+
PgAuditLog::Entry.connection.execute("TRUNCATE #{PgAuditLog::Entry.quoted_table_name}")
|
29
|
+
end
|
30
|
+
|
31
|
+
let(:attributes) { { :str => "foo", :txt => "bar", :int => 5, :date => Date.today, :dt => Time.now.midnight } }
|
32
|
+
|
33
|
+
context "on create" do
|
34
|
+
|
35
|
+
before do
|
36
|
+
AuditedModel.create!(attributes)
|
37
|
+
end
|
38
|
+
|
39
|
+
describe "the audit log record" do
|
40
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "str" }) }
|
41
|
+
|
42
|
+
it { should be }
|
43
|
+
its(:occurred_at) { should be }
|
44
|
+
its(:table_name) { should == AuditedModel.table_name }
|
45
|
+
its(:field_name) { should == "str" }
|
46
|
+
its(:primary_key) { should == AuditedModel.last.id.to_s }
|
47
|
+
its(:operation) { should == "INSERT" }
|
48
|
+
|
49
|
+
context "when a user is present" do
|
50
|
+
before do
|
51
|
+
Thread.current[:current_user] = stub(:id => 1, :unique_name => "my current user")
|
52
|
+
AuditedModel.create!
|
53
|
+
end
|
54
|
+
|
55
|
+
after { Thread.current[:current_user] = nil }
|
56
|
+
|
57
|
+
its(:user_id) { should == 1 }
|
58
|
+
its(:user_unique_name) { should == "my current user" }
|
59
|
+
end
|
60
|
+
|
61
|
+
context "when no user is present" do
|
62
|
+
its(:user_id) { should == -1 }
|
63
|
+
its(:user_unique_name) { should == "UNKNOWN" }
|
64
|
+
end
|
65
|
+
|
66
|
+
it "captures all new values for all fields" do
|
67
|
+
attributes.each do |field_name, value|
|
68
|
+
if field_name == :dt
|
69
|
+
PgAuditLog::Entry.last(:conditions => { :field_name => field_name }).field_value_new.should == value.strftime("%Y-%m-%d %H:%M:%S")
|
70
|
+
else
|
71
|
+
PgAuditLog::Entry.last(:conditions => { :field_name => field_name }).field_value_new.should == value.to_s
|
72
|
+
end
|
73
|
+
PgAuditLog::Entry.last(:conditions => { :field_name => field_name }).field_value_old.should be_nil
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
context "on update" do
|
81
|
+
before do
|
82
|
+
@model = AuditedModel.create!(attributes)
|
83
|
+
end
|
84
|
+
|
85
|
+
context "when going from a value to a another value" do
|
86
|
+
before { @model.update_attributes!(:str => "bar") }
|
87
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "str" }) }
|
88
|
+
|
89
|
+
its(:operation) { should == "UPDATE" }
|
90
|
+
its(:field_value_new) { should == "bar" }
|
91
|
+
its(:field_value_old) { should == "foo" }
|
92
|
+
end
|
93
|
+
|
94
|
+
context "when going from nil to a value" do
|
95
|
+
let(:attributes) { {:txt => nil} }
|
96
|
+
before { @model.update_attributes!(:txt => "baz") }
|
97
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "txt" }) }
|
98
|
+
|
99
|
+
its(:field_value_new) { should == "baz" }
|
100
|
+
its(:field_value_old) { should be_nil }
|
101
|
+
end
|
102
|
+
|
103
|
+
context "when going from a value to nil" do
|
104
|
+
before { @model.update_attributes!(:str => nil) }
|
105
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "str" }) }
|
106
|
+
|
107
|
+
its(:field_value_new) { should be_nil }
|
108
|
+
its(:field_value_old) { should == "foo" }
|
109
|
+
end
|
110
|
+
|
111
|
+
context "when the value does not change" do
|
112
|
+
before { @model.update_attributes!(:str => "foo") }
|
113
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "str", :operation => "UPDATE" }) }
|
114
|
+
|
115
|
+
it { should_not be }
|
116
|
+
end
|
117
|
+
|
118
|
+
context "when the value is nil and does not change" do
|
119
|
+
let(:attributes) { {:txt => nil} }
|
120
|
+
before { @model.update_attributes!(:txt => nil) }
|
121
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "txt", :operation => "UPDATE" }) }
|
122
|
+
|
123
|
+
it { should_not be }
|
124
|
+
end
|
125
|
+
|
126
|
+
context "when the value is a boolean" do
|
127
|
+
|
128
|
+
context "going from nil -> true" do
|
129
|
+
before { @model.update_attributes!(:bool => true) }
|
130
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "bool", :operation => "UPDATE" }) }
|
131
|
+
|
132
|
+
its(:field_value_new) { should == "true" }
|
133
|
+
its(:field_value_old) { should be_nil }
|
134
|
+
end
|
135
|
+
|
136
|
+
context "going from false -> true" do
|
137
|
+
let(:attributes) { {:bool => false} }
|
138
|
+
before do
|
139
|
+
@model.update_attributes!(:bool => true)
|
140
|
+
end
|
141
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "bool", :operation => "UPDATE" }) }
|
142
|
+
|
143
|
+
its(:field_value_new) { should == "true" }
|
144
|
+
its(:field_value_old) { should == "false" }
|
145
|
+
end
|
146
|
+
|
147
|
+
context "going from true -> false" do
|
148
|
+
let(:attributes) { {:bool => true} }
|
149
|
+
|
150
|
+
before do
|
151
|
+
@model.update_attributes!(:bool => false)
|
152
|
+
end
|
153
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "bool", :operation => "UPDATE" }) }
|
154
|
+
|
155
|
+
its(:field_value_new) { should == "false" }
|
156
|
+
its(:field_value_old) { should == "true" }
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
context "on delete" do
|
163
|
+
before do
|
164
|
+
@model = AuditedModel.create!(attributes)
|
165
|
+
@model.delete
|
166
|
+
end
|
167
|
+
|
168
|
+
subject { PgAuditLog::Entry.last(:conditions => { :field_name => "str" }) }
|
169
|
+
|
170
|
+
its(:operation) { should == "DELETE" }
|
171
|
+
|
172
|
+
it "captures all new values for all fields" do
|
173
|
+
attributes.each do |field_name, value|
|
174
|
+
if field_name == :dt
|
175
|
+
PgAuditLog::Entry.last(:conditions => { :field_name => field_name }).field_value_old.should == value.strftime("%Y-%m-%d %H:%M:%S")
|
176
|
+
else
|
177
|
+
PgAuditLog::Entry.last(:conditions => { :field_name => field_name }).field_value_old.should == value.to_s
|
178
|
+
end
|
179
|
+
PgAuditLog::Entry.last(:conditions => { :field_name => field_name }).field_value_new.should be_nil
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require "bundler/setup"
|
2
|
+
require "pg_audit_log"
|
3
|
+
require "with_model"
|
4
|
+
|
5
|
+
connection = nil
|
6
|
+
begin
|
7
|
+
ActiveRecord::Base.establish_connection(:adapter => 'postgresql',
|
8
|
+
:database => 'pg_audit_log_test',
|
9
|
+
:min_messages => 'warning')
|
10
|
+
connection = ActiveRecord::Base.connection
|
11
|
+
connection.execute("SELECT 1")
|
12
|
+
rescue PGError => e
|
13
|
+
puts "-" * 80
|
14
|
+
puts "Unable to connect to database. Please run:"
|
15
|
+
puts
|
16
|
+
puts " createdb pg_audit_log_test"
|
17
|
+
puts "-" * 80
|
18
|
+
raise e
|
19
|
+
end
|
20
|
+
|
21
|
+
connection.execute("DROP TABLE IF EXISTS audit_log")
|
22
|
+
|
23
|
+
sql_path = File.expand_path(File.join(File.dirname(__FILE__), "..", "lib", "generators", "pg_audit_log", "templates", "lib", "tasks", "pg_audit_log"))
|
24
|
+
|
25
|
+
["create_audit_changes.sql", "create_audit_log_table.sql"].each do |sql_file|
|
26
|
+
begin
|
27
|
+
connection.execute File.read(File.join(sql_path, sql_file))
|
28
|
+
rescue => e
|
29
|
+
puts "-" * 80
|
30
|
+
puts "Unable to install #{sql_file}"
|
31
|
+
puts "-" * 80
|
32
|
+
raise e
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
RSpec.configure do |config|
|
37
|
+
config.mock_with :rspec
|
38
|
+
config.extend WithModel
|
39
|
+
|
40
|
+
config.after(:each) do
|
41
|
+
ActiveRecord::Base.connection.reconnect!
|
42
|
+
end
|
43
|
+
end
|
metadata
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pg_audit_log
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 0.0.1
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Case Commons, LLC
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2011-03-28 00:00:00 -04:00
|
14
|
+
default_executable:
|
15
|
+
dependencies:
|
16
|
+
- !ruby/object:Gem::Dependency
|
17
|
+
name: rails
|
18
|
+
prerelease: false
|
19
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
20
|
+
none: false
|
21
|
+
requirements:
|
22
|
+
- - ">="
|
23
|
+
- !ruby/object:Gem::Version
|
24
|
+
version: "2.3"
|
25
|
+
type: :runtime
|
26
|
+
version_requirements: *id001
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec
|
29
|
+
prerelease: false
|
30
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
31
|
+
none: false
|
32
|
+
requirements:
|
33
|
+
- - ">="
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: "0"
|
36
|
+
type: :development
|
37
|
+
version_requirements: *id002
|
38
|
+
- !ruby/object:Gem::Dependency
|
39
|
+
name: with_model
|
40
|
+
prerelease: false
|
41
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
42
|
+
none: false
|
43
|
+
requirements:
|
44
|
+
- - ">="
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: "0"
|
47
|
+
type: :development
|
48
|
+
version_requirements: *id003
|
49
|
+
description: A completely transparent audit logging component for your application using a stored procedure and triggers. Comes with specs for your project and a rake task to generate the reverse SQL to undo changes logged
|
50
|
+
email:
|
51
|
+
- casecommons-dev@googlegroups.com
|
52
|
+
executables: []
|
53
|
+
|
54
|
+
extensions: []
|
55
|
+
|
56
|
+
extra_rdoc_files: []
|
57
|
+
|
58
|
+
files:
|
59
|
+
- .gitignore
|
60
|
+
- .rvmrc
|
61
|
+
- Gemfile
|
62
|
+
- LICENSE
|
63
|
+
- README.rdoc
|
64
|
+
- Rakefile
|
65
|
+
- init.rb
|
66
|
+
- lib/generators/pg_audit_log/install_generator.rb
|
67
|
+
- lib/generators/pg_audit_log/rspec_generator.rb
|
68
|
+
- lib/generators/pg_audit_log/templates/lib/tasks/pg_audit_log.rake
|
69
|
+
- lib/generators/pg_audit_log/templates/lib/tasks/pg_audit_log/create_audit_changes.sql
|
70
|
+
- lib/generators/pg_audit_log/templates/lib/tasks/pg_audit_log/create_audit_log_table.sql
|
71
|
+
- lib/generators/pg_audit_log/templates/lib/tasks/pg_audit_log/uninstall_audit_changes.sql
|
72
|
+
- lib/generators/pg_audit_log/templates/spec/models/pg_audit_log_spec.rb
|
73
|
+
- lib/generators/pg_audit_log/templates/spec/models/pg_audit_log_spec_helper.rb
|
74
|
+
- lib/pg_audit_log.rb
|
75
|
+
- lib/pg_audit_log/entry.rb
|
76
|
+
- lib/pg_audit_log/extensions/postgresql_adapter.rb
|
77
|
+
- lib/pg_audit_log/version.rb
|
78
|
+
- pg_audit_log.gemspec
|
79
|
+
- spec/configuration_spec.rb
|
80
|
+
- spec/connection_adapter_spec.rb
|
81
|
+
- spec/model_spec.rb
|
82
|
+
- spec/pg_audit_log_spec.rb
|
83
|
+
- spec/spec_helper.rb
|
84
|
+
has_rdoc: true
|
85
|
+
homepage: https://github.com/Casecommons/pg_audit_log
|
86
|
+
licenses: []
|
87
|
+
|
88
|
+
post_install_message:
|
89
|
+
rdoc_options: []
|
90
|
+
|
91
|
+
require_paths:
|
92
|
+
- lib
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
94
|
+
none: false
|
95
|
+
requirements:
|
96
|
+
- - ">="
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: "0"
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
100
|
+
none: false
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: "0"
|
105
|
+
requirements: []
|
106
|
+
|
107
|
+
rubyforge_project:
|
108
|
+
rubygems_version: 1.6.2
|
109
|
+
signing_key:
|
110
|
+
specification_version: 3
|
111
|
+
summary: postgresql only database-level audit logging of all databases changes
|
112
|
+
test_files:
|
113
|
+
- spec/configuration_spec.rb
|
114
|
+
- spec/connection_adapter_spec.rb
|
115
|
+
- spec/model_spec.rb
|
116
|
+
- spec/pg_audit_log_spec.rb
|
117
|
+
- spec/spec_helper.rb
|