#refineweek: Audit Log With refine

#refineweek: Audit Log With refine

In this post, we apply refine's built-in audit logging functionality to our Pixels Admin app and to the Pixels client app that we built previously in this refineWeek series. refine's audit logging system comes already baked into its data hooks and inside supplemental data provider packages, like the @pankod/refine-supabase. Today we are going to get it to work by using the auditLogProvider prop.

This is Day 7, and refineWeek is a quickfire tutorial guide that aims to help developers learn the ins-and-outs of refine's powerful capabilities and get going with refine within a week.

Overview

In this series, we have been exploring refine's internals by building two apps: the Pixels client that allows users to create and collboratively draw on a canvas, and the Pixels Admin app that allows admins and editors to manage canvases created by users.

We implemented CRUD actions for Pixels client app on Day 3 and for the admin app on Day 5. In this episode, we enable audit logging on database mutations by defining the auditLogProvider object and passing it to <Refine />.

We are using refine's supplemental Supabase @pankod/refine-supabase package for our dataProvider client. The database mutation methods in Supabase dataProvider already come with audit logging mechanism implemented on them. For each successful database mutation, i.e. create, update and delete actions, a log event is emitted and a params object representing the change is made available to the auditLogProvider.create() method.

We will store the log entries in a logs table in our Supabase database. So, we have to set up the logs table with a shape that complies with the params object sent from the mutation.

We will start by examining the shape of the params object and specifying how the logs table should look like - before we go ahead and create the table with appropriate columns from our Supabase dashboard. We will then work on the auditLogProvider methods, and use the useLogList() hook to list pixels logs inside a modal for each canvas item. Finally, like we did in other parts, we will dig into the existing code to explore how refine emits a log event and how mutation methods implement audit logging under the hood.

Let's dive in!

logs Table for refine Audit Logs

We need to set up the logs table from the Supabase dashboard. But let's first figure out the columns we need for the table. The table should have as columns the properties of the log params object emitted by a mutation.

refine's Log Params Object

A successful resource create action makes the following log params object available to the auditLogProvider.create() method:

{
  "action": "create",
  "resource": "pixels",
  "data": {
    "id": "1",
    "x": "3",
    "y": "3",
    "color": "cyan",
  },
  "meta": {
    "dataProviderName": "Google",
    "id": "1"
  }
}

This object should be passed to the audit log provider's create method in order to create a new record in the logs table.

Likewise, the update and delete actions of a resource - for example, pixels - emit an object with similar, overlapping variations. More on that here.

It is important not to confuse a resource create action with that of the auditLogProvider. The resource create action is carried out by the dataProvider.create() method and produces the log params object. The auditLogProvider.create() method consumes the params object and creates an entry in the logs table.

For our case, we are focused on logging the pixels create actions on a canvas in our Pixels client app.

The meta Object

Notice, the meta.id property on the log params object above. It represents the id of the resource item on which the event was created.

It is possible to append useful data to the meta field by passing the data to the metaData object when the mutation is invoked from a hook. For example, we can add the canvas property to the metaData object inside the argument passed to the mutate function of the useCreate() hook:

const { mutate } = useCreate();

mutate({
  resource: "pixels",
  values: { x, y, color, canvas_id: canvas?.id, user_id: identity.id },
  metaData: {
    canvas,
  },
});

And it will be included in the log params object's meta field:

{
  "action": "create",
  "resource": "pixels",
  "author": {
    "id": ""
    // ...other_properties
  },
  "data": {
    "id": "1",
    "x": "3",
    "y": "3",
    "color": "cyan",
  },
  "meta": {
    "dataProviderName": "Google",
    "id": "1",
    "canvas": {
      "id": "",
      // ...etc.
    },
  }
}

Properties inside the meta object are handy for filtering get requests to the logs table. We are going to use this when we define the auditLogProvider.get() method.

Notice also the author property. It is added when a user is authenticated. Otherwise, it is excluded.

The logs Table

Emanating from the log params object above, our logs table looks like this:

react crud app


Let's go ahead and create this table from our Supabase dashboard before we move forward and start working on the auditLogProvider methods.

<Refine />'s auditLogProvider Object

<Refine />'s audit log provider object should have three methods. It has the following type signature:

const auditLogProvider = {
  create: (params: {
    resource: string;
    action: string;
    data?: any;
    author?: {
      name?: string;
      [key: string]: any;
    };
    previousData?: any;
    meta?: Record<string, any>;
  }) => void;
  get: (params: {
    resource: string;
    action?: string;
    meta?: Record<string, any>;
    author?: Record<string, any>;
    metaData?: MetaDataQuery;
  }) => Promise<any>;
  update: (params: {
    id: BaseKey;
    name: string;
  }) => Promise<any>;
};

Based on this, our auditLogProvider looks like this:

// providers/auditLogProvider.ts

import { AuditLogProvider } from "@pankod/refine-core";
import { dataProvider } from "@pankod/refine-supabase";
import { supabaseClient } from "utility";

export const auditLogProvider: AuditLogProvider = {
  create: params => {
    return dataProvider(supabaseClient).create({
      resource: "logs",
      variables: params,
    });
  },
  update: async ({ id, name }) => {
    const { data } = await dataProvider(supabaseClient).update({
      resource: "logs",
      id,
      variables: { name },
    });

    return data;
  },
  get: async ({ resource, meta }) => {
    const { data } = await dataProvider(supabaseClient).getList({
      resource: "logs",
      filters: [
        {
          field: "resource",
          operator: "eq",
          value: resource,
        },
        {
          field: "meta->canvas->id",
          operator: "eq",
          value: `"${meta?.canvas?.id}"`,
        },
      ],
      sort: [{ order: "desc", field: "created_at" }],
    });

    return data;
  },
};

We'll analyze all three methods in the below sections.

create

The create method is very straightforward. It just takes the log params object sent when the log event was emitted, and adds an entry to the logs table.

It is called when any of the three mutation actions, namely create, update and delete is completed successfully.

update

The update method is similar. Our implementation allows updating the name of the log item. Hence we need to add a name column in our database. If you haven't already noticed it, we have a name column in our logs table and this is the reason. The update methods queries the database with the id of the log entry and allows updating its name. More information is available in this section.

get

The get method is the most significant of the three, especially with the use of the meta argument. What we're doing first is using the dataProvider.getList() method to query the logs table.

Then inside the filters array, we're first filtering log records with the resource field and then with the nested embedded field of meta->canvas->id. As we will encounter in the next section, the canvas property will be appended to the meta field of the log params object. It will be done by passing the canvas to the metaData object of the argument passed to the mutation method of useCreate() data hook. It will therefore be stored in the log record.

When we want to query the logs table, we will use the useLogList() audit log hook that consumes the get() method. The meta?.canvas?.id comes from the meta argument passed to useLogList().

With this done, we are ready to log all pixels creations and show the pixels log list for each of our canvases.

Audit Logging with refine

In order to enable audit logging feature in our app, we have to first pass the auditLogProvider prop to <Refine />. Since pixels are being created in the Pixels app, that's where we are going to implement it:

// App.tsx

<Refine
  ...
  auditLogProvider={auditLogProvider}
/>

This makes all database mutations emit a log event and send the log params object towards the auditLogProvider.create() method. Mutations that emit an event are create(), update() and delete() methods of the dataProvider object.

When these methods are consumed from components using corresponding hooks, and given the logs table is set up properly, a successful mutation creates an entry in the logs table.

Audit Log create Action

In the Pixels app, pixels are created by the onSubmit() event handler defined inside the <CanvasShow /> component. The <CanvasShow /> component looks like this:

// pages/canvases/show.tsx
import { useState } from "react";
import {
    useCreate,
    useGetIdentity,
    useNavigation,
    useShow,
} from "@pankod/refine-core";
import {
    Button,
    Typography,
    Icons,
    Spin,
    Modal,
    useModal,
} from "@pankod/refine-antd";

import { CanvasItem, DisplayCanvas } from "components/canvas";
import { ColorSelect } from "components/color-select";
import { AvatarPanel } from "components/avatar";
import { colors } from "utility";
import { Canvas } from "types";
import { LogList } from "components/logs";

const { LeftOutlined } = Icons;
const { Title } = Typography;

export const CanvasShow: React.FC = () => {
    const [color, setColor] = useState<typeof colors[number]>("black");
    const { modalProps, show, close } = useModal();

    const { data: identity } = useGetIdentity();
    const {
        queryResult: { data: { data: canvas } = {} },
    } = useShow<Canvas>();
    const { mutate } = useCreate();
    const { list, push } = useNavigation();

    const onSubmit = (x: number, y: number) => {
        if (!identity) {
            return push("/login");
        }

        if (typeof x === "number" && typeof y === "number" && canvas?.id) {
            mutate({
                resource: "pixels",
                values: {
                    x,
                    y,
                    color,
                    canvas_id: canvas?.id,
                    user_id: identity.id,
                },
                metaData: {
                    canvas,
                },
                successNotification: false,
            });
        }
    };

    return (
        <div className="container">
            <div className="paper">
                <div className="paper-header">
                    <Button
                        type="text"
                        onClick={() => list("canvases")}
                        style={{ textTransform: "uppercase" }}
                    >
                        <LeftOutlined />
                        Back
                    </Button>
                    <Title level={3}>{canvas?.name ?? canvas?.id ?? ""}</Title>
                    <Button type="primary" onClick={show}>
                        View Changes
                    </Button>
                </div>
                <Modal
                    title="Canvas Changes"
                    {...modalProps}
                    centered
                    destroyOnClose
                    onOk={close}
                    onCancel={() => {
                        close();
                    }}
                    footer={[
                        <Button type="primary" key="close" onClick={close}>
                            Close
                        </Button>,
                    ]}
                >
                    <LogList currentCanvas={canvas} />
                </Modal>

                {canvas ? (
                    <DisplayCanvas canvas={canvas}>
                        {(pixels) =>
                            pixels ? (
                                <div
                                    style={{
                                        display: "flex",
                                        justifyContent: "center",
                                        gap: 48,
                                    }}
                                >
                                    <div>
                                        <ColorSelect
                                            selected={color}
                                            onChange={setColor}
                                        />
                                    </div>
                                    <CanvasItem
                                        canvas={canvas}
                                        pixels={pixels}
                                        onPixelClick={onSubmit}
                                        scale={(20 / (canvas?.width ?? 20)) * 2}
                                        active={true}
                                    />
                                    <div style={{ width: 120 }}>
                                        <AvatarPanel pixels={pixels} />
                                    </div>
                                </div>
                            ) : (
                                <div className="spin-wrapper">
                                    <Spin />
                                </div>
                            )
                        }
                    </DisplayCanvas>
                ) : (
                    <div className="spin-wrapper">
                        <Spin />
                    </div>
                )}
            </div>
        </div>
    );
};

The mutate() function being invoked inside onSubmnit() handler is destrcutured from the useCreate() hook. We know that audit logging has been activated for the useCreate() hooks, so a successful pixels creation sends the params object to auditLogProvider.create method.

Notice that we are passing the currentCanvas as metaData.canvas, which we expect to be populated inside the meta property of the log params object. As we'll see below, we are going to use it to filter our GET request to the logs table using useLogList() hook.

Audit Log List with refine

We are going to display the pixels log list for a canvas in the <LogList /> component. In the Pixels app, it is contained in the <CanvasShow /> page and housed inside a modal accessible by clicking on the View Changes button. The <LogList /> component uses the useLogList() hook to query the logs table:

// components/logs/list.tsx

import React from "react";
import { useLogList } from "@pankod/refine-core";
import { Avatar, AntdList, Typography } from "@pankod/refine-antd";
import { formattedDate, timeFromNow } from "utility/time";

type TLogListProps = {
    currentCanvas: any;
};

export const LogList = ({ currentCanvas }: TLogListProps) => {
  const { data } = useLogList({
    resource: "pixels",
    meta: {
      canvas: currentCanvas,
    },
  });

  return (
    <AntdList
      size="small"
      dataSource={data}
      renderItem={(item: any) => (
        <AntdList.Item>
          <AntdList.Item.Meta
            avatar={
              <Avatar
                src={item?.author?.user_metadata?.avatar_url}
                size={20}
              />
            }
          />
          <Typography.Text style={{ fontSize: "12px" }}>
            <strong>{item?.author?.user_metadata?.email}</strong>
            {` ${item.action}d a pixel on canvas: `}
            <strong>{`${item?.meta?.canvas?.name} `}</strong>
            <span
              style={{ fontSize: "10px", color: "#9c9c9c" }}
            >{`${formattedDate(item.created_at)} - ${timeFromNow(
              item.created_at,
            )} ago`}</span>
          </Typography.Text>
        </AntdList.Item>
      )}
    />
  );
};

If we examine closely, the meta property of the argument object passed to useLogList() hook contains the canvas against which we want to filter the logs table. If we revisit the auditLogProvider.create method, we can see that the value field of the second filter corresponds to this canvas:

{
  field: "meta->canvas->id",
  operator: "eq",
  value: `"${meta?.canvas?.id}"`,
}

We are doing this to make sure that we are getting only the logs for the current canvas.

With this completed, if we ask for the modal in the CanvasShow page, we should be able to see the pixels log list:

react crud app


We don't have a case for creating a pixel in the Pixels Admin app. But we can go ahead and implement the same pixels <LogList /> component for each canvas item in the <CanvasList /> page at /canvases. The code is essentially the same, but the View Changes button appears inside each row in the table:

react crud app


Low Level Inspection

We are now going to examine how audit logging comes built-in inside refine's mutation hooks.

Log params Object

We mentioned earlier that each successful mutation emits a log event and sends a params object to the auditLogProvider.create() method. Let's dig into the code to see how it is done.

The log params object is sent to the auditLogProvider.create() method from inside the log object returned from the useLog() hook:

// @pankod/refine-core/src/hooks/useLog/index.ts/useLog/log
// v3.90.6
const log = useMutation<TLogData, Error, LogParams, unknown>(
  async (params) => {
    const resource = resources.find((p) => p.name === params.resource);
    const logPermissions = resource?.options?.auditLog?.permissions;

    if (logPermissions) {
      if (!hasPermission(logPermissions, params.action)) {
        return;
      }
    }

    let authorData;
    if (isLoading) {
      authorData = await refetch();
    }

    return await auditLogContext.create?.({
      ...params,
      author: identityData ?? authorData?.data,
    });
  },
);

As we can see above, params is made available by reaching the provider via the auditLogContext.create() method.

Prior to that, the log object here utilizes react-query's useMutation() hook to catch the results of the mutation with an observer and emit the event.

Inside Mutation Hooks

Inside mutation hooks, the useLog() hook is used to create a log automatically after a successful resource mutation. For example, the useCreate() data hook implements it with the mutate method on log object returned from useLog():

// @pankod/refine-core/src/hooks/data/useCreate.ts
// v3.90.6

log?.mutate({
  action: "create",
  resource,
  data: values,
  meta: {
    dataProviderName: pickDataProvider(
      resource,
      dataProviderName,
      resources,
    ),
    id: data?.data?.id ?? undefined,
    ...rest,
  },
});

The code snippets above are enough to give us a peek inside what is going, but feel free to explore the entire files for more insight.

Summary

In this episode, we activated refine's built-in audit logging feature by defining and passing the auditLogProviderprop to <Refine />. We we learned that refine implements audit logging from its resource mutation hooks by sending a log params object to the auditProvider.create() method, and when audit loggin is activated, every successful mutation creates an entry in the logs table.

We implemented audit logging for create actions of the pixels resource in our Pixels app and saved the entries in a logs table in our Supabase database. We then fetched the pixel creation logs for each canvas using the useLoglist() hook and displayed the in a modal. We leverage the meta property of the log params object in order to filter our auditProvider.get() request.

Series Wrap Up

In this refineWeek series, built the following two apps with refine:

Pixels - the client app that allows users to create a canvas and draw collaboratively on Pixels Admin - the admin dashboard that helps managers manage users and canvases

While building these twp apps, we have covered core refine concepts like the providers and hooks in significant depth. We had the opportunity to use majority of the providers with the features we added to these apps. Below is the brief outline of the providers we learned about:

  • authProvider: used to handling authentication. We used it to implement email / password based authentiction as well as social logins with Google and GitHub.
  • dataProvider: used to fetch data to and from a backend API by sending HTTP requests. We used the supplementary Supabase package to build a gallery of canvases, a public dashboard and a private dashboard for role based managers.
  • routerProvider: used for routing. We briefly touched over how it handles routing and resources following RESTful conventions.
  • liveProvider: used to implement real time Publish Subscribe features. We used it for allowing users to draw pixels collaboratively on a canvas.
  • accessControlProvider: used to implement authorization. We implemented a Role Based Access Control authorization for editor and admin roles.
  • auditLogProvider: used for logging resource mutations. We used it to log and display pixels drawing activities on a canvas.
  • notificationProvider: used for posting notifications for resource actions. We did not cover it, but used it inside our code.

There are more to refine than what we have covered in this series. We have made great strides in covering these topics so far by going through the documentation, especially to understand the provider - hooks interactions.

We also covered supplementary Supabase anhd Ant Design packages. refine has fantastic support for Ant Design components. And we have seen how refine-antd components complement data fetching by the data providers and help readily present the response data with hooks like useSimpleList(), useTable() and useEditableTable().

We can always build on what we have covered so far. There are plenty of things that we can do moving froward, like customizing the layout, header, auth pages, how exactly the notificationProvider works, how to implement the i18nProvider, etc.

Please feel free to reach out to the refine team or join the refine Discord channel to learn more and / or contribute!