npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

kysely-access-control

v0.0.5

Published

This package contains some utilities for implementing a permission system on top of the [Kysely](https://github.com/koskimas/kysely) query builder.

Downloads

11

Readme

kysely-utils

This package contains some utilities for implementing a permission system on top of the Kysely query builder.

It exposes two interfaces, a low-level interface accessible via createAccessControlPlugin and and a higher level interface that is similar to Postgres's internal permissions accessible via createKyselyGrantGuard.

It uses bun for package installation, monorepo management, building, and running scripts and tests, but exports packages in a way that is compatible with Node or Deno normally.

Motivation

Implementing permissions at the query builder layer makes more sense than in each query:

  1. DRY-er: Common use cases like filtering a table or omitting a column are just specified once, instead of in every query in your application.
  2. Separation of concerns: Maintain a part of your application responsible for generating different guards for different users and ensure that your core application logic is not polluted with permission checks, and doesn't need to change when permissions or new roles are created.
  3. Harder to forget: No more odd bugs where you forget to add a check for .is_deleted or .tenant_id = ?

Even though PostgreSQL has a fully featured permission system, implementing permissions at the query builder layer can makes more sense than in the database itself:

  1. Dynamically generate context specific permissions: Postgres permissions are static, and so you can't, for example, generate permissions based on the current context / user role / action matrix. Although you can use a role per user approach, that role controls those users permissions in any context.
  2. No security definer escape: When using database level permissions, it's common to use security definer functions as an escape hatch. When you do, you're back to manually re-implementing parts of the permissions you want to keep.
  3. More control: Postgres, for example, has no deny rules, and so it can be easy to accidentally grant permissions that leak when additive roles combine.

High Level Grants Usage

Construct a Grant with the following type, like:

type Grant = {
	on: Table;
	for: 'select' | 'insert' | 'update' | 'delete' | 'all'
	columns?: string[] // all columns are allowed if blank
	where?: (
    eb: ExpressionBuilder<KyselyDatabase, TableName>
  ) => ExpressionWrapper<KyselyDatabase, TableName, SqlBool>;
  whereType?: "permissive" | "restrictive";
}

Grant.where and Grant.whereType function similar to Postgres row level security.

You can check a list of grants into your codebase, like:

// in some file db.ts
import { createKyselyGrantGuard, createAccessControlPlugin } from 'kysely-access-control'

const getSharedGrants = (currentUserId) => [
	{
		on: 'posts',
		for: 'select'
	},
	{
		on: 'comments',
		for: 'select'
	},
	{
		on: 'posts',
		for: 'all',
		where: (eb) => eb.eq('author_id', currentUserId)
	},
	{
		on: 'comments',
		for: 'all',
		where: (eb) => eb.eq('author_id', currentUserId)
	}
]

const adminGrants = [
	{
		on: 'accounts',
		for: 'all',
	}
]

const query = (userId, isAdmin) => {
	return db.withPlugin(createAccessControlPlugin(
		createKyselyGrantGuard(
			getSharedGrants(userId).concat(isAdmin ? adminGrants : [])
		)
	)
}

// in some api.ts
import { query } from './db.ts'

// in some request handler
// this query will have permissions enforced
await query(req.user.id, req.user.isAdmin).selectFrom('posts').select(['id']).execute();

Or you can generate them from a database, storing them in some grants table, or anything else you can think of.

In my projects, I'm constructing the plugin in response to each request. In one, I'm doing it in a tRPC middleware and adding it to the RPC's context.

Only Table/Column Grants

Currently only table x column permissions are implemented, i.e. all grants look like:

grant select (id, first_name, last_name) on person to a;

There is no intent to implement schema level ownership or other higher level permissions. If you want a user to be able to access everything, just skip the .withPlugin() call.

Lower Level Access Control Usage

import { createAccessControlPlugin, KyselyAccessControlGuard, Allow, Deny, Update, Delete, ColumnInUpdateSet } from 'kysely-access-control';
import { Database } from './my-kysely-types.ts'

// Define your guard
const guard: KyselyAccessControlGuard<Database> = {
  table: (table, statementType, usageContext) => {
    // table.name is restricted to keyof Database
    if (table.name === 'events' && statementType ===  Delete) {
      return Deny;
    }

    return Allow;
  },
  column: (table, column, statementType, usageContext) => {
    // Control if the column can be inserted, updated independently
    if (table.name === 'events' && column.name === 'is_deleted' && statementType === Update && usageContext === ColumnInUpdateSet) {
      return Deny;
    }

    return Allow;
  }
}

// When executing a query...
const events = await db
  .withPlugin(createAccessControlPlugin(guard))
  .updateTable('events)
  .set({ is_deleted: false })
  .execute();
// throws 'UPDATE denied on events.is_deleted'

Limitations

No Enforcement of Raw SQL

kysely-access-control works by operating on the internal OperationNodes used in Kysely's query builder. As a result, anything specified in raw SQL can't be enforced.

There are definitely legitimate uses that require raw SQL, but try to use it only when necessary in order to maintain most of the benefits of kysely-access-control.

For example,

db.selectFrom('person')
  .select(({ fn, val, ref }) => [ fn<string>('concat', [ref('first_name'), val(' '), ref('last_name')]) ])

Enforces column permissions, whereas:

db.selectFrom('person')
  .select(sql<string>`concat(first_name, ' ', last_name)`)

enforces only table permissions, and:

sql`select concat(first_name, ' ', last_name) from person`

enforces nothing.

No RLS on Insert (or Check for Update out of RLS)

kysely-access-control's RLS works by adding user supplied expression's as where clauses in the right places. As a result, it is only capable of implementing the USING part of traditional RLS, and not the WITH CHECK part.

As a result, we can't check that a new row version (whether inserted or updated) matches the conditions specified.

Types May Be Incorrect

If you use kysely-access-control to restrict access to a column, the query return types may still portray that column as being present (and potentially even not null), even though it will be undefined in the actual result.

Joins May Fail Where You Don't Expect

Even if a foreign key is not null, if you join to a table with a where guard on it, the join may fail because the context does not permit the user to see the joined row.

This is true for Postgres RLS as well.

Top Level .selectAll() is not allowed

While kysely-access-control allows usage of .selectAll() in subqueries, it does not allow it at the top level because it would circumvent column permissions controls.

Unfortunately, even those you provide the column list to Kysely as a type, that type is not inspectable by the plugin system (or at all by the runtime), and as a result we cannot do the sensible thing of replacing a .selectAll() with a select of all columns.

Features

Table/Column Statement Type + Context Controls

createAccessControlPlugin allows you to control access to tables and columns based on the statement type and context.
For example, you can allow a user to select from a table, but not update it, or allow a user to update a table, but not set a particular column.

For full controls, see the types of the guard:

type FullKyselyAccessControlGuard<KyselyDatabase> = {
  table: (
    table: TableNodeTableWithKeyOf<KyselyDatabase>,
    statementType: StatementType,
    tableUsageContext: TableUsageContext
  ) => TableGuardResult<KyselyDatabase>;

  column: (
    table: TableNodeTableWithKeyOf<KyselyDatabase>,
    column: ColumnNode["column"],
    statementType: StatementType,
    columnUsageContext: ColumnUsageContext
  ) => ColumnGuardResult;
};

export enum StatementType {
  Select = "select",
  Insert = "insert",
  Update = "update",
  Delete = "delete",
}

export enum ColumnUsageContext {
  ColumnInSelectOrReturning = "column-in-select-or-returning",
  ColumnInWhereOrJoin = "column-in-where-or-join",
  ColumnInUpdateSet = "column-in-update-set",
  ColumnInInsert = "column-in-insert",
}

export enum TableUsageContext {
  TableTopLevel = "table-top-level",
  TableInJoin = "table-in-join",
}

RLS in Select/Update/Delete

In addition to returning a simple Allow token to allow access, you can also return a tuple where the second argument is a Kysely where clause to be added to the query.

For example, you can implement RLS like so:

const guard: KyselyAccessControlGuard = {
  table: (table) => {
    if (table.name === 'people') {
      return [
        Allow,
        expressionBuilder<Database, 'people'>().eb('is_deleted', 'is', false);
      ];
    }
  }
}

Now, any query that targets the people table will have is_deleted is false inlined as a where clause.

Column Omission vs Erroring

At column level select statements, you can choose Omit as a third option to Allow vs. Deny.

If you choose this option, the column you select will be omitted from the query, and the query will still succeed.

This also works for returning clauses as well, whether they are on a top level insert, update, or delete statement.

Contributing

The most helpful form of contribution right now would be additional tests on complex queries in your actual applications.

Currently, kysely-access-control has not been tested to properly enforce permissions with every type of SQL query Kysely itself can generate.

However, it has been programmed to throw errors if it encounters a query type that is not yet implemented, and it should generate these errors even if you don't enforce any particularly complex permission on them.

For any of these failures, it is possible to make kysely-access-control work, it just requires a few more ifs, so please open an issue.