Row-Level Security (RLS)

Drizzle를 사용하면 모든 Postgres 테이블에 Row-Level Security (RLS)를 활성화하고, 다양한 옵션으로 정책을 생성하며, 해당 정책이 적용되는 역할을 정의하고 관리할 수 있습니다.

Drizzle는 원하는 방식으로 사용할 수 있는 Postgres 정책 및 역할의 원시 표현을 지원합니다. 이는 NeonSupabase와 같은 인기 있는 Postgres 데이터베이스 제공자와 함께 작동합니다.

Drizzle에는 두 데이터베이스 제공자를 위한 특정 사전 정의된 RLS 역할 및 함수가 있지만, 자체 로직을 정의할 수도 있습니다.

Enable RLS

IMPORTANT

How it works in 0.x versions

정책을 추가하지 않고 테이블에서 RLS만 활성화하려면 .enableRLS()를 사용할 수 있습니다.

PostgreSQL 문서에 언급된 대로:

If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified. Operations that apply to the whole table, such as TRUNCATE and REFERENCES, are not subject to row security.

import { integer, pgTable } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
	id: integer(),
}).enableRLS();

v1.0.0-beta.1부터 .enableRLS()는 지원 중단되었으며, 정책을 추가하지 않고 테이블에서 RLS만 활성화하려면 pgTable.withRLS(...)를 사용할 수 있습니다.

PostgreSQL 문서에 언급된 대로:

If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified. Operations that apply to the whole table, such as TRUNCATE and REFERENCES, are not subject to row security.

import { integer, pgTable } from 'drizzle-orm/pg-core';

export const users = pgTable.withRLS('users', {
	id: integer(),
});
important

테이블에 정책을 추가하면 RLS가 자동으로 활성화됩니다. 따라서 테이블에 정책을 추가할 때 명시적으로 RLS를 활성화할 필요가 없습니다.

Roles

현재 Drizzle는 아래와 같이 몇 가지 다른 옵션으로 역할 정의를 지원합니다. 더 많은 옵션에 대한 지원은 향후 릴리스에서 추가될 예정입니다.

import { pgRole } from 'drizzle-orm/pg-core';

export const admin = pgRole('admin', { createRole: true, createDb: true, inherit: true });

역할이 이미 데이터베이스에 존재하고 drizzle-kit이 이를 ‘보거나’ 마이그레이션에 포함시키지 않으려면, 해당 역할을 기존 역할로 표시할 수 있습니다.

import { pgRole } from 'drizzle-orm/pg-core';

export const admin = pgRole('admin').existing();

Policies

RLS를 완전히 활용하려면 Drizzle 테이블 내에서 정책을 정의할 수 있습니다.

info

PostgreSQL에서 정책은 기존 테이블에 연결되어야 합니다. 정책은 항상 특정 테이블과 연결되어 있으므로, 정책 정의를 pgTable의 매개변수로 정의하기로 결정했습니다.

사용 가능한 모든 속성을 가진 pgPolicy 예제

import { sql } from 'drizzle-orm';
import { integer, pgPolicy, pgRole, pgTable } from 'drizzle-orm/pg-core';

export const admin = pgRole('admin');

export const users = pgTable('users', {
	id: integer(),
}, (t) => [
	pgPolicy('policy', {
		as: 'permissive',
		to: admin,
		for: 'delete',
		using: sql``,
		withCheck: sql``,
	}),
]);

정책 옵션

as가능한 값은 permissive 또는 restrictive
to정책이 적용되는 역할을 지정합니다. 가능한 값으로는 public, current_role, current_user, session_user 또는 문자열로 된 다른 역할 이름이 있습니다. pgRole 객체를 참조할 수도 있습니다.
for이 정책이 적용될 명령을 정의합니다. 가능한 값은 all, select, insert, update, delete입니다.
using정책 생성 문의 USING 부분에 적용될 SQL 문입니다.
withCheck정책 생성 문의 WITH CHECK 부분에 적용될 SQL 문입니다.

기존 테이블에 정책 연결

데이터베이스의 기존 테이블에 정책을 연결해야 하는 상황이 있습니다. 가장 일반적인 사용 사례는 Neon 또는 Supabase와 같은 데이터베이스 제공자를 사용할 때 기존 테이블에 정책을 추가해야 하는 경우입니다. 이 경우 .link() API를 사용할 수 있습니다.

import { sql } from "drizzle-orm";
import { pgPolicy } from "drizzle-orm/pg-core";
import { authenticatedRole, realtimeMessages } from "drizzle-orm/supabase";

export const policy = pgPolicy("authenticated role insert policy", {
  for: "insert",
  to: authenticatedRole,
  using: sql``,
}).link(realtimeMessages);

Migrations

drizzle-kit을 사용하여 스키마와 역할을 관리하는 경우, Drizzle 스키마에 정의되지 않은 역할을 참조하고 싶을 수 있습니다. 이러한 경우 drizzle 스키마에서 각 역할을 정의하고 .existing()으로 표시하지 않고도 drizzle-kit이 이러한 역할 관리를 건너뛰도록 할 수 있습니다.

이러한 경우 drizzle.config.ts에서 entities.roles를 사용할 수 있습니다. 전체 참조는 drizzle.config.ts 문서를 참조하세요.

기본적으로 drizzle-kit은 역할을 관리하지 않으므로, drizzle.config.ts에서 이 기능을 활성화해야 합니다.

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: 'postgresql',
  schema: "./drizzle/schema.ts",
  dbCredentials: {
    url: process.env.DATABASE_URL!
  },
  verbose: true,
  strict: true,
  entities: {
    roles: true
  }
});

추가 구성 옵션이 필요한 경우 몇 가지 예제를 더 살펴보겠습니다.

admin 역할이 있고 관리 가능한 역할 목록에서 제외하려는 경우

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  ...
  entities: {
    roles: {
      exclude: ['admin']
    }
  }
});

admin 역할이 있고 관리 가능한 역할 목록에 포함하려는 경우

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  ...
  entities: {
    roles: {
      include: ['admin']
    }
  }
});

Neon을 사용하고 Neon에서 정의한 역할을 제외하려는 경우 provider 옵션을 사용할 수 있습니다

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  ...
  entities: {
    roles: {
      provider: 'neon'
    }
  }
});

Supabase를 사용하고 Supabase에서 정의한 역할을 제외하려는 경우 provider 옵션을 사용할 수 있습니다

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  ...
  entities: {
    roles: {
      provider: 'supabase'
    }
  }
});
important

Drizzle가 데이터베이스 제공자가 지정한 새로운 역할에 비해 약간 구식인 상황이 발생할 수 있습니다. 이러한 경우 provider 옵션과 exclude를 사용하여 추가 역할을 제외할 수 있습니다:

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  ...
  entities: {
    roles: {
      provider: 'supabase',
      exclude: ['new_supabase_role']
    }
  }
});

RLS on views

Drizzle를 사용하면 뷰에도 RLS 정책을 지정할 수 있습니다. 이를 위해서는 뷰의 WITH 옵션에서 security_invoker를 사용해야 합니다. 다음은 간단한 예제입니다:

IMPORTANT

getColumnsdrizzle-orm@1.0.0-beta.2부터 사용 가능합니다(여기에서 자세히 읽어보세요)

pre-1 버전(0.45.1 같은)을 사용하는 경우 getTableColumns를 사용하세요

...

export const roomsUsersProfiles = pgView("rooms_users_profiles")
  .with({
    securityInvoker: true,
  })
  .as((qb) =>
    qb
      .select({
        ...getColumns(roomsUsers),
        email: profiles.email,
      })
      .from(roomsUsers)
      .innerJoin(profiles, eq(roomsUsers.userId, profiles.id))
  );

Using with Neon

Neon 팀은 원시 정책 API 위에 자체 래퍼 비전을 구현하는 데 도움을 주었습니다. 사전 정의된 함수와 Neon의 기본 역할을 포함하는 /neon import를 정의했습니다.

다음은 crudPolicy 함수를 사용하는 방법의 예입니다:

import { crudPolicy } from 'drizzle-orm/neon';
import { integer, pgRole, pgTable } from 'drizzle-orm/pg-core';

export const admin = pgRole('admin');

export const users = pgTable('users', {
	id: integer(),
}, (t) => [
	crudPolicy({ role: admin, read: true, modify: false }),
]);

이 정책은 다음과 동일합니다:

import { sql } from 'drizzle-orm';
import { integer, pgPolicy, pgRole, pgTable } from 'drizzle-orm/pg-core';

export const admin = pgRole('admin');

export const users = pgTable('users', {
	id: integer(),
}, (t) => [
	pgPolicy(`crud-${admin.name}-policy-insert`, {
		for: 'insert',
		to: admin,
		withCheck: sql`false`,
	}),
	pgPolicy(`crud-${admin.name}-policy-update`, {
		for: 'update',
		to: admin,
		using: sql`false`,
		withCheck: sql`false`,
	}),
	pgPolicy(`crud-${admin.name}-policy-delete`, {
		for: 'delete',
		to: admin,
		using: sql`false`,
	}),
	pgPolicy(`crud-${admin.name}-policy-select`, {
		for: 'select',
		to: admin,
		using: sql`true`,
	}),
]);

Neon은 사전 정의된 authenticatedanaonymous 역할과 관련 함수를 노출합니다. RLS에 Neon을 사용하는 경우, 기존으로 표시된 이러한 역할과 RLS 쿼리의 관련 함수를 사용할 수 있습니다.

// drizzle-orm/neon
export const authenticatedRole = pgRole('authenticated').existing();
export const anonymousRole = pgRole('anonymous').existing();

export const authUid = (userIdColumn: AnyPgColumn) => sql`(select auth.user_id() = ${userIdColumn})`;

export const neonIdentitySchema = pgSchema('neon_identity');

export const usersSync = neonIdentitySchema.table('users_sync', {
  rawJson: jsonb('raw_json').notNull(),
  id: text().primaryKey().notNull(),
  name: text(),
  email: text(),
  createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }),
  deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }),
});

예를 들어, 다음과 같이 Neon 사전 정의된 역할과 함수를 사용할 수 있습니다:

import { sql } from 'drizzle-orm';
import { authenticatedRole } from 'drizzle-orm/neon';
import { integer, pgPolicy, pgRole, pgTable } from 'drizzle-orm/pg-core';

export const admin = pgRole('admin');

export const users = pgTable('users', {
	id: integer(),
}, (t) => [
	pgPolicy(`policy-insert`, {
		for: 'insert',
		to: authenticatedRole,
		withCheck: sql`false`,
	}),
]);

Using with Supabase

또한 스키마에서 사용할 수 있는 기존으로 표시된 사전 정의된 역할 세트가 포함된 /supabase import도 있습니다. 이 import는 향후 릴리스에서 더 많은 함수와 헬퍼로 확장되어 RLS 및 Supabase 사용을 더 간단하게 만들 것입니다.

// drizzle-orm/supabase
export const anonRole = pgRole('anon').existing();
export const authenticatedRole = pgRole('authenticated').existing();
export const serviceRole = pgRole('service_role').existing();
export const postgresRole = pgRole('postgres_role').existing();
export const supabaseAuthAdminRole = pgRole('supabase_auth_admin').existing();

예를 들어, 다음과 같이 Supabase 사전 정의된 역할을 사용할 수 있습니다:

import { sql } from 'drizzle-orm';
import { serviceRole } from 'drizzle-orm/supabase';
import { integer, pgPolicy, pgRole, pgTable } from 'drizzle-orm/pg-core';

export const admin = pgRole('admin');

export const users = pgTable('users', {
	id: integer(),
}, (t) => [
	pgPolicy(`policy-insert`, {
		for: 'insert',
		to: serviceRole,
		withCheck: sql`false`,
	}),
]);

/supabase import에는 애플리케이션에서 사용할 수 있는 사전 정의된 테이블과 함수도 포함되어 있습니다.

// drizzle-orm/supabase

const auth = pgSchema('auth');
export const authUsers = auth.table('users', {
	id: uuid().primaryKey().notNull(),
});

const realtime = pgSchema('realtime');
export const realtimeMessages = realtime.table(
	'messages',
	{
		id: bigserial({ mode: 'bigint' }).primaryKey(),
		topic: text().notNull(),
		extension: text({
			enum: ['presence', 'broadcast', 'postgres_changes'],
		}).notNull(),
	},
);

export const authUid = sql`(select auth.uid())`;
export const realtimeTopic = sql`realtime.topic()`;

이를 통해 코드에서 사용할 수 있으며, Drizzle Kit은 이를 기존 데이터베이스로 취급하여 다른 엔티티에 연결하기 위한 정보로만 사용합니다.

import { foreignKey, pgPolicy, pgTable, text, uuid } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm/sql";
import { authenticatedRole, authUsers } from "drizzle-orm/supabase";

export const profiles = pgTable(
  "profiles",
  {
    id: uuid().primaryKey().notNull(),
    email: text().notNull(),
  },
  (table) => [
    foreignKey({
      columns: [table.id],
	  // Supabase의 auth 테이블 참조
      foreignColumns: [authUsers.id],
      name: "profiles_id_fk",
    }).onDelete("cascade"),
    pgPolicy("authenticated can view all profiles", {
      for: "select",
	  // Supabase의 사전 정의된 역할 사용
      to: authenticatedRole,
      using: sql`true`,
    }),
  ]
);

Supabase에 존재하는 테이블에 정책을 추가하는 예제를 확인해 보겠습니다.

import { sql } from "drizzle-orm";
import { pgPolicy } from "drizzle-orm/pg-core";
import { authenticatedRole, realtimeMessages } from "drizzle-orm/supabase";

export const policy = pgPolicy("authenticated role insert policy", {
  for: "insert",
  to: authenticatedRole,
  using: sql``,
}).link(realtimeMessages);

Drizzle RLS를 Supabase와 함께 사용하는 방법과 실제 쿼리를 수행하는 방법을 보여주는 훌륭한 예제도 있습니다. 또한 Supabase에 대한 모든 트랜잭션 작업을 처리할 수 있는 훌륭한 래퍼인 createDrizzle도 포함되어 있습니다. 향후 릴리스에서는 drizzle-orm/supabase로 이동되어 기본적으로 사용할 수 있게 될 것입니다.

Drizzle SupaSecureSlack repo를 확인해 주세요.

다음은 이 저장소의 구현 예제입니다:

type SupabaseToken = {
  iss?: string;
  sub?: string;
  aud?: string[] | string;
  exp?: number;
  nbf?: number;
  iat?: number;
  jti?: string;
  role?: string;
};

export function createDrizzle(token: SupabaseToken, { admin, client }: { admin: PgDatabase<any>; client: PgDatabase<any> }) {
  return {
    admin,
    rls: (async (transaction, ...rest) => {
      return await client.transaction(async (tx) => {
        // Supabase exposes auth.uid() and auth.jwt()
        // https://supabase.com/docs/guides/database/postgres/row-level-security#helper-functions
        try {
          await tx.execute(sql`
          -- auth.jwt()
          select set_config('request.jwt.claims', '${sql.raw(
            JSON.stringify(token)
          )}', TRUE);
          -- auth.uid()
          select set_config('request.jwt.claim.sub', '${sql.raw(
            token.sub ?? ""
          )}', TRUE);
          -- set local role
          set local role ${sql.raw(token.role ?? "anon")};
          `);
          return await transaction(tx);
        } finally {
          await tx.execute(sql`
            -- reset
            select set_config('request.jwt.claims', NULL, TRUE);
            select set_config('request.jwt.claim.sub', NULL, TRUE);
            reset role;
            `);
        }
      }, ...rest);
    }) as typeof client.transaction,
  };
}

다음과 같이 사용할 수 있습니다:

// https://github.com/orgs/supabase/discussions/23224
// Should be secure because we use the access token that is signed, and not the data read directly from the storage
export async function createDrizzleSupabaseClient() {
  const {
    data: { session },
  } = await createClient().auth.getSession();
  return createDrizzle(decode(session?.access_token ?? ""), { admin, client });
}

async function getRooms() {
  const db = await createDrizzleSupabaseClient();
  return db.rls((tx) => tx.select().from(rooms));
}