sentinelz
v1.1.0
Published
Unified Casbin RBAC/ABAC wrapper with SQL + MongoDB adapters and bundled migrations
Maintainers
Readme
Sentinelz
What is this?
sentinelz wraps Apache Casbin — the open-source library that handles RBAC/ABAC authorization logic ("who can do what") — and removes the usual database setup pain around it.
Normally, using Casbin with a database means picking a specific adapter package (a TypeORM adapter, a Prisma adapter, a Mongoose adapter, ...), installing it separately, wiring it up, and manually creating the table Casbin needs. sentinelz removes that: you pick sql or mongo, give it a connection string, and it:
- Creates its own table/collection automatically — no manual
CREATE TABLE - Gives you one simple class (
Sentinelz) to check permissions and manage roles - Works completely independently of whatever database/ORM your app already uses for its own data — it doesn't integrate with your existing tables, it just owns one small
sentinelz_rulestable off to the side
Install
npm install sentinelz
# plus the driver for your dialect/database:
npm install knex pg # sql adapter, Postgres
npm install knex mysql2 # sql adapter, MySQL
npm install knex sqlite3 # sql adapter, SQLite
npm install mongoose # mongo adapter
npm install redis # optional: redis cacheCasbin itself ships as part of sentinelz — you never install or import it directly. The adapter/cache packages above are optional dependencies: install only what your chosen adapter/cache needs.
How you'd actually use it
Say you're building an app where users can read/write "articles", and some users are admins. You don't write any Casbin config for this — sentinelz ships a sensible default (plain role-based: subject, object, action + roles) and uses it automatically. Just connect and start calling methods on the instance:
import { SentinelzFactory } from 'sentinelz';
const sentinelz = await SentinelzFactory.create({
adapter: 'sql',
adapterConfig: {
client: 'pg', // or 'mysql2', 'sqlite3'
connection: process.env.DATABASE_URL,
},
});
// The sentinelz_rules table is created automatically here — nothing to set up manually.
// Give alice permission to write articles
await sentinelz.addPolicy('alice', 'articles', 'write');
// Make bob an admin, and give admins permission to write articles
await sentinelz.addPolicy('admin', 'articles', 'write');
await sentinelz.addRole('bob', 'admin');
// Now check permissions anywhere in your app:
await sentinelz.enforce('alice', 'articles', 'write'); // true (direct policy)
await sentinelz.enforce('bob', 'articles', 'write'); // true (via admin role)
await sentinelz.enforce('carol', 'articles', 'write'); // false (no policy, no role)Use it in a route, right before letting the request through:
app.patch('/articles/:id', async (req, res) => {
const allowed = await sentinelz.enforce(req.user.id, 'articles', 'write');
if (!allowed) return res.status(403).send('Forbidden');
// ...update the article
});If you use NestJS, there's a guard + decorator that does this declaratively — see the NestJS section below.
That's the whole workflow: SentinelzFactory.create() once, add policies/roles as your app needs them (usually from an admin panel or a seed script), and call enforce() wherever you need a yes/no permission check. You interact with one instance and its methods — nothing about Casbin or the underlying database leaks into your code.
Custom permission model (advanced / ABAC)
The default model covers plain RBAC. If you need attribute-based rules or a custom matcher, pass your own Casbin model file — this is the only case where you'd need to touch Casbin's own config format:
const sentinelz = await SentinelzFactory.create({
modelPath: './rbac_model.conf', // your own model — omit this field to use the bundled default
adapter: 'sql',
adapterConfig: { client: 'pg', connection: process.env.DATABASE_URL },
});# rbac_model.conf — this is exactly what's bundled as the default, shown here as a starting point
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.actAdapters
One adapter per storage family — not per ORM:
| Adapter | Backed by | Covers |
| ------- | ---------------------------------- | ------------------------------------------------------------------------ |
| sql | Knex | PostgreSQL, MySQL, SQLite, MS SQL Server — pick the dialect via client |
| mongo | Mongoose | MongoDB |
// Postgres
{ adapter: 'sql', adapterConfig: { client: 'pg', connection: process.env.DATABASE_URL } }
// MySQL
{ adapter: 'sql', adapterConfig: { client: 'mysql2', connection: process.env.DATABASE_URL } }
// SQLite (zero-infra, great for tests)
{ adapter: 'sql', adapterConfig: { client: 'sqlite3', connection: { filename: './dev.sqlite3' } } }
// MongoDB
{ adapter: 'mongo', adapterConfig: { uri: process.env.MONGODB_URI } }Postgres/MSSQL schema (namespace) support
{
adapter: 'sql',
adapterConfig: {
client: 'pg',
connection: process.env.DATABASE_URL,
schema: 'tenant_a', // isolates this consumer's rows under tenant_a.sentinelz_rules
createSchemaIfMissing: true, // opt-in CREATE SCHEMA IF NOT EXISTS during migrate()
},
}Ignored (with a warning) for sqlite3/mysql2, which have no schema concept.
Migrations
Both adapters own their schema and run it automatically on SentinelzFactory.create() (migrate: { auto: true } by default). No manual CREATE TABLE, ever. Set migrate: { auto: false } and call sentinelz.migrate() yourself if you'd rather run migrations as an explicit deploy step.
Config sources
// Explicit config (shown above), or:
const sentinelz = await SentinelzFactory.createFromEnv(); // reads SENTINELZ_* env vars
const sentinelz = await SentinelzFactory.createFromFile(
'./sentinelz.config.json',
);See .env.example for the full SENTINELZ_* environment variable reference.
Optional Redis (or in-memory) caching
Caches enforce() results; a decorator over the core enforcer, not baked into it — fully disabled by default, and disabling it changes nothing about correctness.
const sentinelz = await SentinelzFactory.create({
adapter: 'sql',
adapterConfig: { client: 'pg', connection: process.env.DATABASE_URL },
cache: {
enabled: true,
type: 'redis', // or 'memory' for an in-process fallback
ttl: 300,
redisUrl: process.env.REDIS_URL,
},
});Any policy or role mutation (addPolicy, removePolicy, addRole, ...) invalidates the whole cache — correctness over partial-invalidation cleverness.
NestJS
import { Module } from '@nestjs/common';
import { SentinelzModule } from 'sentinelz/nestjs';
@Module({
imports: [
SentinelzModule.register({
adapter: 'sql',
adapterConfig: { client: 'pg', connection: process.env.DATABASE_URL },
}),
],
})
export class AppModule {}import { Controller, Patch, Param, UseGuards } from '@nestjs/common';
import { SentinelzGuard, CheckAbility } from 'sentinelz/nestjs';
@Controller('articles')
@UseGuards(SentinelzGuard)
export class ArticlesController {
@Patch(':id')
@CheckAbility({ action: 'write', resource: 'articles' })
async updateArticle(@Param('id') id: string) {
// Only reached if req.user.id has write permission on articles
return { success: true, id };
}
}sentinelz/nestjs is a separate export path — a non-Nest consumer never pulls in @nestjs/common.
API
What ...args actually means
A handful of methods below take ...args: string[] instead of named parameters. That's not vagueness — it's because the shape depends on which Casbin model you're using (see Custom permission model), so the method can't hardcode parameter names that would only be right for one model.
With the bundled default model (which is what you're using unless you passed a custom modelPath), every one of these is always a (subject, object, action) triple:
await sentinelz.enforce('alice', 'articles', 'write');
// ^subject ^object ^action
// "Is alice allowed to write articles?"
await sentinelz.addPolicy('alice', 'articles', 'write');
// "Grant alice permission to write articles" — same 3 positions, different verbIf you bring your own ABAC model, the number and meaning of the arguments follows whatever you defined in that model's [request_definition]/[policy_definition] instead — sentinelz just passes them through to Casbin.
Sentinelz (the instance you get back from SentinelzFactory.create())
| Method | What it does |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| enforce(...args) → boolean | The core check: is this request allowed? |
| addPolicy(...args) → boolean | Grants a permission directly to a subject (not via a role) |
| removePolicy(...args) → boolean | Revokes a permission added with addPolicy |
| updatePolicy(oldRule, newRule) → boolean | Swaps one policy rule for another in one call |
| getPolicy() → string[][] | Every policy rule currently loaded |
| getPoliciesForUser(user) → string[][] | Rules that apply directly to one subject |
| addRole(user, role) → boolean | Assigns a role to a subject — grant the role itself permissions via addPolicy(role, obj, act), and every subject with that role inherits them |
| removeRole(user, role) → boolean | Removes a role assignment (the role's own policies are untouched) |
| getRolesForUser(user) → string[] | Every role assigned to a subject |
| getUsersForRole(role) → string[] | Every subject assigned to a role |
| getAllRoles() → string[] | Every role name that appears anywhere in the current policies |
| loadPolicy() → void | Reloads policies from storage into memory |
| loadFilteredPolicy(filter) → void | Loads only the rules matching filter instead of the whole table (see Filtered policy loading) |
| isFiltered() → boolean | True while a filtered subset is loaded |
| savePolicy() → void | Persists the current in-memory policies to storage |
| clearPolicy() → void | Empties the in-memory policy set (storage untouched until you savePolicy()) |
| migrate() → void | Runs the adapter's schema setup explicitly (see Migrations) |
| close() → void | Closes the underlying database connection |
Full type signatures (with per-method doc comments — these show up on hover in your editor too) live in src/core/enforcer.interface.ts.
Filtered policy loading
By default every rule is loaded into memory. For large rule tables, load just what you need:
// Rules for alice, plus the role (`g`) rows and role permissions she inherits
await sentinelz.loadFilteredPolicy({
ptype: ['p', 'g'],
v0: ['alice', 'admin'],
});A PolicyFilter has ptype ('p' rules vs 'g' role assignments) and v0..v5 (the rule's positional values — with the default model, v0 is the subject, v1 the object, v2 the action). Each field is a list of accepted values; fields are ANDed; omitted fields don't constrain.
- Include the
grows and role subjects. If you filter onlyprows foralice, her role assignments aren't loaded and inherited permissions silently disappear. savePolicy()throws while filtered (saving would overwrite storage with the loaded subset). CallloadPolicy()to return to the full set.addPolicy/removePolicy/addRole/… still write through to storage as usual.- This is runtime-only: there is no startup/config option, so the instance always boots with the full policy set.
SentinelzFactory
class SentinelzFactory {
static create(config: ISentinelzConfig): Promise<ISentinelz>;
static createFromEnv(modelPath?: string): Promise<ISentinelz>;
static createFromFile(configPath: string): Promise<ISentinelz>;
}Errors thrown are typed: AdapterNotFoundError, AdapterInitializationError, MigrationError, EnforcementError, PolicyManagementError — all extend SentinelzError.
Examples
Runnable examples live under examples/: sql-sqlite-example, sql-postgres-example, mongo-example, nestjs-example.
Development
yarn install
yarn build
yarn test # unit tests (mocked/SQLite, no external services)
docker compose up -d # postgres, mysql, mongo, redis
yarn test:integrationReleases are fully automated — see docs/RELEASING.md.
License
MIT
