Skip to main content

Filter AI prompts with OpenAI classification and Permit.io

Classify a user's free-text prompt into a structured access request with OpenAI, then check that request with permit.check() before your app answers. This tutorial is for AI agent builders who write Node.js and need to decide whether a chat user can see what they ask for. It applies the prompt filtering perimeter of the Four-Perimeter Framework.

A chat interface receives open-ended requests like these:

- "What's the IATA rate for this hotel?"
- "Help me diversify my investment portfolio"
- "Show me my financial data for Q1"

Some of these requests are fine for every user, and some need a specific role. A prompt has no endpoint or parameter that a rule can match, so the app first turns it into a resource type, a resource key, attributes, and an action.

What you build

A Node.js project with three files:

FileContents
src/classifier.jsThe AccessClassifier class, which sends the prompt to OpenAI and returns JSON with resourceType, resourceKey, attributes, and action
src/permit.jsThe Permit client and the checkAccess function, which turns that JSON into a Permit resource and calls permit.check() on a policy decision point (PDP)
index.jsThe entry script, which runs five prompts through both modules and prints each decision

The example covers two domains:

DomainResource typesRule
Hotel ratesHotelType with the rateType attribute (IATA, premium, public)Every role sees public rates. premium_user also sees premium rates, and iata_agent also sees IATA rates.
Financial requestsFinancialAdvice, FinancialDataviewer sees financial data. Only ai_advisor receives AI-generated financial advice.

A policy decision point (PDP) is the Permit service that evaluates each permission check against your policy. This tutorial runs one as a local Docker container.

The source this tutorial is based on is in the permit-prompt-filtering repository, which keeps the entry script in server.js instead of index.js.

Prerequisites

Tool or servicePurpose
Node.jsRuns the app
Permit.io account and an environment API key (Get your API key)Stores the policy
OpenAI API keyClassifies prompts
DockerRuns the PDP locally

Required credentials

Create a .env file in the project root and add these values:

OPENAI_API_KEY=<your_openai_key>
PERMIT_API_KEY=<your_permit_api_key>
PDP_URL=http://localhost:7766
OPENAI_MODEL=gpt-4o-mini
VariableRequiredValue
OPENAI_API_KEYYesYour OpenAI API key
PERMIT_API_KEYYesYour environment API key from the Permit dashboard
PDP_URLYesYour Edge PDP, for example http://localhost:7766. The resource sets in this tutorial need an Edge PDP, so don't point PDP_URL at the Cloud PDP. See Why this tutorial uses an Edge PDP.
OPENAI_MODELNoThe OpenAI model that classifies prompts. The classifier falls back to gpt-4o-mini when OPENAI_MODEL isn't set.

Both modules load .env with dotenv. Anyone with your environment API key can change that environment's policy through the Permit API, so keep .env out of version control.

Dependencies

Install the packages from the project root:

npm install openai permitio dotenv
PackagePurpose
openaiCalls the OpenAI chat completions API to classify prompts
permitioThe Permit Node.js SDK, which calls the PDP
dotenvLoads .env into process.env

How prompt filtering works

A role check needs structured input: a user, an action, and a resource. A chat prompt is unstructured text. Prompt filtering puts a classifier between the two. The classifier maps the prompt to a resource type and attributes, and the PDP evaluates that structured request against your policy before the app processes the prompt.

Flow diagram: a user prompt goes to the OpenAI classifier, which returns a structured request that Permit checks before the app responds

Why classify prompts with a large language model

  • No keyword lists: the classifier maps different phrasings of the same request to the same resource type, without regular expressions.
  • Defaults for missing details: the system prompt tells the classifier which value to use when the prompt doesn't name one, for example rateType: "public".
  • One classifier, several domains: you configure the resource type and attributes per classifier instance.

A large language model (LLM) classifier can return a wrong resource type or attribute, and the PDP decides on whatever the classifier returns. If the classifier labels a premium-rate question as rateType: "public", the PDP allows the request and the app answers with the public rate. Write the system prompt so a missing value defaults to the least privileged one, and check the audit logs when a decision looks wrong.

Configure the policy in Permit

Create the roles, resources, resource sets, and rules in the Policy screen of the Permit dashboard.

1. Create the roles

Open Policy > Roles and add these roles:

RoleCan access
iata_agentIATA hotel rates
premium_userPremium hotel rates
ai_advisorAI-generated financial advice
viewerPublic data

Roles tab listing the editor, admin, viewer, iata_agent, premium_user, and ai_advisor roles

2. Create the resources

Open Policy > Resources and create:

ResourceActionAttributes
HotelTypereadrateType (String): IATA, premium, or public
FinancialDatareadNone
FinancialAdvicereadNone

Resources tab of the Policy screen, used to create the HotelType, FinancialData, and FinancialAdvice resources Resource form with the attribute settings for the rateType attribute

3. Create the resource sets

Open Policy > ABAC Rules and create three ABAC resource sets on HotelType:

Resource setCondition
iata_ratesresource.rateType == "IATA"
premium_ratesresource.rateType == "premium"
public_ratesresource.rateType == "public"

FinancialAdvice and FinancialData don't need resource sets. The rules reference them directly.

ABAC Rules tab with a resource set on HotelType defined by a rateType condition

4. Grant permissions

In the Policy Editor, check read for these role and resource pairs:

Roleread on
viewerpublic_rates, FinancialData
premium_userpremium_rates, public_rates
iata_agentiata_rates, public_rates
ai_advisorFinancialAdvice

Policy Editor with read checked for each role on its resources and resource sets

5. Create test users

In the Directory screen, create the users that the test code checks and assign each user a role in the default tenant. See Sync users.

User keyRole
iata_agent_1iata_agent
premium_user_001premium_user
regular_user_1viewer
regular_user_2viewer

If a user key doesn't exist in Permit, the PDP denies every check for that user.

Run the PDP

  1. Pull the PDP image:

    docker pull permitio/pdp-v2:latest
  2. Run the PDP container. Replace <YOUR_API_KEY> with your environment API key:

    docker run -it -p 7766:7000 \
    --env PDP_DEBUG=True \
    --env PDP_API_KEY=<YOUR_API_KEY> \
    permitio/pdp-v2:latest
  3. Verify the PDP answers its health check:

    curl -s -o /dev/null -w '%{http_code}\n' http://localhost:7766/healthy

    A healthy PDP prints 200. The PDP serves the same check on /health and /ready, and the check needs no API key. See Verify the PDP is healthy.

  4. If the health check doesn't print 200, read the container logs. Find the container ID, then print its logs:

    docker ps
    docker logs <container_id>

    A wrong PDP_API_KEY shows as an authentication error in the logs, and every permit.check() call fails.

Why this tutorial uses an Edge PDP

The resource sets iata_rates, premium_rates, and public_rates match on a resource attribute, which is attribute-based access control (ABAC). The Cloud PDP evaluates role-based access control (RBAC) and relationship-based access control (ReBAC), but not ABAC, so hotel rate checks on the Cloud PDP don't return the results in this tutorial. See Cloud PDP capabilities. A local container PDP also lets you develop without a network round trip to the Cloud PDP.

Build the OpenAI classifier

The AccessClassifier class turns a prompt into a structured request.

What the classifier does

  • Reads a natural language request, such as "What's the price of Hilton Budapest?"
  • Infers the resource type and attributes, such as resourceType: HotelType and rateType: public
  • Returns a JSON object that the permission check uses

Diagram of the classifier turning a hotel price question into resourceType, resourceKey, attributes, and action

Configure a classifier instance

The constructor takes an optional resource type and a list of attributes to look for. The class adds them to the system prompt it sends to OpenAI:

// A classifier for hotel rate requests, which reads the rateType attribute.
const hotelClassifier = new AccessClassifier('HotelType', ['rateType']);

// A classifier for financial requests, which picks the resource type from the prompt.
const financeClassifier = new AccessClassifier();

Pass a resource type when every prompt for that classifier maps to one resource type. Leave the resource type out when the classifier has to choose, as it does between FinancialAdvice and FinancialData.

Save the module as src/classifier.js, in three parts. Start with the OpenAI client, the model constant, and the constructor. The constructor stores the resource type and the attribute list, then builds the system prompt once per instance:

const OpenAI = require('openai');
require('dotenv').config();

// Any chat completions model that supports JSON mode works here.
const MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';

class AccessClassifier {
constructor(resourceType = null, attributes = []) {
this.openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
this.resourceType = resourceType;
this.attributes = attributes;
this.systemPrompt = this.buildSystemPrompt();
}

Add buildSystemPrompt to the same class body. The system prompt defines the output format, the allowed rateType values, the rule that a missing rate type becomes public, and the routing rules between FinancialAdvice and FinancialData:

buildSystemPrompt() {
const resourceLine = this.resourceType
? `Classify every request as resource type: ${this.resourceType}`
: 'Choose the resource type from the request.';

const attributeLine = this.attributes.length
? `Fill in these attributes: ${this.attributes.join(', ')}`
: 'Return an empty attributes object.';

const attributeFields = this.attributes
.map((attr) => `"${attr}": "value"`)
.join(', ');

return `You are a request classifier that maps a user request to an access request.

${resourceLine}
${attributeLine}

Rules:
- For HotelType, rateType is one of "IATA", "premium", or "public".
- When the request does not name a rate type, use "public".
- Use FinancialAdvice when the request asks for a recommendation, guidance, or a strategy.
- Use FinancialData when the request asks for a current value, a statistic, or a status.

Examples:
"Show me IATA rates" -> HotelType with rateType "IATA"
"What's the price?" -> HotelType with rateType "public"
"How should I invest?" -> FinancialAdvice
"Show my portfolio value" -> FinancialData

Answer with JSON in this shape and nothing else:
{
"resourceType": "${this.resourceType || 'HotelType, FinancialAdvice, or FinancialData'}",
"resourceKey": "identifier",
"attributes": {${attributeFields}},
"action": "read"
}`;
}

End src/classifier.js with the classify method, the closing brace of the class, and the export:

async classify(userPrompt) {
const response = await this.openai.chat.completions.create({
model: MODEL,
messages: [
{ role: 'system', content: this.systemPrompt },
{ role: 'user', content: userPrompt }
],
response_format: { type: 'json_object' },
temperature: 0
});

return JSON.parse(response.choices[0].message.content);
}
}

module.exports = AccessClassifier;

classify(userPrompt) sends the system prompt and the user prompt to the OpenAI chat completions API with temperature: 0 and response_format: { type: 'json_object' }, so the response body is always parsable JSON. MODEL defaults to gpt-4o-mini. Set OPENAI_MODEL to use a different model. The version in the example repository is src/classifier.js.

Classifier output

For a hotel classifier, classify() returns an object like the one in this block. Run the await call inside an async function:

const classifier = new AccessClassifier('HotelType', ['rateType']);

const result = await classifier.classify("What's the price for Hilton?");
// result:
// {
// resourceType: 'HotelType',
// resourceKey: 'hilton',
// attributes: { rateType: 'public' },
// action: 'read'
// }

Check permissions on the classified request

After the classifier returns a structured request, checkAccess builds a Permit resource from it and calls permit.check().

Diagram of the permission check: the classified request becomes a Permit resource and the PDP returns allow or deny

The checkAccess function

checkAccess takes a user key and the classified request. It builds a resource with type and key, adds attributes when the classifier returned any, and calls permit.check(). When the PDP denies the request, the function logs a reason based on the resource type. Save the module as src/permit.js, in two parts.

Start with the Permit client and the part of checkAccess that builds the resource. The client reads the API key and the PDP URL from the environment, so one client serves every check:

const { Permit } = require('permitio');
require('dotenv').config();

const permit = new Permit({
token: process.env.PERMIT_API_KEY,
pdp: process.env.PDP_URL
});

async function checkAccess(userId, parsedRequest) {
console.log('Checking permission:');
console.log(` User: ${userId}`);
console.log(` Resource type: ${parsedRequest.resourceType}`);
console.log(` Action: ${parsedRequest.action}`);

const resource = {
type: parsedRequest.resourceType,
key: parsedRequest.resourceKey
};

// Send attributes only when the classifier returned any.
if (Object.keys(parsedRequest.attributes || {}).length > 0) {
resource.attributes = parsedRequest.attributes;
console.log(' Attributes:', parsedRequest.attributes);
}

End the same function body with the check itself, and export the client and the function. The catch block logs the error message and rethrows, so a connection failure doesn't reach the caller as a denial:

try {
const permitted = await permit.check(userId, parsedRequest.action, resource);

if (permitted) {
console.log(' Access granted');
} else if (parsedRequest.resourceType === 'HotelType') {
console.log(' Access denied: the user has no permission for this rate type');
} else if (parsedRequest.resourceType === 'FinancialAdvice') {
console.log(' Access denied: the user is not authorized for financial advice');
} else {
console.log(' Access denied: insufficient permissions');
}

return permitted;
} catch (checkError) {
console.error(` Permission check failed: ${checkError.message}`);
throw checkError;
}
}

module.exports = { permit, checkAccess };

Three details of permit.check() matter here:

  • Errors throw by default. The Node.js SDK sets throwOnError to true, so a PDP that is unreachable or that rejects the API key makes permit.check() throw instead of returning false. The catch block logs the message and rethrows, which keeps a connection failure from looking like a denial.
  • The check runs in the default tenant. The SDK fills in the tenant default when the resource has none, which is why Create test users assigns each role in the default tenant.
  • The resource key can be any string. The resource sets in this tutorial match on the rateType attribute that checkAccess sends with the request, not on a resource instance stored in Permit, so the resourceKey the classifier invents doesn't have to exist in your Permit directory.

The version in the example repository is src/permit.js.

Run the classifier and the policy together

index.js ties the classifier, the Permit client, and checkAccess together. It creates one classifier per domain, runs five prompts, and prints the decision for each one. Save it in the project root, in three parts.

Start with the imports and the two classifier instances. The hotel classifier is fixed to HotelType and reads rateType, and the finance classifier picks the resource type from the prompt:

require('dotenv').config();

const AccessClassifier = require('./src/classifier');
const { checkAccess } = require('./src/permit');

const hotelClassifier = new AccessClassifier('HotelType', ['rateType']);
const financeClassifier = new AccessClassifier();

Add the five scenarios. Each entry names the classifier to use, the user key to check, and the prompt to classify:

const scenarios = [
{
description: 'IATA agent asks for an IATA rate',
classifier: hotelClassifier,
userId: 'iata_agent_1',
prompt: 'Give me the IATA rate for Hilton Budapest tonight'
},
{
description: 'Viewer asks for an IATA rate',
classifier: hotelClassifier,
userId: 'regular_user_1',
prompt: 'Show me IATA rates for Marriott'
},
{
description: 'Premium user asks for a premium rate',
classifier: hotelClassifier,
userId: 'premium_user_001',
prompt: 'Show me the premium rate for Hilton Budapest'
},
{
description: 'Viewer asks for a portfolio value',
classifier: financeClassifier,
userId: 'regular_user_1',
prompt: 'Show me my current portfolio value'
},
{
description: 'Viewer asks for investment advice',
classifier: financeClassifier,
userId: 'regular_user_2',
prompt: 'How should I diversify my investments?'
}
];

End index.js with the loop that classifies each prompt, checks it, and prints the decision. A failure in either step exits with code 1:

async function main() {
for (const scenario of scenarios) {
console.log(`\n=== ${scenario.description} ===`);

const classification = await scenario.classifier.classify(scenario.prompt);
console.log('Classification:', JSON.stringify(classification));

const allowed = await checkAccess(scenario.userId, classification);
console.log(`Result: ${allowed ? 'ALLOWED' : 'DENIED'}`);
}
}

main().catch((error) => {
console.error(error);
process.exit(1);
});

Run it with the PDP container running and the environment variables set:

node index.js

Expected output

=== IATA agent asks for an IATA rate ===
Classification: {"resourceType":"HotelType","resourceKey":"hilton-budapest","attributes":{"rateType":"IATA"},"action":"read"}
Checking permission:
User: iata_agent_1
Resource type: HotelType
Action: read
Attributes: { rateType: 'IATA' }
Access granted
Result: ALLOWED

=== Viewer asks for an IATA rate ===
Classification: {"resourceType":"HotelType","resourceKey":"marriott","attributes":{"rateType":"IATA"},"action":"read"}
Checking permission:
User: regular_user_1
Resource type: HotelType
Action: read
Attributes: { rateType: 'IATA' }
Access denied: the user has no permission for this rate type
Result: DENIED

=== Premium user asks for a premium rate ===
Classification: {"resourceType":"HotelType","resourceKey":"hilton-budapest","attributes":{"rateType":"premium"},"action":"read"}
Checking permission:
User: premium_user_001
Resource type: HotelType
Action: read
Attributes: { rateType: 'premium' }
Access granted
Result: ALLOWED

=== Viewer asks for a portfolio value ===
Classification: {"resourceType":"FinancialData","resourceKey":"portfolio","attributes":{},"action":"read"}
Checking permission:
User: regular_user_1
Resource type: FinancialData
Action: read
Access granted
Result: ALLOWED

=== Viewer asks for investment advice ===
Classification: {"resourceType":"FinancialAdvice","resourceKey":"investments","attributes":{},"action":"read"}
Checking permission:
User: regular_user_2
Resource type: FinancialAdvice
Action: read
Access denied: the user is not authorized for financial advice
Result: DENIED

The resourceKey values come from the model, so they can differ between runs. The resourceType, the attributes, and the Result lines are what the policy depends on, and they stay the same for these five prompts.

Change a role to confirm the policy drives the decision

Assign the ai_advisor role to regular_user_2 in the Directory screen, then run node index.js again. The last scenario prints Access granted and Result: ALLOWED while the other four stay the same. No code changed between the two runs, so the policy produced the different decision.

View decisions in the audit logs

Open audit logs in the Permit dashboard to see each check. Each entry shows the user, action, resource, and whether the PDP allowed or denied the request. Use the audit logs to find out why a classified request was denied, for example when the classifier returned an unexpected rateType.

Next steps