better_record 0.18.3 → 0.19.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/app/models/better_record/base.rb +1 -1
- data/app/models/better_record/logged_action.rb +1 -1
- data/db/migrate/20190209195134_audit_trigger_v3.rb +196 -0
- data/db/postgres-audit-v3-table.psql +80 -0
- data/db/postgres-audit-v3-trigger.psql +315 -0
- data/lib/better_record/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3e89311a82ba55ea3a011faa5ad3c85770bc88d29dd402fbd5856167a2151ab8
|
4
|
+
data.tar.gz: '01396e71a81cc99478664cb762c30d4d663abe3e06c76ce4a82e71e4d676d65d'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f527649a03436eeabdbddf11aa1d0ac1b2bf60a0db5c42831fd143c47a3f3aac9fd9f85d6f18cf2142290c03fedef48d0cd2987d826e56f32cbc1f7adfd9c72a
|
7
|
+
data.tar.gz: 015501e77bd844a80c679ef16b443f9e1f9f561162aaea06102c22f3c66b7f7706521c3869080ba2aa21434d3abe99a82f8377a3d7797635e0bbdef16c1de5aa
|
@@ -25,7 +25,7 @@ module BetterRecord
|
|
25
25
|
# == Class Methods ========================================================
|
26
26
|
def self.set_audit_methods!
|
27
27
|
begin
|
28
|
-
connection.execute(%Q(SELECT 1 FROM #{BetterRecord::LoggedAction.table_name}_#{self.table_name} LIMIT 1))
|
28
|
+
connection.execute(%Q(SELECT 1 FROM #{BetterRecord::LoggedAction.table_name.sub('_view', '')}_#{self.table_name} LIMIT 1))
|
29
29
|
|
30
30
|
self.const_set(:LoggedAction, Class.new(ApplicationRecord))
|
31
31
|
self.const_get(:LoggedAction).table_name = "#{BetterRecord::LoggedAction.table_name}_#{self.table_name}"
|
@@ -12,7 +12,7 @@ module BetterRecord
|
|
12
12
|
}.with_indifferent_access
|
13
13
|
|
14
14
|
# == Attributes ===========================================================
|
15
|
-
self.table_name = "#{BetterRecord.db_audit_schema}.
|
15
|
+
self.table_name = "#{BetterRecord.db_audit_schema}.logged_actions_view"
|
16
16
|
|
17
17
|
# == Extensions ===========================================================
|
18
18
|
|
@@ -0,0 +1,196 @@
|
|
1
|
+
class AuditTriggerV3 < ActiveRecord::Migration[5.2]
|
2
|
+
def up
|
3
|
+
|
4
|
+
|
5
|
+
children = execute <<-SQL
|
6
|
+
SELECT pg_inherits.*, c.relname AS child, p.relname AS parent
|
7
|
+
FROM
|
8
|
+
pg_inherits JOIN pg_class AS c ON (inhrelid=c.oid)
|
9
|
+
JOIN pg_class as p ON (inhparent=p.oid);
|
10
|
+
SQL
|
11
|
+
|
12
|
+
children.each do |child|
|
13
|
+
execute <<-SQL
|
14
|
+
ALTER TABLE #{BetterRecord.db_audit_schema}.#{child['child']}
|
15
|
+
RENAME TO #{'old_' * child['parent'].split('old_').size}#{child['child']}
|
16
|
+
SQL
|
17
|
+
end
|
18
|
+
|
19
|
+
execute <<-SQL
|
20
|
+
ALTER SEQUENCE IF EXISTS auditing.old_logged_actions_event_id_seq RENAME TO old_old_logged_actions_event_id_seq;
|
21
|
+
ALTER TABLE #{BetterRecord.db_audit_schema}.old_logged_actions
|
22
|
+
RENAME TO old_old_logged_actions
|
23
|
+
SQL
|
24
|
+
|
25
|
+
# seq = execute <<-SQL
|
26
|
+
# SELECT table_name, column_name, column_default from
|
27
|
+
# information_schema.columns where table_name='old_old_logged_actions' AND column_name = 'event_id';
|
28
|
+
# SQL
|
29
|
+
#
|
30
|
+
# seq = seq.first
|
31
|
+
#
|
32
|
+
# val = "nextval('auditing.logged_actions_event_id_seq1'::regclass)"
|
33
|
+
|
34
|
+
|
35
|
+
execute <<-SQL
|
36
|
+
ALTER SEQUENCE IF EXISTS auditing.logged_actions_event_id_seq RENAME TO old_logged_actions_event_id_seq;
|
37
|
+
ALTER TABLE #{BetterRecord.db_audit_schema}.logged_actions
|
38
|
+
RENAME TO old_logged_actions
|
39
|
+
SQL
|
40
|
+
|
41
|
+
sql = ""
|
42
|
+
source = File.new(BetterRecord::Engine.root.join('db', 'postgres-audit-v3-table.psql'), "r")
|
43
|
+
while (line = source.gets)
|
44
|
+
sql << line.gsub(/SELECTED_SCHEMA_NAME/, BetterRecord.db_audit_schema)
|
45
|
+
end
|
46
|
+
source.close
|
47
|
+
|
48
|
+
execute sql
|
49
|
+
|
50
|
+
sql = ""
|
51
|
+
source = File.new(BetterRecord::Engine.root.join('db', 'postgres-audit-v3-trigger.psql'), "r")
|
52
|
+
while (line = source.gets)
|
53
|
+
sql << line.gsub(/SELECTED_SCHEMA_NAME/, BetterRecord.db_audit_schema)
|
54
|
+
end
|
55
|
+
source.close
|
56
|
+
|
57
|
+
execute sql
|
58
|
+
|
59
|
+
rows = Developer.connection.execute <<-SQL
|
60
|
+
select trg.tgname,
|
61
|
+
CASE trg.tgtype::integer & 66
|
62
|
+
WHEN 2 THEN 'BEFORE'
|
63
|
+
WHEN 64 THEN 'INSTEAD OF'
|
64
|
+
ELSE 'AFTER'
|
65
|
+
end as trigger_type,
|
66
|
+
case trg.tgtype::integer & cast(28 as int2)
|
67
|
+
when 16 then 'UPDATE'
|
68
|
+
when 8 then 'DELETE'
|
69
|
+
when 4 then 'INSERT'
|
70
|
+
when 20 then 'INSERT, UPDATE'
|
71
|
+
when 28 then 'INSERT, UPDATE, DELETE'
|
72
|
+
when 24 then 'UPDATE, DELETE'
|
73
|
+
when 12 then 'INSERT, DELETE'
|
74
|
+
end as trigger_event,
|
75
|
+
tbl.relname as table_name,
|
76
|
+
obj_description(trg.oid) as remarks,
|
77
|
+
case
|
78
|
+
when trg.tgenabled='O' then 'ENABLED'
|
79
|
+
else 'DISABLED'
|
80
|
+
end as status,
|
81
|
+
case trg.tgtype::integer & 1
|
82
|
+
when 1 then 'ROW'::text
|
83
|
+
else 'STATEMENT'::text
|
84
|
+
end as trigger_level
|
85
|
+
FROM pg_trigger trg
|
86
|
+
JOIN pg_class tbl on trg.tgrelid = tbl.oid
|
87
|
+
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
|
88
|
+
WHERE trg.tgname not like 'RI_ConstraintTrigger%'
|
89
|
+
AND trg.tgname not like 'pg_sync_pg%'
|
90
|
+
SQL
|
91
|
+
|
92
|
+
rows.each do |r|
|
93
|
+
if r['tgname'].to_s =~ /audit_trigger/
|
94
|
+
puts "\n\nPLEASE RECREATE ALL AUDIT TRIGGERS FOR #{r['table_name']}\n\n#{r}\n\n"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
puts "\n\nTo insert old audits back into logged_actions run:\n\n"
|
99
|
+
|
100
|
+
puts <<-RUBY
|
101
|
+
INSERT INTO #{BetterRecord.db_audit_schema}.logged_actions_view
|
102
|
+
(
|
103
|
+
schema_name,
|
104
|
+
table_name,
|
105
|
+
relid,
|
106
|
+
session_user_name,
|
107
|
+
app_user_id,
|
108
|
+
app_user_type,
|
109
|
+
app_ip_address,
|
110
|
+
action_tstamp_tx,
|
111
|
+
action_tstamp_stm,
|
112
|
+
action_tstamp_clk,
|
113
|
+
transaction_id,
|
114
|
+
application_name,
|
115
|
+
client_addr,
|
116
|
+
client_port,
|
117
|
+
client_query,
|
118
|
+
action,
|
119
|
+
row_id,
|
120
|
+
row_data,
|
121
|
+
changed_fields,
|
122
|
+
statement_only
|
123
|
+
)
|
124
|
+
SELECT
|
125
|
+
schema_name,
|
126
|
+
table_name,
|
127
|
+
relid,
|
128
|
+
session_user_name,
|
129
|
+
app_user_id,
|
130
|
+
app_user_type,
|
131
|
+
app_ip_address,
|
132
|
+
action_tstamp_tx,
|
133
|
+
action_tstamp_stm,
|
134
|
+
action_tstamp_clk,
|
135
|
+
transaction_id,
|
136
|
+
application_name,
|
137
|
+
client_addr,
|
138
|
+
client_port,
|
139
|
+
client_query,
|
140
|
+
action,
|
141
|
+
row_id,
|
142
|
+
row_data,
|
143
|
+
changed_fields,
|
144
|
+
statement_only
|
145
|
+
FROM #{BetterRecord.db_audit_schema}.old_logged_actions
|
146
|
+
ORDER BY old_logged_actions.event_id;
|
147
|
+
|
148
|
+
INSERT INTO #{BetterRecord.db_audit_schema}.logged_actions_view
|
149
|
+
(
|
150
|
+
schema_name,
|
151
|
+
table_name,
|
152
|
+
relid,
|
153
|
+
session_user_name,
|
154
|
+
app_user_id,
|
155
|
+
app_user_type,
|
156
|
+
app_ip_address,
|
157
|
+
action_tstamp_tx,
|
158
|
+
action_tstamp_stm,
|
159
|
+
action_tstamp_clk,
|
160
|
+
transaction_id,
|
161
|
+
application_name,
|
162
|
+
client_addr,
|
163
|
+
client_port,
|
164
|
+
client_query,
|
165
|
+
action,
|
166
|
+
row_id,
|
167
|
+
row_data,
|
168
|
+
changed_fields,
|
169
|
+
statement_only
|
170
|
+
)
|
171
|
+
SELECT
|
172
|
+
schema_name,
|
173
|
+
table_name,
|
174
|
+
relid,
|
175
|
+
session_user_name,
|
176
|
+
app_user_id,
|
177
|
+
app_user_type,
|
178
|
+
app_ip_address,
|
179
|
+
action_tstamp_tx,
|
180
|
+
action_tstamp_stm,
|
181
|
+
action_tstamp_clk,
|
182
|
+
transaction_id,
|
183
|
+
application_name,
|
184
|
+
client_addr,
|
185
|
+
client_port,
|
186
|
+
client_query,
|
187
|
+
action,
|
188
|
+
row_id,
|
189
|
+
row_data,
|
190
|
+
changed_fields,
|
191
|
+
statement_only
|
192
|
+
FROM #{BetterRecord.db_audit_schema}.old_old_logged_actions
|
193
|
+
ORDER BY old_old_logged_actions.event_id;
|
194
|
+
RUBY
|
195
|
+
end
|
196
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
CREATE EXTENSION IF NOT EXISTS hstore;
|
2
|
+
|
3
|
+
CREATE SCHEMA IF NOT EXISTS SELECTED_SCHEMA_NAME;
|
4
|
+
REVOKE ALL ON SCHEMA SELECTED_SCHEMA_NAME FROM public;
|
5
|
+
|
6
|
+
COMMENT ON SCHEMA SELECTED_SCHEMA_NAME IS 'Out-of-table audit/history logging tables and trigger functions';
|
7
|
+
|
8
|
+
--
|
9
|
+
-- Audited data. Lots of information is available, it's just a matter of how much
|
10
|
+
-- you really want to record. See:
|
11
|
+
--
|
12
|
+
-- http://www.postgresql.org/docs/9.1/static/functions-info.html
|
13
|
+
--
|
14
|
+
-- Remember, every column you add takes up more audit table space and slows audit
|
15
|
+
-- inserts.
|
16
|
+
--
|
17
|
+
-- Every index you add has a big impact too, so avoid adding indexes to the
|
18
|
+
-- audit table unless you REALLY need them. The hstore GIST indexes are
|
19
|
+
-- particularly expensive.
|
20
|
+
--
|
21
|
+
-- It is sometimes worth copying the audit table, or a coarse subset of it that
|
22
|
+
-- you're interested in, into a temporary table where you CREATE any useful
|
23
|
+
-- indexes and do your analysis.
|
24
|
+
--
|
25
|
+
CREATE TABLE IF NOT EXISTS SELECTED_SCHEMA_NAME.logged_actions (
|
26
|
+
event_id bigserial primary key,
|
27
|
+
schema_name text not null,
|
28
|
+
table_name text not null,
|
29
|
+
relid oid not null,
|
30
|
+
session_user_name text,
|
31
|
+
app_user_id integer,
|
32
|
+
app_user_type text,
|
33
|
+
app_ip_address inet,
|
34
|
+
action_tstamp_tx TIMESTAMP WITH TIME ZONE NOT NULL,
|
35
|
+
action_tstamp_stm TIMESTAMP WITH TIME ZONE NOT NULL,
|
36
|
+
action_tstamp_clk TIMESTAMP WITH TIME ZONE NOT NULL,
|
37
|
+
transaction_id bigint,
|
38
|
+
application_name text,
|
39
|
+
client_addr inet,
|
40
|
+
client_port integer,
|
41
|
+
client_query text,
|
42
|
+
action TEXT NOT NULL CHECK (action IN ('I','D','U', 'T', 'A')),
|
43
|
+
row_id bigint,
|
44
|
+
row_data hstore,
|
45
|
+
changed_fields hstore,
|
46
|
+
statement_only boolean not null
|
47
|
+
);
|
48
|
+
|
49
|
+
REVOKE ALL ON SELECTED_SCHEMA_NAME.logged_actions FROM public;
|
50
|
+
|
51
|
+
COMMENT ON TABLE SELECTED_SCHEMA_NAME.logged_actions IS 'History of auditable actions on audited tables, from SELECTED_SCHEMA_NAME.if_modified_func()';
|
52
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.event_id IS 'Unique identifier for each auditable event';
|
53
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.schema_name IS 'Database schema audited table for this event is in';
|
54
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.table_name IS 'Non-schema-qualified table name of table event occured in';
|
55
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.relid IS 'Table OID. Changes with drop/create. Get with ''tablename''::regclass';
|
56
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.session_user_name IS 'Login / session user whose statement caused the audited event';
|
57
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.app_user_id IS 'Application-provided polymorphic user id';
|
58
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.app_user_type IS 'Application-provided polymorphic user type';
|
59
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.app_ip_address IS 'Application-provided ip address of user whose statement caused the audited event';
|
60
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.action_tstamp_tx IS 'Transaction start timestamp for tx in which audited event occurred';
|
61
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.action_tstamp_stm IS 'Statement start timestamp for tx in which audited event occurred';
|
62
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.action_tstamp_clk IS 'Wall clock time at which audited event''s trigger call occurred';
|
63
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.transaction_id IS 'Identifier of transaction that made the change. May wrap, but unique paired with action_tstamp_tx.';
|
64
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.client_addr IS 'IP address of client that issued query. Null for unix domain socket.';
|
65
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.client_port IS 'Remote peer IP port address of client that issued query. Undefined for unix socket.';
|
66
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.client_query IS 'Top-level query that caused this auditable event. May be more than one statement.';
|
67
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.application_name IS 'Application name set when this audit event occurred. Can be changed in-session by client.';
|
68
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.action IS 'Action type; I = insert, D = delete, U = update, T = truncate, A = archive';
|
69
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.row_id IS 'Record primary_key. Null for statement-level trigger. Prefers NEW.id if exists';
|
70
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.row_data IS 'Record value. Null for statement-level trigger. For INSERT this is the new tuple. For DELETE and UPDATE it is the old tuple.';
|
71
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.changed_fields IS 'New values of fields changed by UPDATE. Null except for row-level UPDATE events.';
|
72
|
+
COMMENT ON COLUMN SELECTED_SCHEMA_NAME.logged_actions.statement_only IS '''t'' if audit event is from an FOR EACH STATEMENT trigger, ''f'' for FOR EACH ROW';
|
73
|
+
|
74
|
+
CREATE INDEX IF NOT EXISTS logged_actions_relid_idx ON SELECTED_SCHEMA_NAME.logged_actions(relid);
|
75
|
+
CREATE INDEX IF NOT EXISTS logged_actions_action_tstamp_tx_stm_idx ON SELECTED_SCHEMA_NAME.logged_actions(action_tstamp_stm);
|
76
|
+
CREATE INDEX IF NOT EXISTS logged_actions_action_idx ON SELECTED_SCHEMA_NAME.logged_actions(action);
|
77
|
+
CREATE INDEX IF NOT EXISTS logged_actions_row_id_idx ON SELECTED_SCHEMA_NAME.logged_actions(row_id);
|
78
|
+
|
79
|
+
CREATE OR REPLACE VIEW SELECTED_SCHEMA_NAME.logged_actions_view AS SELECT * FROM SELECTED_SCHEMA_NAME.logged_actions;
|
80
|
+
ALTER VIEW SELECTED_SCHEMA_NAME.logged_actions_view ALTER COLUMN event_id SET DEFAULT NEXTVAL('"SELECTED_SCHEMA_NAME"."logged_actions_event_id_seq"');
|
@@ -0,0 +1,315 @@
|
|
1
|
+
-- An audit history is important on most tables. Provide an audit trigger that logs to
|
2
|
+
-- a dedicated audit table for the major relations.
|
3
|
+
--
|
4
|
+
-- This file should be generic and not depend on application roles or structures,
|
5
|
+
-- as it's being listed here:
|
6
|
+
--
|
7
|
+
-- https://wiki.postgresql.org/wiki/Audit_trigger_91plus
|
8
|
+
--
|
9
|
+
-- This trigger was originally based on
|
10
|
+
-- http://wiki.postgresql.org/wiki/Audit_trigger
|
11
|
+
-- but has been completely rewritten.
|
12
|
+
--
|
13
|
+
-- Should really be converted into a relocatable EXTENSION, with control and upgrade files.
|
14
|
+
|
15
|
+
CREATE OR REPLACE FUNCTION SELECTED_SCHEMA_NAME.skip_logged_actions_main()
|
16
|
+
RETURNS TRIGGER
|
17
|
+
AS $$
|
18
|
+
BEGIN
|
19
|
+
raise exception 'insert on wrong table';
|
20
|
+
RETURN NULL;
|
21
|
+
END;
|
22
|
+
$$
|
23
|
+
LANGUAGE plpgsql
|
24
|
+
SECURITY DEFINER
|
25
|
+
SET search_path = pg_catalog, public;
|
26
|
+
|
27
|
+
DROP TRIGGER IF EXISTS logged_actions_skip_direct ON SELECTED_SCHEMA_NAME.logged_actions;
|
28
|
+
CREATE TRIGGER logged_actions_skip_direct BEFORE INSERT ON SELECTED_SCHEMA_NAME.logged_actions EXECUTE PROCEDURE SELECTED_SCHEMA_NAME.skip_logged_actions_main();
|
29
|
+
|
30
|
+
CREATE OR REPLACE FUNCTION SELECTED_SCHEMA_NAME.logged_actions_partition()
|
31
|
+
RETURNS TRIGGER AS
|
32
|
+
$$
|
33
|
+
DECLARE
|
34
|
+
table_name text;
|
35
|
+
BEGIN
|
36
|
+
|
37
|
+
table_name = NEW.table_name::TEXT;
|
38
|
+
|
39
|
+
EXECUTE 'CREATE TABLE IF NOT EXISTS SELECTED_SCHEMA_NAME.logged_actions_' || quote_ident(table_name::TEXT) || '(
|
40
|
+
CHECK (table_name = ' || quote_literal(table_name::TEXT) || ')
|
41
|
+
) INHERITS (SELECTED_SCHEMA_NAME.logged_actions)';
|
42
|
+
|
43
|
+
EXECUTE 'INSERT INTO SELECTED_SCHEMA_NAME.logged_actions_' || quote_ident(table_name::TEXT) || ' VALUES ($1.*)' USING NEW;
|
44
|
+
|
45
|
+
RETURN NEW;
|
46
|
+
END;
|
47
|
+
$$
|
48
|
+
LANGUAGE plpgsql
|
49
|
+
SECURITY DEFINER
|
50
|
+
SET search_path = pg_catalog, public;
|
51
|
+
|
52
|
+
DROP TRIGGER IF EXISTS logged_actions_partition_by_table ON SELECTED_SCHEMA_NAME.logged_actions_view;
|
53
|
+
CREATE TRIGGER logged_actions_partition_by_table instead OF INSERT ON SELECTED_SCHEMA_NAME.logged_actions_view
|
54
|
+
FOR each ROW EXECUTE PROCEDURE SELECTED_SCHEMA_NAME.logged_actions_partition();
|
55
|
+
|
56
|
+
CREATE OR REPLACE FUNCTION SELECTED_SCHEMA_NAME.if_modified_func()
|
57
|
+
RETURNS TRIGGER AS
|
58
|
+
$$
|
59
|
+
DECLARE
|
60
|
+
audit_row SELECTED_SCHEMA_NAME.logged_actions;
|
61
|
+
include_values boolean;
|
62
|
+
log_diffs boolean;
|
63
|
+
h_old hstore;
|
64
|
+
h_new hstore;
|
65
|
+
user_row record;
|
66
|
+
excluded_cols text[] = ARRAY[]::text[];
|
67
|
+
pk_val_query text;
|
68
|
+
BEGIN
|
69
|
+
IF TG_WHEN <> 'AFTER' THEN
|
70
|
+
RAISE EXCEPTION 'SELECTED_SCHEMA_NAME.if_modified_func() may only run as an AFTER trigger';
|
71
|
+
END IF;
|
72
|
+
|
73
|
+
audit_row = ROW(
|
74
|
+
nextval('SELECTED_SCHEMA_NAME.logged_actions_event_id_seq'), -- event_id
|
75
|
+
TG_TABLE_SCHEMA::text, -- schema_name
|
76
|
+
TG_TABLE_NAME::text, -- table_name
|
77
|
+
TG_RELID, -- relation OID for much quicker searches
|
78
|
+
session_user::text, -- session_user_name
|
79
|
+
NULL, NULL, NULL, -- app_user_id, app_user_type, app_ip_address
|
80
|
+
current_timestamp, -- action_tstamp_tx
|
81
|
+
statement_timestamp(), -- action_tstamp_stm
|
82
|
+
clock_timestamp(), -- action_tstamp_clk
|
83
|
+
txid_current(), -- transaction ID
|
84
|
+
current_setting('application_name'), -- client application
|
85
|
+
inet_client_addr(), -- client_addr
|
86
|
+
inet_client_port(), -- client_port
|
87
|
+
current_query(), -- top-level query or queries (if multistatement) from client
|
88
|
+
substring(TG_OP,1,1), -- action
|
89
|
+
NULL, NULL, NULL, -- row_id, row_data, changed_fields
|
90
|
+
'f' -- statement_only
|
91
|
+
);
|
92
|
+
|
93
|
+
IF NOT TG_ARGV[0]::boolean IS DISTINCT FROM 'f'::boolean THEN
|
94
|
+
audit_row.client_query = NULL;
|
95
|
+
END IF;
|
96
|
+
|
97
|
+
IF ((TG_ARGV[1] IS NOT NULL) AND (TG_LEVEL = 'ROW')) THEN
|
98
|
+
pk_val_query = 'SELECT $1.' || quote_ident(TG_ARGV[1]::text);
|
99
|
+
|
100
|
+
IF (TG_OP IS DISTINCT FROM 'DELETE') THEN
|
101
|
+
EXECUTE pk_val_query INTO audit_row.row_id USING NEW;
|
102
|
+
END IF;
|
103
|
+
|
104
|
+
IF audit_row.row_id IS NULL THEN
|
105
|
+
EXECUTE pk_val_query INTO audit_row.row_id USING OLD;
|
106
|
+
END IF;
|
107
|
+
END IF;
|
108
|
+
|
109
|
+
IF TG_ARGV[2] IS NOT NULL THEN
|
110
|
+
excluded_cols = TG_ARGV[2]::text[];
|
111
|
+
END IF;
|
112
|
+
|
113
|
+
CREATE TEMP TABLE IF NOT EXISTS
|
114
|
+
"_app_user" (user_id integer, user_type text, ip_address inet);
|
115
|
+
|
116
|
+
IF (TG_OP = 'UPDATE' AND TG_LEVEL = 'ROW') THEN
|
117
|
+
audit_row.row_data = hstore(OLD.*) - excluded_cols;
|
118
|
+
audit_row.changed_fields = (hstore(NEW.*) - audit_row.row_data) - excluded_cols;
|
119
|
+
IF audit_row.changed_fields = hstore('') THEN
|
120
|
+
-- All changed fields are ignored. Skip this update.
|
121
|
+
RETURN NULL;
|
122
|
+
END IF;
|
123
|
+
ELSIF (TG_OP = 'DELETE' AND TG_LEVEL = 'ROW') THEN
|
124
|
+
audit_row.row_data = hstore(OLD.*) - excluded_cols;
|
125
|
+
ELSIF (TG_OP = 'INSERT' AND TG_LEVEL = 'ROW') THEN
|
126
|
+
audit_row.row_data = hstore(NEW.*) - excluded_cols;
|
127
|
+
ELSIF (TG_LEVEL = 'STATEMENT' AND TG_OP IN ('INSERT','UPDATE','DELETE','TRUNCATE')) THEN
|
128
|
+
audit_row.statement_only = 't';
|
129
|
+
ELSE
|
130
|
+
RAISE EXCEPTION '[SELECTED_SCHEMA_NAME.if_modified_func] - Trigger func added as trigger for unhandled case: %, %',TG_OP, TG_LEVEL;
|
131
|
+
RETURN NULL;
|
132
|
+
END IF;
|
133
|
+
|
134
|
+
-- inject app_user data into audit
|
135
|
+
BEGIN
|
136
|
+
PERFORM
|
137
|
+
n.nspname, c.relname
|
138
|
+
FROM
|
139
|
+
pg_catalog.pg_class c
|
140
|
+
LEFT JOIN
|
141
|
+
pg_catalog.pg_namespace n
|
142
|
+
ON n.oid = c.relnamespace
|
143
|
+
WHERE
|
144
|
+
n.nspname like 'pg_temp_%'
|
145
|
+
AND
|
146
|
+
c.relname = '_app_user';
|
147
|
+
|
148
|
+
IF FOUND THEN
|
149
|
+
FOR user_row IN SELECT * FROM _app_user LIMIT 1 LOOP
|
150
|
+
audit_row.app_user_id = user_row.user_id;
|
151
|
+
audit_row.app_user_type = user_row.user_type;
|
152
|
+
audit_row.app_ip_address = user_row.ip_address;
|
153
|
+
END LOOP;
|
154
|
+
END IF;
|
155
|
+
END;
|
156
|
+
-- end app_user data
|
157
|
+
|
158
|
+
INSERT INTO SELECTED_SCHEMA_NAME.logged_actions_view VALUES (audit_row.*);
|
159
|
+
RETURN NULL;
|
160
|
+
END;
|
161
|
+
$$
|
162
|
+
LANGUAGE plpgsql
|
163
|
+
SECURITY DEFINER
|
164
|
+
SET search_path = pg_catalog, public;
|
165
|
+
|
166
|
+
|
167
|
+
COMMENT ON FUNCTION SELECTED_SCHEMA_NAME.if_modified_func() IS
|
168
|
+
$$
|
169
|
+
Track changes to a table at the statement and/or row level.
|
170
|
+
|
171
|
+
Optional parameters to trigger in CREATE TRIGGER call:
|
172
|
+
|
173
|
+
param 0: boolean, whether to log the query text. Default 't'.
|
174
|
+
|
175
|
+
param 1: text, primary_key_column of audited table if bigint.
|
176
|
+
|
177
|
+
param 2: text[], columns to ignore in updates. Default [].
|
178
|
+
|
179
|
+
Updates to ignored cols are omitted from changed_fields.
|
180
|
+
|
181
|
+
Updates with only ignored cols changed are not inserted
|
182
|
+
into the audit log.
|
183
|
+
|
184
|
+
Almost all the processing work is still done for updates
|
185
|
+
that ignored. If you need to save the load, you need to use
|
186
|
+
WHEN clause on the trigger instead.
|
187
|
+
|
188
|
+
No warning or error is issued if ignored_cols contains columns
|
189
|
+
that do not exist in the target table. This lets you specify
|
190
|
+
a standard set of ignored columns.
|
191
|
+
|
192
|
+
There is no parameter to disable logging of values. Add this trigger as
|
193
|
+
a 'FOR EACH STATEMENT' rather than 'FOR EACH ROW' trigger if you do not
|
194
|
+
want to log row values.
|
195
|
+
|
196
|
+
Note that the user name logged is the login role for the session. The audit trigger
|
197
|
+
cannot obtain the active role because it is reset by the SECURITY DEFINER invocation
|
198
|
+
of the audit trigger its self.
|
199
|
+
$$;
|
200
|
+
|
201
|
+
|
202
|
+
CREATE OR REPLACE FUNCTION SELECTED_SCHEMA_NAME.get_primary_key_column(target_table text)
|
203
|
+
RETURNS text AS
|
204
|
+
$$
|
205
|
+
DECLARE
|
206
|
+
_pk_query_text text;
|
207
|
+
_pk_column_name text;
|
208
|
+
BEGIN
|
209
|
+
_pk_query_text = 'SELECT a.attname ' ||
|
210
|
+
'FROM pg_index i ' ||
|
211
|
+
'JOIN pg_attribute a ON a.attrelid = i.indrelid ' ||
|
212
|
+
' AND a.attnum = ANY(i.indkey) ' ||
|
213
|
+
'WHERE i.indrelid = ' || quote_literal(target_table::TEXT) || '::regclass ' ||
|
214
|
+
'AND i.indisprimary ' ||
|
215
|
+
'AND format_type(a.atttypid, a.atttypmod) = ' || quote_literal('bigint'::TEXT) ||
|
216
|
+
'LIMIT 1';
|
217
|
+
|
218
|
+
EXECUTE _pk_query_text INTO _pk_column_name;
|
219
|
+
raise notice 'Value %', _pk_column_name;
|
220
|
+
return _pk_column_name;
|
221
|
+
END;
|
222
|
+
$$
|
223
|
+
LANGUAGE plpgsql;
|
224
|
+
|
225
|
+
COMMENT ON FUNCTION SELECTED_SCHEMA_NAME.get_primary_key_column(text) IS
|
226
|
+
$$
|
227
|
+
Get primary key column name if single PK and type bigint.
|
228
|
+
|
229
|
+
Arguments:
|
230
|
+
target_table: Table name, schema qualified if not on search_path
|
231
|
+
$$;
|
232
|
+
|
233
|
+
CREATE OR REPLACE FUNCTION SELECTED_SCHEMA_NAME.audit_table(target_table regclass, audit_rows boolean, audit_query_text boolean, ignored_cols text[])
|
234
|
+
RETURNS void AS
|
235
|
+
$$
|
236
|
+
DECLARE
|
237
|
+
stm_targets text = 'INSERT OR UPDATE OR DELETE OR TRUNCATE';
|
238
|
+
_q_txt text;
|
239
|
+
_pk_column_name text;
|
240
|
+
_pk_column_snip text;
|
241
|
+
_ignored_cols_snip text = '';
|
242
|
+
BEGIN
|
243
|
+
EXECUTE 'DROP TRIGGER IF EXISTS audit_trigger_row ON ' || quote_ident(target_table::TEXT);
|
244
|
+
EXECUTE 'DROP TRIGGER IF EXISTS audit_trigger_stm ON ' || quote_ident(target_table::TEXT);
|
245
|
+
|
246
|
+
EXECUTE 'CREATE TABLE IF NOT EXISTS SELECTED_SCHEMA_NAME.logged_actions_' || quote_ident(target_table::TEXT) || '(
|
247
|
+
CHECK (table_name = ' || quote_literal(target_table::TEXT) || ')
|
248
|
+
) INHERITS (SELECTED_SCHEMA_NAME.logged_actions)';
|
249
|
+
|
250
|
+
IF audit_rows THEN
|
251
|
+
_pk_column_name = SELECTED_SCHEMA_NAME.get_primary_key_column(target_table::TEXT);
|
252
|
+
|
253
|
+
IF _pk_column_name IS NOT NULL THEN
|
254
|
+
_pk_column_snip = ', ' || quote_literal(_pk_column_name);
|
255
|
+
ELSE
|
256
|
+
_pk_column_snip = ', NULL';
|
257
|
+
END IF;
|
258
|
+
|
259
|
+
IF array_length(ignored_cols,1) > 0 THEN
|
260
|
+
_ignored_cols_snip = ', ' || quote_literal(ignored_cols);
|
261
|
+
END IF;
|
262
|
+
_q_txt = 'CREATE TRIGGER audit_trigger_row AFTER INSERT OR UPDATE OR DELETE ON ' ||
|
263
|
+
quote_ident(target_table::TEXT) ||
|
264
|
+
' FOR EACH ROW EXECUTE PROCEDURE SELECTED_SCHEMA_NAME.if_modified_func(' ||
|
265
|
+
quote_literal(audit_query_text) || _pk_column_snip || _ignored_cols_snip || ');';
|
266
|
+
RAISE NOTICE '%',_q_txt;
|
267
|
+
EXECUTE _q_txt;
|
268
|
+
stm_targets = 'TRUNCATE';
|
269
|
+
ELSE
|
270
|
+
END IF;
|
271
|
+
|
272
|
+
_q_txt = 'CREATE TRIGGER audit_trigger_stm AFTER ' || stm_targets || ' ON ' ||
|
273
|
+
target_table ||
|
274
|
+
' FOR EACH STATEMENT EXECUTE PROCEDURE SELECTED_SCHEMA_NAME.if_modified_func('||
|
275
|
+
quote_literal(audit_query_text) || ');';
|
276
|
+
RAISE NOTICE '%',_q_txt;
|
277
|
+
EXECUTE _q_txt;
|
278
|
+
|
279
|
+
END;
|
280
|
+
$$
|
281
|
+
LANGUAGE plpgsql;
|
282
|
+
|
283
|
+
COMMENT ON FUNCTION SELECTED_SCHEMA_NAME.audit_table(regclass, boolean, boolean, text[]) IS
|
284
|
+
$$
|
285
|
+
Add auditing support to a table.
|
286
|
+
|
287
|
+
Arguments:
|
288
|
+
target_table: Table name, schema qualified if not on search_path
|
289
|
+
audit_rows: Record each row change, or only audit at a statement level
|
290
|
+
audit_query_text: Record the text of the client query that triggered the audit event?
|
291
|
+
ignored_cols: Columns to exclude from update diffs, ignore updates that change only ignored cols.
|
292
|
+
$$;
|
293
|
+
|
294
|
+
-- Pg doesn't allow variadic calls with 0 params, so provide a wrapper
|
295
|
+
CREATE OR REPLACE FUNCTION SELECTED_SCHEMA_NAME.audit_table(target_table regclass, audit_rows boolean, audit_query_text boolean)
|
296
|
+
RETURNS void AS
|
297
|
+
$$
|
298
|
+
SELECT SELECTED_SCHEMA_NAME.audit_table($1, $2, $3, ARRAY[]::text[]);
|
299
|
+
$$
|
300
|
+
LANGUAGE SQL;
|
301
|
+
|
302
|
+
-- And provide a convenience call wrapper for the simplest case
|
303
|
+
-- of row-level logging with no excluded cols and query logging enabled.
|
304
|
+
--
|
305
|
+
CREATE OR REPLACE FUNCTION SELECTED_SCHEMA_NAME.audit_table(target_table regclass)
|
306
|
+
RETURNS void AS
|
307
|
+
$$
|
308
|
+
SELECT SELECTED_SCHEMA_NAME.audit_table($1, BOOLEAN 't', BOOLEAN 't');
|
309
|
+
$$
|
310
|
+
LANGUAGE SQL;
|
311
|
+
|
312
|
+
COMMENT ON FUNCTION SELECTED_SCHEMA_NAME.audit_table(regclass) IS
|
313
|
+
$$
|
314
|
+
Add auditing support to the given table. Row-level changes will be logged with full client query text. No cols are ignored.
|
315
|
+
$$;
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: better_record
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.19.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sampson Crowley
|
@@ -271,9 +271,12 @@ files:
|
|
271
271
|
- db/migrate/20190107202602_add_updated_at_to_better_record_table_sizes.rb
|
272
272
|
- db/migrate/20190123225641_add_exchange_rate_integer_type.rb
|
273
273
|
- db/migrate/20190209033946_update_better_record_audit_functions.rb
|
274
|
+
- db/migrate/20190209195134_audit_trigger_v3.rb
|
274
275
|
- db/postgres-audit-trigger.psql
|
275
276
|
- db/postgres-audit-v2-table.psql
|
276
277
|
- db/postgres-audit-v2-trigger.psql
|
278
|
+
- db/postgres-audit-v3-table.psql
|
279
|
+
- db/postgres-audit-v3-trigger.psql
|
277
280
|
- lib/better_record.rb
|
278
281
|
- lib/better_record/batches.rb
|
279
282
|
- lib/better_record/concerns/active_record_extensions/associations_extensions/association_scope_extensions.rb
|