Use when implementing row-level security (RLS) in Bknd. Covers filter policies, user ownership patterns, public/private records, entity-specific RLS, multi-tenant isolation, and data-level access control.
npx claudepluginhub cameronapak/bknd-expert --plugin bknd-research-skillsThis skill uses the workspace's default tool permissions.
Implement data-level access control using filter policies to restrict which records users can access.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
Implement data-level access control using filter policies to restrict which records users can access.
auth: { enabled: true })guard: { enabled: true })user_id)UI steps: Admin Panel > Auth > Roles > Select role
Note: RLS configuration requires code mode. UI is read-only.
Ensure entity has a field to track ownership:
import { serve } from "bknd/adapter/bun";
import { em, entity, text, number } from "bknd";
const schema = em({
posts: entity("posts", {
title: text().required(),
content: text(),
user_id: number().required(), // Ownership field
}),
});
Users can only read their own records:
serve({
connection: { url: "file:data.db" },
config: {
data: schema.toJSON(),
auth: {
enabled: true,
guard: { enabled: true },
roles: {
user: {
implicit_allow: false,
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [
{
description: "Users read own records only",
effect: "filter",
filter: { user_id: "@user.id" },
},
],
},
],
},
},
},
},
});
| Component | Purpose |
|---|---|
effect: "filter" | Apply row-level filtering (not allow/deny) |
filter | Query conditions added to every request |
@user.id | Variable replaced with current user's ID |
When user with ID 5 queries posts, the filter transforms:
// User's query
api.data.readMany("posts", { where: { status: "published" } });
// Becomes (with RLS filter applied)
api.data.readMany("posts", { where: { status: "published", user_id: 5 } });
Apply RLS to all operations:
{
roles: {
user: {
implicit_allow: false,
permissions: [
// Read: own records
{
permission: "data.entity.read",
effect: "allow",
policies: [{
effect: "filter",
filter: { user_id: "@user.id" },
}],
},
// Create: allowed (user_id set via hook/plugin)
{ permission: "data.entity.create", effect: "allow" },
// Update: own records
{
permission: "data.entity.update",
effect: "allow",
policies: [{
effect: "filter",
filter: { user_id: "@user.id" },
}],
},
// Delete: own records
{
permission: "data.entity.delete",
effect: "allow",
policies: [{
effect: "filter",
filter: { user_id: "@user.id" },
}],
},
],
},
},
}
Different RLS rules per entity:
{
roles: {
user: {
implicit_allow: false,
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [
// Posts: filter by author
{
condition: { entity: "posts" },
effect: "filter",
filter: { author_id: "@user.id" },
},
// Comments: filter by user
{
condition: { entity: "comments" },
effect: "filter",
filter: { user_id: "@user.id" },
},
// Categories: no filter (public)
{
condition: { entity: "categories" },
effect: "allow",
},
],
},
],
},
},
}
Users see public records AND their own private records:
{
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [
{
condition: { entity: "posts" },
effect: "filter",
filter: {
$or: [
{ is_public: true }, // Public posts
{ user_id: "@user.id" }, // Own posts
],
},
},
],
},
],
}
Authors see their drafts, everyone sees published:
{
roles: {
author: {
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [
{
condition: { entity: "posts" },
effect: "filter",
filter: {
$or: [
{ status: "published" }, // Anyone can read published
{ author_id: "@user.id" }, // Author reads own drafts
],
},
},
],
},
],
},
viewer: {
is_default: true,
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [
{
condition: { entity: "posts" },
effect: "filter",
filter: { status: "published" }, // Only published
},
],
},
],
},
},
}
Isolate data by organization/tenant:
const schema = em({
organizations: entity("organizations", {
name: text().required(),
}),
projects: entity("projects", {
name: text().required(),
org_id: number().required(),
}),
tasks: entity("tasks", {
title: text().required(),
org_id: number().required(),
}),
});
// Assuming user has org_id field
{
roles: {
member: {
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [
{
condition: { entity: { $in: ["projects", "tasks"] } },
effect: "filter",
filter: { org_id: "@user.org_id" },
},
],
},
{
permission: "data.entity.create",
effect: "allow",
policies: [
{
condition: { entity: { $in: ["projects", "tasks"] } },
effect: "allow",
},
],
},
],
},
},
}
Users access records belonging to their team:
// Assuming user has team_id field
{
roles: {
team_member: {
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [{
effect: "filter",
filter: { team_id: "@user.team_id" },
}],
},
{
permission: "data.entity.update",
effect: "allow",
policies: [{
effect: "filter",
filter: { team_id: "@user.team_id" },
}],
},
],
},
},
}
Manager sees their reports' data:
// Manager sees records where:
// - They own the record, OR
// - Record belongs to someone they manage
// Note: This pattern may require custom logic via hooks
{
roles: {
manager: {
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [{
effect: "filter",
filter: {
$or: [
{ user_id: "@user.id" },
{ manager_id: "@user.id" },
],
},
}],
},
],
},
},
}
{
roles: {
anonymous: {
is_default: true,
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [{
condition: { entity: "posts" },
effect: "filter",
filter: { is_public: true },
}],
},
],
},
user: {
permissions: [
// Read: public + own
{
permission: "data.entity.read",
effect: "allow",
policies: [{
condition: { entity: "posts" },
effect: "filter",
filter: {
$or: [
{ is_public: true },
{ user_id: "@user.id" },
],
},
}],
},
// Create/Update/Delete: own only
{ permission: "data.entity.create", effect: "allow" },
{
permission: "data.entity.update",
effect: "allow",
policies: [{
effect: "filter",
filter: { user_id: "@user.id" },
}],
},
{
permission: "data.entity.delete",
effect: "allow",
policies: [{
effect: "filter",
filter: { user_id: "@user.id" },
}],
},
],
},
},
}
Admin sees everything, users see own:
{
roles: {
admin: {
implicit_allow: true, // No RLS filters applied
},
user: {
permissions: [
{
permission: "data.entity.read",
effect: "allow",
policies: [{
effect: "filter",
filter: { user_id: "@user.id" },
}],
},
],
},
},
}
RLS filters query results but you also need to set ownership on creation.
// Frontend code
const api = new Api({ baseUrl: "http://localhost:7654/api" });
const user = await api.auth.me();
await api.data.createOne("posts", {
title: "My Post",
user_id: user.id, // Client sets ownership
});
Use Bknd events to auto-set ownership:
import { serve } from "bknd/adapter/bun";
import { DataRecordMutatingEvent } from "bknd";
serve({
connection: { url: "file:data.db" },
config: {
data: schema.toJSON(),
auth: { /* ... */ },
},
options: {
onBuild: async (app) => {
const events = app.modules.get("events");
events.on(DataRecordMutatingEvent, async (event) => {
if (event.data.action === "create") {
const authModule = app.modules.get("auth");
const user = await authModule.resolveAuthFromRequest(event.data.ctx?.request);
if (user && !event.data.record.user_id) {
event.data.record.user_id = user.id;
}
}
});
},
},
});
# User 1
curl -X POST http://localhost:7654/api/auth/password/register \
-H "Content-Type: application/json" \
-d '{"email": "user1@test.com", "password": "pass123"}'
# User 2
curl -X POST http://localhost:7654/api/auth/password/register \
-H "Content-Type: application/json" \
-d '{"email": "user2@test.com", "password": "pass123"}'
# Login as user1
TOKEN1=$(curl -s -X POST http://localhost:7654/api/auth/password/login \
-H "Content-Type: application/json" \
-d '{"email": "user1@test.com", "password": "pass123"}' | jq -r '.token')
# Create post
curl -X POST http://localhost:7654/api/data/posts \
-H "Authorization: Bearer $TOKEN1" \
-H "Content-Type: application/json" \
-d '{"title": "User1 Post", "user_id": 1}'
# Login as user2
TOKEN2=$(curl -s -X POST http://localhost:7654/api/auth/password/login \
-H "Content-Type: application/json" \
-d '{"email": "user2@test.com", "password": "pass123"}' | jq -r '.token')
# Query posts - should NOT see user1's posts
curl http://localhost:7654/api/data/posts \
-H "Authorization: Bearer $TOKEN2"
# Expected: empty array or only user2's posts
# User2 try to update user1's post - should fail or affect 0 rows
curl -X PATCH http://localhost:7654/api/data/posts/1 \
-H "Authorization: Bearer $TOKEN2" \
-H "Content-Type: application/json" \
-d '{"title": "Hacked!"}'
# Expected: 404 or 0 affected (record filtered out)
Problem: RLS filter not restricting data
Fix: Ensure guard is enabled:
{
auth: {
enabled: true,
guard: { enabled: true }, // Required!
},
}
Problem: Using @id instead of @user.id
Fix: Use correct placeholders:
| Placeholder | Meaning |
|---|---|
@user.id | Current user's ID |
@user.email | Current user's email |
@id | Current record ID (not user) |
// WRONG - @id is record ID, not user ID
filter: { user_id: "@id" }
// CORRECT
filter: { user_id: "@user.id" }
Problem: RLS applies to wrong entities
Fix: Add entity condition for entity-specific RLS:
// WRONG - applies to ALL entities
policies: [{
effect: "filter",
filter: { user_id: "@user.id" },
}]
// CORRECT - only posts entity
policies: [{
condition: { entity: "posts" },
effect: "filter",
filter: { user_id: "@user.id" },
}]
Problem: Using effect: "allow" when you need filtering
Fix: Understand the difference:
| Effect | Purpose |
|---|---|
allow | Grant permission (no data filtering) |
deny | Block permission entirely |
filter | Allow but filter results |
// WRONG - allows all, no filtering
{ effect: "allow", filter: { user_id: "@user.id" } }
// CORRECT - filters results
{ effect: "filter", filter: { user_id: "@user.id" } }
Problem: New records have null user_id
Fix: Either set in client or use server hook (see "Setting User Ownership" section above)
Problem: $or filter returning wrong results
Fix: Verify syntax:
// CORRECT $or syntax
filter: {
$or: [
{ is_public: true },
{ user_id: "@user.id" },
],
}
DO:
user_id) to entities needing RLSeffect: "filter" for row-level restrictionsDON'T:
@id (record) with @user.id (user)guard: { enabled: true }effect: "allow" with filter field (use effect: "filter")