How to Build a Policy Management System in 5 Steps
Handling compliance tasks requires us to have a clear record of which policies are in place and how they change over time.
For this, we need a robust system that enables colleagues to interact with our policy data in a structured way. The trouble is the specific internal workflows involved in this are rarely the same from one organization to the next.
Today, we’re exploring one way of addressing this problem, by building a custom policy management system in Budibase.
By the end, you’ll have a fully working tool that you can easily modify for your own more granular needs in our open-source, low-code platform.
First, though, let’s start with the basics.
What is a policy management system?
As the name suggests, a policy management system is an internal tool that’s used to manage our company policies.
In the simplest examples, this might mean enabling CRUD actions, allowing us to create, read, update, or delete policy data.
More often, though, we’ll need to enforce more sophisticated workflows. For instance, creating approval workflows, where certain types of users can request changes to policies while others can respond to or approve these.
We may even require additional layers of approval or more granular permissions for specific users to request or approve changes to particular kinds of policies.
On top of this, policy management tools can be used to provide a record of how our policies have changed over time, providing oversight and auditability.
So…
What are we building?
We’re building a simple policy management solution on top of an existing Postgres database, although with Budibase, we could just as easily use another RDBMS or NoSQL tool of our choice.
This will be built around two user roles. Editors will be able to submit new versions for existing policies, using the policy_versions. Approvers can then approve these, as well as performing full CRUD actions on our policies table.
Along the way, we’ll provide the queries you need to create a look-alike database so you can build along with our tutorial.
When a new policy_version is approved, the relevant policies table will also be updated to reflect these changes.
This means that we can keep our policies up to date with our real-world requirements, while also providing a clear paper trail for how these have evolved over time.
You might also like our guide to building an engineering change management system .
Let’s get started.
How to build a policy management system in 5 steps
If you haven’t already, sign up for a Budibase account, which will allow you to build as many apps as you like for free.
1. Connecting our database
The first thing we’ll need to do is create a new Budibase app. We could use a pre-built template or an existing application dump, but today we’re starting from scratch.
When we choose this option, we’ll be prompted to give our app a name and URL extension. We’re going to go with Policy Management System.

At this point, we’re offered a range of data sources that we can connect our app to, including RDBMSs, NoSQL tools, APIs, spreadsheets, and more.

As we said earlier, we’re using a PostgreSQL database. When we choose this option, we’ll be prompted to enter our connection details.

Then, we’re offered a choice of which tables we’d like to Fetch, making them queryable within Budibase. Our database has two tables called policies and policy_versions. We’re going to select both.

You can use the following query to create our tables:
1CREATE TABLE policies (
2
3 id SERIAL PRIMARY KEY,
4
5 title TEXT UNIQUE,
6
7 description TEXT,
8
9 category TEXT,
10
11 status TEXT CHECK (status IN ('Active', 'Archived')),
12
13 created_at TIMESTAMP DEFAULT NOW(),
14
15 updated_at TIMESTAMP DEFAULT NOW()
16
17);
18
19CREATE TABLE policy_versions (
20
21 id SERIAL PRIMARY KEY,
22
23 policy_id INTEGER REFERENCES policies(id) ON DELETE CASCADE,
24
25 title TEXT,
26
27 description TEXT,
28
29 category TEXT,
30
31 status TEXT CHECK (status IN ('Draft', 'Pending Approval', 'Approved', 'Rejected', 'Archived')),
32
33 created_at TIMESTAMP DEFAULT NOW(),
34
35 updated_at TIMESTAMP DEFAULT NOW()
36
37);You can populate these with:
1-- Insert Active Policy
2
3INSERT INTO policies (title, description, category, status)
4
5VALUES
6
7 ('IT Security Policy', 'Defines security standards for IT systems', 'Security', 'Active'),
8
9 ('Employee Conduct Policy', 'Guidelines for employee behavior and ethics', 'Human Resources', 'Archived'),
10
11 ('Data Privacy Policy', 'Outlines data handling and privacy practices', 'Compliance', 'Active');
12
13-- Insert Draft Policy Versions
14
15INSERT INTO policy_versions (policy_id, title, description, category, status)
16
17VALUES
18
19 (1, 'IT Security Policy', 'Initial draft of IT security policy', 'Security', 'Draft'),
20
21 (2, 'Employee Conduct Policy', 'Initial draft of employee conduct policy', 'Human Resources', 'Draft'),
22
23 (3, 'Data Privacy Policy', 'Initial draft of data privacy policy', 'Compliance', 'Draft');
24
25-- Insert Pending Approval Policy Versions
26
27INSERT INTO policy_versions (policy_id, title, description, category, status)
28
29VALUES
30
31 (1, 'IT Security Policy', 'Reviewed IT security policy draft', 'Security', 'Pending Approval'),
32
33 (2, 'Employee Conduct Policy', 'Updated draft of employee conduct policy', 'Human Resources', 'Pending Approval');
34
35-- Insert Approved Policy Versions
36
37INSERT INTO policy_versions (policy_id, title, description, category, status)
38
39VALUES
40
41 (1, 'IT Security Policy', 'Final version of IT security policy', 'Security', 'Approved'),
42
43 (3, 'Data Privacy Policy', 'Final version of data privacy policy', 'Compliance', 'Approved');
44
45-- Insert Rejected Policy Versions
46
47INSERT INTO policy_versions (policy_id, title, description, category, status)
48
49VALUES
50
51 (2, 'Employee Conduct Policy', 'Rejected version of employee conduct policy', 'Human Resources', 'Rejected');Here’s how our policies table looks in Budibase’s Data section once we’ve Fetched it.

Altering existing columns
In Budibase, we can make adjustments within the Data section that will then be reflected when we start generating UIs and automations.
Firstly, all of our TEXT columns are handled the same way in our database, but Budibase distinguishes between a few types of textual data. Each database table has a field called description.
We’ll change these from Text to Long Form Text. Remember to repeat this process on both tables.

Next, our tables have a few attributes that we only want to offer defined options for. These are the status and category columns on each table. Our possible categories will be Security, Human Resources, and Compliance, although you could add more to suit your needs.

We’ll change the type for these columns to Single Select and input these options. Again, remember to do this for both tables.
The status in each of our tables will have different options. On the policies table, these will be Active and Archived.

For policy_versions, we’ll use Draft, Pending Approval, Approved, and Rejected.

Default values
We can also add Default Values within the Data layer. These will be used as a fallback when a row is created if a value isn’t specified for a particular column. We’re going to add these across all of our statuses and dates on both tables.
We’ll start with the statusattributes, using the options picker in their settings. On policies, we’ll default to Active.

We’ll repeat this same process to set the default status for our policy_versions table to Pending Approval.
Next, each of our tables contains columns called created_at and updated_at. We want both of these to be populated with the current date and time when a row is created, so we’ll bind their default values to {{ Date }}.

Again, make sure to repeat this process across both date columns in each of our tables.
Configuring relationships and user columns
Our tables already have the data we need to denote relationships. Each one has a unique id attribute. The policy_id attribute under policy_versions then corresponds to the id attribute in the policies table.
We just need to configure this in Budibase.
Start by hitting Define Relationship.

We’re then presented with this modal, where we can set up our relationship.

We’re going to set this so that one row in the policies table links to many rows on policy_versions, using id as our primary key and policy_id as our foreign key.

Now, we can see related rows across each of our tables.

We also want the ability to link rows on our policy_versions table to specific users, but this works a little bit differently since we’re dealing with Budibase’s internal Users table.
We offer two special data types for this, depending on whether we want to relate rows to one user or many.
We’re going to add two Single User columns to our policy_versions table. The first will be called editor. We’re also enabling the option to default to current user.

We’ll then add a second Single User column and call it approver. This time, we don’t need a default value.

Adding user roles
Before we go any further, we’ll need to set up our policy management system’s access roles. As we outlined earlier, we’ll have two roles called editor and approver, who can submit and approve new policy versions, respectively.
We’ll start by hitting Manage Roles. This opens Budibase’s visual RBAC editor.

Here, we can configure our user roles. We can then assign permissions to these elsewhere in the builder.
We’ll start by adding our two roles, editor and approver.

We want our approvers to inherit their permissions from editors. To do this, we’ll place editorto the left ofapprover` and draw a line between them to denote inheritance.

Building role-specific views
We can define what permissions we’ll give to users with each role without leaving the Data section.
That is, we’re going to create database views based on each table that will define which read and write actions each role is granted.
The first thing we need to do is remove their permissions to perform full CRUD actions on the underlying tables. We’ll do this by setting the Access for each table to App Admin, which is the highest user role.

We’ll start by creating views for our policies table, as the permissions within these will be quite simple. Hit Create a View. We’ll be presented with this modal, where we can give our new view a name.
We’ll call this first one Editor Policies.

Then, we’ll set the Access to Editor.

Then, under Columns, we’ll set everything to read-only.

We’ll repeat this process to make a second view called Approver Policies, setting its Access to Approver. This time, we’ll leave all columns readable, except updated_at, which we’ll make read-only.

Next, we’ll create role-specific views for our policy_versions table.
We’ll create one called Editor Versions, with the Access set to Editor. This time, we’ll set all columns except title, description, and category to read-only.

Finally, Approver Policies will have all columns set to read-only, except for status.

And that’s our data model ready to go. Now, we can start generating automation logic and UIs based on this.
2. Setting up approval logic
Our approval management system will rely heavily on a couple of automation rules. So, we want to build these before we start designing our app screens.
Archiving policies
First, we’re going to set up a simple rule that will allow Approvers to set the status of policies to Archived with a button press. To do this, we’ll use a Row Action, which we can generate from the Data section.
Head to the Approver Policies view and hit Create row action.

When prompted, we’ll call this Archive.

This creates an automation rule that’s triggered by user actions on a specific row from the front-end of your app.

We’re going to add a single action step after our trigger. Hit the plus icon. Here, we’ll select Update Row.

We’ll set the Table to policies and hit the lightning bolt icon to open the bindings drawer for our Row ID.

Under Trigger Outputs, we’ll choose id.

Now, the Update Row action will be taken on whichever row triggered our automation rule.
We’ll use then add the status and updated_at fields.

We’ll set status to Archived as a static value. We’ll then bind the updated_at column to {{ date now "" }}, setting the current timestamp.

We can then hit Run Test to confirm that this works.

Approving policy change requests
Next, we’re going to build an automation that will be triggered each time a policy_version is approved. This will take the data from the approved version and use it to update the corresponding row on the policies table.
We’ll start by adding a new automation, which we’ll call Approve. This time, we’re selecting a Row Updated trigger.

We’ll set the Table for our trigger to policy_versions.

Next, we’ll add a condition so that we only continue if {{ trigger.row.status }} equals Approved.

Next, we need to retrieve the relevant policies row that our automation will update. To do this, we’ll add a Query Rows action, pointed at the policies table.

We only want to return the row that’s related to the policy_versions row that’s been approved. So, we’ll add a filter condition so that id equals {{ trigger.row.policy_id }}.

Lastly, we’ll use the id of the row this returns within an Update row action, pointed at the policies table.

We’ll add category, title, updated_at, and description as fields and bind these to their respective values from our trigger output, along with the current timestamp for our updated_at field.

Again, we’ll hit Run Test to confirm that everything works.

3. Building a change request screen
Now that we’ve set up our automation logic, we can start building UIs. Head to the Editor Policies view in the data section and hit Generate App Screen.

Here, we’ll choose the option for a table with modal forms.

Here’s how this will look in the Design section.

The first thing we’ll do is tidy up our existing UI by editing our Headline component and removing any unnecessary columns from our table.

Editors aren’t allowed to add new rows to the policies table, so we can also delete our Create New button and the associated modal form.

Here’s our remaining Edit form.

At present, this updates the policies row that a user clicks on in our table. We’re going to modify this so that it creates a new policy_versions row, linked to the policies row that a user clicks on.
We’ll start by setting our form’s Data to Editor Versions and its Type to Create.

We’ll then remove all fields except policies, title, category, and description.

We’ll also update our display text, then under Styles, set our Button Position to Top.

Now, we don’t want users to have to specify the policy they’re editing. Rather, this should be automatically populated based on which table row they click.
Currently, when a user clicks on a row, a State variable is set, using the clicked row’s _id. We’re going to set a default value for our policy field using this, with {{ State.ID_5Whzvriuv }}. We’ll then select the option to disable this field, so it can’t be written.

Here’s how this will look when we preview our app.

However, we also want to display the current values of our policy data, so that users don’t need to rewrite this from scratch. To do this, we’ll need to expose our form to the entire policies row to which our new version will be related.
We’ll start by nesting our Form Block inside a Data Provider component.

Then, we’ll add a filter to this so that it only returns the single row where _id equals our state variable from before.

We’ll then add default values to our remaining fields in the format {{ [Policies Data Provider].Rows.0.category }}.

Here’s how this looks in our preview.

4. Building approver screens
Next, we’ll start building screens for our Approvers.
Policy CRUD UIs
Firstly, we want to build a screen where users with the Approval role will have full CRUD permissions for our policies table.
We’ll start by generating another table UI with modal forms, this time from our Approver Policies view.

As before, we’ll start by editing our display text and removing any extraneous columns from our table.

For our Create form, we’re removing all columns except title, description, and category. We’ve also moved our button to the top again.

On our Edit form, we’re leaving all columns visible, but setting the ones we don’t need to Disabled, we’ve also arranged them into columns using their Layout settings.

Note that when we generated this screen, it automatically included a button to trigger our Archive row action. Since we have this, we don’t need our Delete button, so we’ll remove that.

Lastly, we need to set our updated_at column to the current timestamp whenever a user updates a row. To do this, we’ll open the actions drawer for our Save button.
Here, we can manually specify a value for our column, using {{ date now “” }}.

Policy version approvals
Next, we’re going to add a screen where users can review and approve new policy_versions.
So, we’ll start by generating a screen from our Approver Versions view.
Again, we’ve updated our display text and removed any columns we don’t need.

Approvers don’t need to create new policy_versions, so we’ll delete the button and modal forms that relate to this. We’ve then replaced our button with an Options Picker with its field and placeholder set to Status.

We’ll set the options for this to match the possible status values from our database, as well as giving it a default value of Pending Approval.

Then, we’ll add a filter to our table so that it only returns rows where status equals {{ [Status Filter Options Picker].Value }}

Now, when we load the screen, only pending requests appear by default.

Lastly, on our remaining form, we’ve set all fields to disabled except for status.

Then, under our save button’s actions, we’ll use the same binding as before to populate an updated_at value, as well as setting our approver column to {{ Current User._id }}.

From a functional point of view, that’s our app done.
5. Design tweaks and publishing
Before we push our policy management system live, we’re going to make a few minor UX improvements.
Firstly, under Screen and Theme, we’ll choose Midnight. While we’re here, we’ll also adjust our app’s color scheme to better reflect the Budibase brand.

Across each of our tables, we’ll use the Label setting to add proper capitalization to our display texts.

We’ll do the same on our forms using the Label and Placeholder settings.

Similarly, we’ll use the Label settings under Navigation to make the entries in our nav bar more appropriate.

Lastly, we’ll remove the navigation entry for our /editor-policies screen since users with the Editor role can only access a single UI.

Here’s a reminder of what our finished app looks like.
Turn data into action with Budibase
Budibase is the open-source, low-code platform that empowers IT teams to turn data into action.
To learn more about why organizations of all sizes choose Budibase to build internal tools, CRUD apps, approval workflows, ticketing systems, and more, head to our features overview.