npm.io
1.0.3 • Published 6d ago

@aginix/adonis-rls

Licence
MIT
Version
1.0.3
Deps
0
Size
71 kB
Vulns
0
Weekly
78

@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-rls

The 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=42

Outside 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 POLICY layer (planned).
  • Row-level only; column-level visibility belongs to a future serializer/transformer concern.
  • Article.create({...}) does NOT run rlsScope (no rows to scope at INSERT time). Write authz is delegated to Bouncer.
  • Single actor type per app (config.actorResolver returns one shape).
  • Pivot tables in manyToMany preloads are not scoped — only the related model is.

License

MIT

Keywords