drizzle-policy
v0.1.0
Published
Application-side policy enforcement for Drizzle ORM.
Maintainers
Readme
drizzle-policy
Application-side policy enforcement for Drizzle ORM.
Wrap your Drizzle client once, define the rules your app cares about, and keep writing normal Drizzle queries. Drizzle Policy adds policy predicates, injects safe values, or rejects unsafe operations before the query reaches the database.
It is useful for:
- tenant, workspace, organization, or account isolation
- soft-delete filtering
- blocking direct raw execution by default
- custom read, insert, update, and delete rules that live with your application code
Drizzle Policy is not a replacement for database-native row-level security. It protects code that uses the wrapped client.
Install
npm install drizzle-policydrizzle-orm is a peer dependency.
The root import targets Drizzle v1 RC:
import { createPolicyClient, definePolicies } from 'drizzle-policy';Use drizzle-policy/v0 if your app is still on Drizzle v0.
Quick Start
This example scopes every supported query to the current tenant when a table has
a tenantId column.
import { createPolicyClient, definePolicies } from 'drizzle-policy';
import { scopeIsolationPolicy } from 'drizzle-policy/recipes/scope-isolation';
import { rawDb } from './db';
type PolicyContext = {
tenantId: string;
};
export const policies = definePolicies<PolicyContext>()(() => [
scopeIsolationPolicy({
column: 'tenantId',
getScopeValue: ctx => ctx.tenantId,
}),
]);
export const { db, policyContext } = createPolicyClient(rawDb, {
policies,
});Run request or job code inside a policy context:
await policyContext.run({ tenantId: session.tenantId }, async () => {
return handler(request);
});For tests, scripts, and background jobs, you can also pass context directly:
await db.withPolicyContext({ tenantId: 'tenant_123' }, async db => {
return db.query.projects.findMany();
});Use the wrapped db everywhere you want policies enforced.
If your framework already owns request or job context storage, pass a reader and use the returned client:
export const { db } = createPolicyClient(rawDb, {
policies,
getContext: customContextReader,
});For a local PostgreSQL client example that configures Drizzle Policy without opening a database connection on startup:
bun run example:clientWith the policy above:
- reads, updates, and deletes on scoped tables are limited to the current tenant
- inserts into scoped tables get the current
tenantId - inserts or updates for another tenant are rejected
- tables without
tenantIdare left alone by this recipe - raw
executeis hidden from the normal wrapped client unless you explicitly enter an unsafe execute scope
Recipes
Recipes are prebuilt policy factories for common app-level safeguards. They are
just definePolicy(...) wrappers under the hood, so you can mix them with
custom policies in the same policy set. Import only the recipes you need.
import { scopeIsolationPolicy } from 'drizzle-policy/recipes/scope-isolation';
import { softDeletePolicy } from 'drizzle-policy/recipes/soft-delete';Scope Isolation
Use scopeIsolationPolicy when rows belong to an application scope such as a
tenant, workspace, organization, account, or project.
type PolicyContext = {
workspaceId: string;
};
scopeIsolationPolicy<PolicyContext>({
column: 'workspaceId',
getScopeValue: ctx => ctx.workspaceId,
});For tables with workspaceId, the recipe constrains reads, updates, and deletes
to the current workspace. Inserts get the current workspace value automatically.
If some tables should be treated differently, pass table-specific decisions:
import type * as schema from './schema';
scopeIsolationPolicy<PolicyContext, typeof schema>({
column: 'workspaceId',
getScopeValue: ctx => ctx.workspaceId,
onTableWithoutScopeColumn: {
countries: 'ignore',
auditLogs: 'throw',
},
});The optional typeof schema generic gives TypeScript autocomplete for table
names in table-keyed options. You do not pass your schema object at runtime.
Recipes accept custom names when you want domain-specific errors, trace events,
or unsafe policy permissions. For unsafe({ policies: [...] }) autocomplete,
put your context and schema generics on definePolicies, then let the recipe
call infer its own name literal:
export const policies = definePolicies<PolicyContext, typeof schema>()(() => [
scopeIsolationPolicy({
name: 'tenant-isolation',
column: 'tenantId',
getScopeValue: ctx => ctx.tenantId,
onTableWithoutScopeColumn: {
auditLogs: 'ignore',
},
}),
]);Avoid putting explicit generics on the recipe call when you need autocomplete for a custom name:
scopeIsolationPolicy<PolicyContext, typeof schema>({
name: 'tenant-isolation',
column: 'tenantId',
getScopeValue: ctx => ctx.tenantId,
});That form typechecks, but TypeScript widens the custom name to string, so
tenant-isolation is still allowed as a custom policy permission but cannot be
suggested in unsafe({ policies: [...] }).
Soft Delete
Use softDeletePolicy when deleted rows stay in the table.
import { softDeletePolicy } from 'drizzle-policy/recipes/soft-delete';
softDeletePolicy({
column: 'deletedAt',
});For tables with deletedAt, reads only return rows where deletedAt is null.
Deletes are rejected by default.
To turn deletes into updates:
softDeletePolicy({
column: 'deletedAt',
deleteBehavior: 'softDelete',
deletedValue: () => new Date(),
});You can combine recipes in one policy set:
export const policies = definePolicies<PolicyContext>()(() => [
scopeIsolationPolicy({
column: 'tenantId',
getScopeValue: ctx => ctx.tenantId,
}),
softDeletePolicy({
column: 'deletedAt',
deleteBehavior: 'softDelete',
}),
]);Custom Policies
Use policy.define(...) when a recipe is not enough.
import { definePolicies } from 'drizzle-policy';
import { and, eq, isNull } from 'drizzle-orm';
import type * as schema from './schema';
type PolicyContext = {
userId: string;
};
export const policies = definePolicies<PolicyContext>()(policy => [
policy.define({
name: 'visible-projects',
onMissingContext: 'throw',
appliesTo: ({ tableKey }) => tableKey === 'projects',
read: ({ table, ctx }) => {
const projects = table as typeof schema.projects;
return and(eq(projects.ownerId, ctx.userId), isNull(projects.deletedAt));
},
}),
]);Policy hooks use normal Drizzle expressions. Import eq, and, or, isNull,
sql, and other helpers from drizzle-orm; Drizzle Policy does not introduce a
separate predicate language.
Policies can define hooks for four operations:
read: return a Drizzle conditioninsert: return inserted values, or transformed inserted valuesupdate: return a condition, transformedsetvalues, or bothdelete: return a condition, reject the delete, or convert it into an update
appliesTo narrows which table operations a policy should consider. If you
omit it, the policy applies anywhere it has a hook.
onMissingContext: 'throw' makes the policy fail closed when no context is
available and lets TypeScript treat ctx as defined inside that policy's hooks.
Delete As Update
A custom delete hook can replace a delete with an update:
const policies = definePolicies<PolicyContext>()(policy => [
policy.define({
name: 'archive-project-deletes',
onMissingContext: 'throw',
appliesTo: ({ tableKey }) => tableKey === 'projects',
delete: ({ ctx }) => ({
action: 'update',
set: {
deletedAt: new Date(),
deletedById: ctx.userId,
},
}),
}),
]);Configuration
The safest production setup usually fails closed for unclassified table operations and direct raw execution:
export const { db, policyContext } = createPolicyClient(rawDb, {
policies,
onNoPolicyMatched: 'throw',
rawExecution: 'throw',
});Defaults:
| Option | Default | Meaning |
| ------------------- | ------- | ------------------------------------------------------------------------------------------ |
| onNoPolicyMatched | allow | Operations that no policy handles continue unchanged. |
| rawExecution | throw | Direct raw execution methods such as execute are rejected outside unsafe execute scopes. |
Recipe defaults:
| Option | Default | Meaning |
| ------------------------------------------------ | -------- | --------------------------------------------------------------------- |
| scopeIsolationPolicy.onTableWithoutScopeColumn | ignore | Tables without the configured scope column are ignored by the recipe. |
| scopeIsolationPolicy.onMissingScopeValue | throw | Scoped operations without a scope value are rejected. |
| scopeIsolationPolicy.onScopeValueMismatch | throw | Insert/update values for another scope are rejected. |
| softDeletePolicy.deleteBehavior | throw | Deletes are rejected unless you opt into soft-delete updates. |
Raw Execution
Raw execution APIs bypass table-aware planning, so Drizzle Policy rejects them
by default. The wrapped TypeScript client also omits execute unless you
explicitly allow raw execution for the client or enter an unsafe scope that
grants it:
const result = await db.unsafe({ execute: true }).execute(sql`select 1`);If you set rawExecution: 'allow', execute is available on the normal client
surface:
const { db } = createPolicyClient(rawDb, {
policies,
rawExecution: 'allow',
getContext: policyContext.get,
});
await db.execute(sql`select 1`);For legacy JavaScript, casts, or conditional access to execute, the
rawExecution callback remains a runtime fallback:
type PolicyContext = {
role: 'admin' | 'member';
};
const { db } = createPolicyClient(rawDb, {
policies,
getContext: policyContext.get,
rawExecution({ ctx }) {
return ctx?.role === 'admin' ? 'allow' : 'throw';
},
});You can still use Drizzle's sql template inside normal query builders and
policy hooks.
Temporary Exceptions
Use unsafe for a narrow, intentional exception, such as an admin flow that
needs to include soft-deleted rows.
const projects = await db
.unsafe({ policies: ['soft-delete'] })
.query.projects.findMany();Custom recipe names inferred by definePolicies are suggested here too:
const allTenantProjects = await db
.unsafe({ policies: ['tenant-isolation'] })
.query.projects.findMany();Only the named policies are skipped for that returned client. Other policies still run. You can combine permissions when one client needs both kinds of escape hatch:
await db
.unsafe({ policies: ['soft-delete'], execute: true })
.execute(sql`select refresh_admin_cache()`);Tracing
Pass trace while developing a policy setup to see which calls were checked
and which policy decisions were made.
const { db } = createPolicyClient(rawDb, {
policies,
getContext: policyContext.get,
trace(event) {
console.debug(event);
},
});Trace events are for debugging configuration, not for enforcing security.
Supported Drizzle Calls
Both Drizzle versions currently cover:
- SQL-like select, insert, update, and delete builders
- joined tables in select builders
- relational
db.query.*.findMany(...)andfindFirst(...) - nested relational
withconfigs - transactions, with the transaction client wrapped in the same policies
- raw
execute, when granted throughunsafe({ execute: true })or therawExecutionruntime fallback
Drizzle v0
The policy definition API is shared. Only the client import changes:
import { definePolicies } from 'drizzle-policy';
import { createPolicyClient } from 'drizzle-policy/v0';For a tiny local v0 playground:
bun run example:v0:minimalProtection Model
Drizzle Policy protects operations that go through the wrapped client. Queries that bypass it are outside its control, including:
- direct access to the unwrapped Drizzle client
- raw execution that your app explicitly allows
- migrations and maintenance scripts that do not use the policy client
- database users or tools that connect outside your application
Use database-native permissions or row-level security as the final boundary when you need protection outside application code.
