@aginix/adonis-rls
v1.0.1
Published
Row-level security for AdonisJS Lucid — scope Model.query() to the current actor by default, with explicit .sudo() escape
Maintainers
Readme
@aginix/adonis-rls
Row-level security for AdonisJS Lucid. Model.query() is scoped to the current actor by default — there's no opt-in step beyond composing one mixin and declaring a rlsScope. Bypass is explicit and named (.sudo(), optionally with a reason for audit hooks).
Status: v0.1 beta. API surface is design-locked but the package has not been deployed against a production database. Use behind a feature flag if you adopt it before 0.2.
Install
pnpm add @aginix/adonis-rls
node ace configure @aginix/adonis-rlsThe configure hook publishes config/rls.ts and registers inject_actor_middleware on the router.
1. Make a model RLS-aware
// app/models/article.ts
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { RowLevelSecurity, type RlsContext } from '@aginix/adonis-rls'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
import User from '#models/user'
export default class Article extends compose(BaseModel, RowLevelSecurity) {
@column({ isPrimary: true })
declare id: number
@column()
declare authorId: number
@column()
declare visibility: 'public' | 'private'
static rlsScope(
query: ModelQueryBuilderContract<typeof Article>,
ctx: RlsContext<User>
) {
const user = ctx.actor
if (!user) return query.where('visibility', 'public') // anonymous
if (user.role === 'admin') return query // admin sees all
return query.where((q) => {
q.where('author_id', user.id).orWhere('visibility', 'public')
})
}
}That's it. Every read goes through rlsScope automatically — Article.query(), Article.find(id), Article.findOrFail(id), Article.query().paginate(...), Article.query().preload('comments'). Mass update / delete / increment / decrement via the query builder are scoped too.
2. Run inside an actor context
Inside an HTTP handler, the middleware does this for you:
// HTTP handler — actor auto-injected via ctx.auth.user
await Article.query() // scoped to ctx.auth.user
await Article.find(42) // null if user can't see id=42Outside HTTP (jobs, seeders, CLI), be explicit:
import { runWithActor } from '@aginix/adonis-rls'
await runWithActor(systemUser, async () => {
await Article.query()
})
// or via the static helper:
await Article.forActor(systemUser)3. Bypass (when you really need to)
await Article.sudo('payroll job: monthly summary') // with a reason (recommended)
await Article.sudo() // no reason -- fine for obvious call sites
await Article.query().sudo('admin export').where('status', 'archived')reason is optional. When passed, it's preserved on the builder state for an upcoming audit layer; pass one whenever the call site isn't self-explanatory.
4. Preload cascades
.preload('comments') runs Comment's rlsScope automatically:
// Both Article and Comment have rlsScope. Both run.
await Article.query().preload('comments')
// Compose with user clauses inside the callback
await Article.query().preload('comments', (q) => q.where('approved', true))
// Mix scopes across the tree
await Article.sudo('admin export').preload('comments', (q) => q.forActor(viewer))5. Config
// config/rls.ts
import { defineConfig } from '@aginix/adonis-rls'
export default defineConfig({
// What happens when Model.query() runs without an actor (no middleware,
// no .forActor, no .sudo). 'throw' (default) is safest. 'deny' returns
// no rows. 'allow' skips the scope entirely — only opt in if you have
// another authz layer.
defaultBehavior: 'throw',
// How to resolve the actor from HttpContext. Default reads ctx.auth?.user.
// Override here if your auth shape is different. May be sync or async —
// the middleware awaits the result, so you can do ctx.auth.check(),
// verify a JWT, hit a session store, etc.
// actorResolver: async (ctx) => {
// await ctx.auth.check()
// return ctx.auth.user ?? null
// },
})Errors
| Class | When |
| -------------------------- | ------------------------------------------------------------------------------------- |
| MissingActorError | Model.query() ran with no actor and defaultBehavior: 'throw'. |
| RlsScopeNotDefinedError | Model composes RowLevelSecurity but doesn't declare static rlsScope. |
Composing with other packages
| Package | How |
| ------------------------- | --------------------------------------------------------------------------------------- |
| @aginix/adonis-vulcan | Model.query().filter(qs) — RLS applies first, vulcan's filter clauses AND on top. |
| @aginix/adonis-object-id| Article.findByObjectId(ref) — routes through query(), so RLS hides invisible rows. |
| BaseResourceController | All five actions are RLS-scoped automatically when the model uses the mixin. |
| Bouncer | Complementary — Bouncer gates endpoints, RLS gates rows. Use both. |
Limitations (v0.1)
- In-app TypeScript only; no Postgres-native
CREATE POLICYlayer (planned). - Row-level only; column-level visibility belongs to a future serializer/transformer concern.
Article.create({...})does NOT runrlsScope(no rows to scope at INSERT time). Write authz is delegated to Bouncer.- Single actor type per app (
config.actorResolverreturns one shape). - Pivot tables in
manyToManypreloads are not scoped — only the related model is.
License
MIT
