diff --git a/migrations/1768213263723_create-audit-logs-table.cjs b/migrations/1768213263723_create-audit-logs-table.cjs new file mode 100644 index 000000000..36a1dbc0b --- /dev/null +++ b/migrations/1768213263723_create-audit-logs-table.cjs @@ -0,0 +1,69 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +exports.shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +exports.up = async (pgm) => { + await pgm.createTable("audit_logs", { + id: { type: "serial", primaryKey: true }, + table_name: { type: "varchar(100)", notNull: true }, + record_id: { type: "integer", notNull: true }, + action: { type: "varchar(20)", notNull: true }, + old_values: { type: "jsonb" }, + new_values: { type: "jsonb" }, + changed_fields: { type: "text[]" }, + user_id: { + type: "integer", + references: "users(id)", + onDelete: "SET NULL", + }, + actor_email: { type: "varchar(255)" }, + actor_type: { type: "varchar(50)", default: "'system'" }, + migration_name: { type: "varchar(100)" }, + created_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("NOW()"), + }, + }); + + // Index for querying history of a specific record + await pgm.createIndex("audit_logs", ["table_name", "record_id"], { + name: "idx_audit_logs_table_record", + }); + + // Index for time-based queries + await pgm.createIndex("audit_logs", ["created_at"], { + name: "idx_audit_logs_created_at", + }); + + // Partial index for filtering by actor + await pgm.createIndex("audit_logs", ["user_id"], { + name: "idx_audit_logs_user_id", + where: "user_id IS NOT NULL", + }); + + // Partial index for migration-related changes + await pgm.createIndex("audit_logs", ["migration_name"], { + name: "idx_audit_logs_migration", + where: "migration_name IS NOT NULL", + }); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +exports.down = async (pgm) => { + await pgm.dropIndex("audit_logs", [], { name: "idx_audit_logs_migration" }); + await pgm.dropIndex("audit_logs", [], { name: "idx_audit_logs_user_id" }); + await pgm.dropIndex("audit_logs", [], { name: "idx_audit_logs_created_at" }); + await pgm.dropIndex("audit_logs", [], { name: "idx_audit_logs_table_record" }); + await pgm.dropTable("audit_logs"); +}; diff --git a/migrations/1768213263724_create-audit-trigger-function.cjs b/migrations/1768213263724_create-audit-trigger-function.cjs new file mode 100644 index 000000000..841c004b0 --- /dev/null +++ b/migrations/1768213263724_create-audit-trigger-function.cjs @@ -0,0 +1,96 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +exports.shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +exports.up = async (pgm) => { + await pgm.db.query(` + CREATE OR REPLACE FUNCTION audit_trigger_func() + RETURNS TRIGGER AS $$ + DECLARE + v_old_values JSONB; + v_new_values JSONB; + v_changed_fields TEXT[]; + v_record_id INTEGER; + v_user_id INTEGER; + v_actor_email VARCHAR(255); + v_actor_type VARCHAR(50); + v_migration_name VARCHAR(100); + v_key TEXT; + BEGIN + -- Get context from session variables (set by application or migrations) + v_user_id := NULLIF(current_setting('app.actor_user_id', true), '')::INTEGER; + v_actor_email := NULLIF(current_setting('app.actor_email', true), ''); + v_actor_type := COALESCE(NULLIF(current_setting('app.actor_type', true), ''), 'system'); + v_migration_name := NULLIF(current_setting('app.current_migration', true), ''); + + -- Determine record_id (handle composite keys for users_organizations) + IF TG_TABLE_NAME = 'users_organizations' THEN + v_record_id := COALESCE(NEW.user_id, OLD.user_id); + ELSE + v_record_id := COALESCE(NEW.id, OLD.id); + END IF; + + -- Set old/new values based on operation + IF TG_OP = 'INSERT' THEN + v_old_values := NULL; + v_new_values := to_jsonb(NEW); + v_changed_fields := NULL; + ELSIF TG_OP = 'UPDATE' THEN + v_old_values := to_jsonb(OLD); + v_new_values := to_jsonb(NEW); + -- Compute changed fields + SELECT ARRAY_AGG(key) + INTO v_changed_fields + FROM jsonb_each(v_new_values) AS n(key, value) + WHERE v_old_values->key IS DISTINCT FROM n.value; + ELSIF TG_OP = 'DELETE' THEN + v_old_values := to_jsonb(OLD); + v_new_values := NULL; + v_changed_fields := NULL; + END IF; + + -- Insert audit log entry + INSERT INTO audit_logs ( + table_name, + record_id, + action, + old_values, + new_values, + changed_fields, + user_id, + actor_email, + actor_type, + migration_name + ) VALUES ( + TG_TABLE_NAME, + v_record_id, + TG_OP, + v_old_values, + v_new_values, + v_changed_fields, + v_user_id, + v_actor_email, + v_actor_type, + v_migration_name + ); + + RETURN COALESCE(NEW, OLD); + END; + $$ LANGUAGE plpgsql; + `); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +exports.down = async (pgm) => { + await pgm.db.query(`DROP FUNCTION IF EXISTS audit_trigger_func();`); +}; diff --git a/migrations/1768213263725_apply-audit-triggers.cjs b/migrations/1768213263725_apply-audit-triggers.cjs new file mode 100644 index 000000000..234784b29 --- /dev/null +++ b/migrations/1768213263725_apply-audit-triggers.cjs @@ -0,0 +1,43 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +exports.shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +exports.up = async (pgm) => { + // Apply audit trigger to users table + await pgm.db.query(` + CREATE TRIGGER audit_users + AFTER INSERT OR UPDATE OR DELETE ON users + FOR EACH ROW EXECUTE FUNCTION audit_trigger_func(); + `); + + // Apply audit trigger to organizations table + await pgm.db.query(` + CREATE TRIGGER audit_organizations + AFTER INSERT OR UPDATE OR DELETE ON organizations + FOR EACH ROW EXECUTE FUNCTION audit_trigger_func(); + `); + + // Apply audit trigger to users_organizations table + await pgm.db.query(` + CREATE TRIGGER audit_users_organizations + AFTER INSERT OR UPDATE OR DELETE ON users_organizations + FOR EACH ROW EXECUTE FUNCTION audit_trigger_func(); + `); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +exports.down = async (pgm) => { + await pgm.db.query(`DROP TRIGGER IF EXISTS audit_users_organizations ON users_organizations;`); + await pgm.db.query(`DROP TRIGGER IF EXISTS audit_organizations ON organizations;`); + await pgm.db.query(`DROP TRIGGER IF EXISTS audit_users ON users;`); +}; diff --git a/packages/database/schema.sql b/packages/database/schema.sql index 726e9835c..c36ac6c1a 100644 --- a/packages/database/schema.sql +++ b/packages/database/schema.sql @@ -63,6 +63,15 @@ DROP CONSTRAINT IF EXISTS "email_domains_organization_id_fkey"; ALTER TABLE IF EXISTS ONLY "public"."authenticators" DROP CONSTRAINT IF EXISTS "authenticators_user_id_fkey"; +ALTER TABLE IF EXISTS ONLY "public"."audit_logs" +DROP CONSTRAINT IF EXISTS "audit_logs_user_id_fkey"; + +DROP TRIGGER IF EXISTS "audit_users_organizations" ON "public"."users_organizations"; + +DROP TRIGGER IF EXISTS "audit_users" ON "public"."users"; + +DROP TRIGGER IF EXISTS "audit_organizations" ON "public"."organizations"; + DROP INDEX IF EXISTS "public"."index_users_on_reset_password_token"; DROP INDEX IF EXISTS "public"."index_users_on_email"; @@ -71,6 +80,14 @@ DROP INDEX IF EXISTS "public"."index_organizations_on_siret"; DROP INDEX IF EXISTS "public"."index_authenticators_on_credential_id"; +DROP INDEX IF EXISTS "public"."idx_audit_logs_user_id"; + +DROP INDEX IF EXISTS "public"."idx_audit_logs_table_record"; + +DROP INDEX IF EXISTS "public"."idx_audit_logs_migration"; + +DROP INDEX IF EXISTS "public"."idx_audit_logs_created_at"; + ALTER TABLE IF EXISTS ONLY "public"."users" DROP CONSTRAINT IF EXISTS "users_pkey"; @@ -104,6 +121,9 @@ DROP CONSTRAINT IF EXISTS "email_deliverability_whitelist_pkey"; ALTER TABLE IF EXISTS ONLY "public"."authenticators" DROP CONSTRAINT IF EXISTS "authenticators_pkey"; +ALTER TABLE IF EXISTS ONLY "public"."audit_logs" +DROP CONSTRAINT IF EXISTS "audit_logs_pkey"; + ALTER TABLE IF EXISTS "public"."users_oidc_clients" ALTER COLUMN "id" DROP DEFAULT; @@ -128,6 +148,10 @@ ALTER TABLE IF EXISTS "public"."email_domains" ALTER COLUMN "id" DROP DEFAULT; +ALTER TABLE IF EXISTS "public"."audit_logs" +ALTER COLUMN "id" +DROP DEFAULT; + DROP TABLE IF EXISTS "public"."users_organizations"; DROP SEQUENCE IF EXISTS "public"."users_oidc_clients_id_seq"; @@ -160,17 +184,129 @@ DROP TABLE IF EXISTS "public"."email_deliverability_whitelist"; DROP TABLE IF EXISTS "public"."authenticators"; +DROP SEQUENCE IF EXISTS "public"."audit_logs_id_seq"; + +DROP TABLE IF EXISTS "public"."audit_logs"; + +DROP FUNCTION IF EXISTS "public"."audit_trigger_func" (); + -- -- Name: SCHEMA "public"; Type: COMMENT; Schema: -; Owner: - -- COMMENT ON SCHEMA "public" IS 'standard public schema'; +-- +-- Name: audit_trigger_func(); Type: FUNCTION; Schema: public; Owner: - +-- +CREATE FUNCTION "public"."audit_trigger_func" () RETURNS "trigger" LANGUAGE "plpgsql" AS $$ + DECLARE + v_old_values JSONB; + v_new_values JSONB; + v_changed_fields TEXT[]; + v_record_id INTEGER; + v_user_id INTEGER; + v_actor_email VARCHAR(255); + v_actor_type VARCHAR(50); + v_migration_name VARCHAR(100); + v_key TEXT; + BEGIN + -- Get context from session variables (set by application or migrations) + v_user_id := NULLIF(current_setting('app.actor_user_id', true), '')::INTEGER; + v_actor_email := NULLIF(current_setting('app.actor_email', true), ''); + v_actor_type := COALESCE(NULLIF(current_setting('app.actor_type', true), ''), 'system'); + v_migration_name := NULLIF(current_setting('app.current_migration', true), ''); + + -- Determine record_id (handle composite keys for users_organizations) + IF TG_TABLE_NAME = 'users_organizations' THEN + v_record_id := COALESCE(NEW.user_id, OLD.user_id); + ELSE + v_record_id := COALESCE(NEW.id, OLD.id); + END IF; + + -- Set old/new values based on operation + IF TG_OP = 'INSERT' THEN + v_old_values := NULL; + v_new_values := to_jsonb(NEW); + v_changed_fields := NULL; + ELSIF TG_OP = 'UPDATE' THEN + v_old_values := to_jsonb(OLD); + v_new_values := to_jsonb(NEW); + -- Compute changed fields + SELECT ARRAY_AGG(key) + INTO v_changed_fields + FROM jsonb_each(v_new_values) AS n(key, value) + WHERE v_old_values->key IS DISTINCT FROM n.value; + ELSIF TG_OP = 'DELETE' THEN + v_old_values := to_jsonb(OLD); + v_new_values := NULL; + v_changed_fields := NULL; + END IF; + + -- Insert audit log entry + INSERT INTO audit_logs ( + table_name, + record_id, + action, + old_values, + new_values, + changed_fields, + user_id, + actor_email, + actor_type, + migration_name + ) VALUES ( + TG_TABLE_NAME, + v_record_id, + TG_OP, + v_old_values, + v_new_values, + v_changed_fields, + v_user_id, + v_actor_email, + v_actor_type, + v_migration_name + ); + + RETURN COALESCE(NEW, OLD); + END; + $$; + SET default_tablespace = ''; SET default_table_access_method = "heap"; +-- +-- Name: audit_logs; Type: TABLE; Schema: public; Owner: - +-- +CREATE TABLE "public"."audit_logs" ( + "id" integer NOT NULL, + "table_name" character varying(100) NOT NULL, + "record_id" integer NOT NULL, + "action" character varying(20) NOT NULL, + "old_values" "jsonb", + "new_values" "jsonb", + "changed_fields" "text" [], + "user_id" integer, + "actor_email" character varying(255), + "actor_type" character varying(50) DEFAULT '''system'''::character varying, + "migration_name" character varying(100), + "created_at" timestamp with time zone DEFAULT "now" () NOT NULL +); + +-- +-- Name: audit_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- +CREATE SEQUENCE "public"."audit_logs_id_seq" AS integer START +WITH + 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; + +-- +-- Name: audit_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- +ALTER SEQUENCE "public"."audit_logs_id_seq" OWNED BY "public"."audit_logs"."id"; + -- -- Name: authenticators; Type: TABLE; Schema: public; Owner: - -- @@ -428,6 +564,13 @@ CREATE TABLE "public"."users_organizations" ( "verified_at" timestamp with time zone ); +-- +-- Name: audit_logs id; Type: DEFAULT; Schema: public; Owner: - +-- +ALTER TABLE ONLY "public"."audit_logs" +ALTER COLUMN "id" +SET DEFAULT "nextval" ('"public"."audit_logs_id_seq"'::"regclass"); + -- -- Name: email_domains id; Type: DEFAULT; Schema: public; Owner: - -- @@ -472,6 +615,12 @@ SET DEFAULT "nextval" ( '"public"."users_oidc_clients_id_seq"'::"regclass" ); +-- +-- Name: audit_logs audit_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- +ALTER TABLE ONLY "public"."audit_logs" +ADD CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id"); + -- -- Name: authenticators authenticators_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -538,6 +687,30 @@ ADD CONSTRAINT "users_organizations_pkey" PRIMARY KEY ("user_id", "organization_ ALTER TABLE ONLY "public"."users" ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id"); +-- +-- Name: idx_audit_logs_created_at; Type: INDEX; Schema: public; Owner: - +-- +CREATE INDEX "idx_audit_logs_created_at" ON "public"."audit_logs" USING "btree" ("created_at"); + +-- +-- Name: idx_audit_logs_migration; Type: INDEX; Schema: public; Owner: - +-- +CREATE INDEX "idx_audit_logs_migration" ON "public"."audit_logs" USING "btree" ("migration_name") +WHERE + ("migration_name" IS NOT NULL); + +-- +-- Name: idx_audit_logs_table_record; Type: INDEX; Schema: public; Owner: - +-- +CREATE INDEX "idx_audit_logs_table_record" ON "public"."audit_logs" USING "btree" ("table_name", "record_id"); + +-- +-- Name: idx_audit_logs_user_id; Type: INDEX; Schema: public; Owner: - +-- +CREATE INDEX "idx_audit_logs_user_id" ON "public"."audit_logs" USING "btree" ("user_id") +WHERE + ("user_id" IS NOT NULL); + -- -- Name: index_authenticators_on_credential_id; Type: INDEX; Schema: public; Owner: - -- @@ -558,6 +731,42 @@ CREATE UNIQUE INDEX "index_users_on_email" ON "public"."users" USING "btree" ("e -- CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "public"."users" USING "btree" ("reset_password_token"); +-- +-- Name: organizations audit_organizations; Type: TRIGGER; Schema: public; Owner: - +-- +CREATE TRIGGER "audit_organizations" +AFTER INSERT +OR DELETE +OR +UPDATE ON "public"."organizations" FOR EACH ROW +EXECUTE FUNCTION "public"."audit_trigger_func" (); + +-- +-- Name: users audit_users; Type: TRIGGER; Schema: public; Owner: - +-- +CREATE TRIGGER "audit_users" +AFTER INSERT +OR DELETE +OR +UPDATE ON "public"."users" FOR EACH ROW +EXECUTE FUNCTION "public"."audit_trigger_func" (); + +-- +-- Name: users_organizations audit_users_organizations; Type: TRIGGER; Schema: public; Owner: - +-- +CREATE TRIGGER "audit_users_organizations" +AFTER INSERT +OR DELETE +OR +UPDATE ON "public"."users_organizations" FOR EACH ROW +EXECUTE FUNCTION "public"."audit_trigger_func" (); + +-- +-- Name: audit_logs audit_logs_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- +ALTER TABLE ONLY "public"."audit_logs" +ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON DELETE SET NULL; + -- -- Name: authenticators authenticators_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- diff --git a/packages/database/src/drizzle/relations.ts b/packages/database/src/drizzle/relations.ts index 2949e5b15..2026b9017 100644 --- a/packages/database/src/drizzle/relations.ts +++ b/packages/database/src/drizzle/relations.ts @@ -1,5 +1,6 @@ import { relations } from "drizzle-orm/relations"; import { + audit_logs, authenticators, email_domains, franceconnect_userinfo, @@ -34,6 +35,7 @@ export const usersRelations = relations(users, ({ many }) => ({ authenticators: many(authenticators), moderations: many(moderations), franceconnect_userinfos: many(franceconnect_userinfo), + audit_logs: many(audit_logs), users_organizations: many(users_organizations), })); @@ -83,6 +85,13 @@ export const franceconnect_userinfoRelations = relations( }), ); +export const audit_logsRelations = relations(audit_logs, ({ one }) => ({ + user: one(users, { + fields: [audit_logs.user_id], + references: [users.id], + }), +})); + export const users_organizationsRelations = relations( users_organizations, ({ one }) => ({ diff --git a/packages/database/src/drizzle/schema.ts b/packages/database/src/drizzle/schema.ts index 039e5d078..e1bd823c9 100644 --- a/packages/database/src/drizzle/schema.ts +++ b/packages/database/src/drizzle/schema.ts @@ -3,7 +3,9 @@ import { bigint, boolean, foreignKey, + index, integer, + jsonb, pgTable, primaryKey, serial, @@ -287,6 +289,48 @@ export const franceconnect_userinfo = pgTable( ], ); +export const audit_logs = pgTable( + "audit_logs", + { + id: serial().primaryKey().notNull(), + table_name: varchar({ length: 100 }).notNull(), + record_id: integer().notNull(), + action: varchar({ length: 20 }).notNull(), + old_values: jsonb(), + new_values: jsonb(), + changed_fields: text().array(), + user_id: integer(), + actor_email: varchar({ length: 255 }), + actor_type: varchar({ length: 50 }).default("'system'"), + migration_name: varchar({ length: 100 }), + created_at: timestamp({ withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("idx_audit_logs_created_at").using( + "btree", + table.created_at.asc().nullsLast().op("timestamptz_ops"), + ), + index("idx_audit_logs_migration") + .using("btree", table.migration_name.asc().nullsLast().op("text_ops")) + .where(sql`(migration_name IS NOT NULL)`), + index("idx_audit_logs_table_record").using( + "btree", + table.table_name.asc().nullsLast().op("text_ops"), + table.record_id.asc().nullsLast().op("text_ops"), + ), + index("idx_audit_logs_user_id") + .using("btree", table.user_id.asc().nullsLast().op("int4_ops")) + .where(sql`(user_id IS NOT NULL)`), + foreignKey({ + columns: [table.user_id], + foreignColumns: [users.id], + name: "audit_logs_user_id_fkey", + }).onDelete("set null"), + ], +); + export const users_organizations = pgTable( "users_organizations", { diff --git a/packages/database/src/pg/migrator/migrate.test.ts.snapshot b/packages/database/src/pg/migrator/migrate.test.ts.snapshot index ebec83303..06a41f6ad 100644 --- a/packages/database/src/pg/migrator/migrate.test.ts.snapshot +++ b/packages/database/src/pg/migrator/migrate.test.ts.snapshot @@ -1,5 +1,8 @@ exports[`Database migration with external PostgreSQL > applies complete schema to fresh database instance 1`] = ` [ + { + "table_name": "audit_logs" + }, { "table_name": "authenticators" }, diff --git a/packages/database/src/pglite/migrator/migrate.test.ts.snapshot b/packages/database/src/pglite/migrator/migrate.test.ts.snapshot index 7c2185696..0cf4a17f3 100644 --- a/packages/database/src/pglite/migrator/migrate.test.ts.snapshot +++ b/packages/database/src/pglite/migrator/migrate.test.ts.snapshot @@ -1,5 +1,8 @@ exports[`Database migration with in-memory PostgreSQL > bootstraps schema from scratch on clean database 1`] = ` [ + { + "table_name": "audit_logs" + }, { "table_name": "authenticators" },