This example shows how to use a Prisma Client extension to provide the current application user's ID as context to an audit log trigger in Postgres. User IDs are included in an audit trail tracking every change to rows on certain tables.
NOTE: Because this example extension wraps every query in a new batch transaction, explicitly running transactions with
prisma.$transaction()
may not work as intended. In a future version of Prisma Client,query
extensions will have access to information about whether they are run inside a transaction, similar to therunInTransaction
parameter provided to Prisma middleware. When this is available, this example will be updated to work for queries run inside explicit transactions.
This extension is provided as an example only. It is not intended to be used in production environments.
Please read the documentation on query
extensions for more information.
Certain applications need to track changes to data over time and generate an audit trail to record which user made each change. One approach is to store this audit log in one or more tables in the same database and populate the audit log automatically with database triggers.
In order to provide information about the current user and/or session, it is possible to set a runtime parameter in Postgres and reference it from a trigger.
There are a few steps required to set up an audit table in Postgres and then provide session context to it from Prisma:
There is more than one way to structure audit log tables. For example, you might create a single audit log table which tracks changes to various other tables. In this case, you could include text
columns that track the base table's schema and name, as well as a json
column which contains the actual data for each modification.
In this example, we create a separate "Version
" table for each base table that needs an audit log. These tables contain columns that mirror all of the columns on the base table, as well as a few extra columns that contain metadata about each version:
versionId
: The primary key of the version table — a unique value for each version of each row in the base tableversionOperation
: Tracks whether the modification was aCREATE
,UPDATE
, orDELETE
commandversion<ModelName>Id
: The ID of the row in the base table. This is a nullable foreign key to the base table for rows that still exist, but can beNULL
for deleted rows. The deleted row IDs are tracked in the mirroredid
column.versionUserId
: The ID of the user who made the modification, provided as context by the applicationversionTimestamp
: When the modification was made
There are benefits and drawbacks to each way to model audit log tables, but the general approach for including session context is the same.
NOTE: This example also uses the
multiSchema
preview feature for Postgres in order to store audit tables in a separateaudit
schema.
Create a custom migration file that includes SQL commands to create audit triggers for operations on the base tables. See this migration file, which includes a trigger for each table that needs to be audited:
CREATE OR REPLACE FUNCTION "audit"."Product_audit"() RETURNS TRIGGER AS $$
BEGIN
IF (TG_OP = 'DELETE') THEN
INSERT INTO "audit"."ProductVersion"
VALUES (DEFAULT, 'DELETE', NULL, current_setting('app.current_user_id', TRUE)::int, now(), OLD.*);
ELSIF (TG_OP = 'UPDATE') THEN
INSERT INTO "audit"."ProductVersion"
VALUES (DEFAULT, 'UPDATE', NEW."id", current_setting('app.current_user_id', TRUE)::int, now(), NEW.*);
ELSIF (TG_OP = 'INSERT') THEN
INSERT INTO "audit"."ProductVersion"
VALUES (DEFAULT, 'INSERT', NEW."id", current_setting('app.current_user_id', TRUE)::int, now(), NEW.*);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit
AFTER INSERT OR UPDATE OR DELETE ON "public"."Product"
FOR EACH ROW EXECUTE FUNCTION "audit"."Product_audit"();
These triggers create a row in a Version
table for every row affected by an INSERT
, UPDATE
, or DELETE
command on the base table. They set the versionUserId
column to the result of calling current_setting('app.current_user_id', TRUE)::int
, which gets a runtime parameter named app.current_user_id
that we'll set via a Prisma Client extension.
3. Create a Prisma Client extension that wraps queries in a transaction and sets the audit log session context
In order to set a runtime parameter in Postgres, you can execute a raw query with Prisma:
await prisma.$executeRaw`SELECT set_config('app.current_user_id', ${user}, TRUE)`;
However, each time you run a query, Prisma may use a different connection from the connection pool. In order to associate the parameter with all of the queries in the context of a given request in your application, you should:
- Start a transaction.
- Set the runtime parameter as a
LOCAL
setting, which lasts only until the end of the current transaction. This may be done by passingTRUE
to the third argument (is_local
) of theset_config()
function. - Run all queries for the duration of the request inside this transaction.
All queries for a given transaction will use the same database connection, and because the setting is local, it won't affect any other transactions.
Prisma Client extensions provide a way to easily ensure all queries run inside a transaction with a setting enabled for RLS. See the script.ts
file for an example extension, which allows you to create a client instance for the current user:
const userPrisma = prisma.forUser(user.id);
await userPrisma.product.update({
where: { id: product.id },
data: { name: "Updated Name" },
});
Clone this repository:
git clone [email protected]:sbking/prisma-client-extensions.git
Create a .env
file and install dependencies:
cd audit-log-context
cp .env.example .env
npm install
Run the following command to start a new Postgres database in a Docker container:
docker compose up -d
Run this command to apply migrations to the database:
npx prisma migrate deploy
Run the following command to add seed data to the database:
npx prisma db seed
To run the script.ts
file, run the following command:
npm run dev