Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const docsSidebar = [
{ text: 'List Query', link: '/guide/list-query' },
{ text: 'Lambda Deployment', link: '/guide/lambda' },
{ text: 'Testing', link: '/guide/testing' },
{ text: 'Row Level Security', link: '/guide/rls' },
],
},
{
Expand Down
155 changes: 155 additions & 0 deletions docs/guide/rls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
---
title: Row Level Security
---

# Row Level Security (RLS)

Glasswork ships first-class helpers for PostgreSQL Row Level Security using Prisma Client Extensions. This guide shows how to scope every request to a tenant, provide an admin escape hatch, and test RLS behavior without extra boilerplate.

## What you get

- Per-request Prisma clients that set `SET LOCAL` session variables before each query.
- Hono middleware to place tenant context on the request.
- Awilix provider to resolve a scoped Prisma client (`tenantPrisma` by default).
- Admin/bypass client for system operations.
- Testing helpers to seed data per tenant and run code with scoped clients.

## Defaults

- Session variables:
- `app.tenant_id`
- `app.user_id`
- `app.user_role`
- `app.bypass_rls` (used by the admin client and `seedTenant`)
- Transaction wrapping is on by default so `SET LOCAL` stays scoped to the query.

## Database setup (PostgreSQL)

Enable RLS and add policies that read the session variables:

```sql
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
FOR ALL
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

CREATE POLICY admin_delete ON projects
FOR DELETE
USING (
tenant_id = current_setting('app.tenant_id', true)::uuid
AND current_setting('app.user_role', true) = 'admin'
);
```

Prisma schema convention (example):

```prisma
model Project {
id String @id @default(cuid())
name String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
createdBy String

@@index([tenantId])
}
```

## Glasswork integration

1) **Add middleware** to extract tenant info (uses `c.get('auth')` by default):

```ts
import { rlsMiddleware } from 'glasswork';

app.use(rlsMiddleware());
```

2) **Register the scoped Prisma provider** in your module:

```ts
import { createRLSProvider } from 'glasswork';

export const AppModule = defineModule({
name: 'app',
providers: [
PrismaService, // base client on prismaService.client
createRLSProvider(), // exposes tenantPrisma (scoped)
ProjectService,
],
});
```

3) **Inject the scoped client** in services:

```ts
class ProjectService {
constructor(private readonly tenantPrisma: PrismaClient) {}

async findAll() {
return this.tenantPrisma.project.findMany();
}
}
```

### Customizing tokens and variables

```ts
createRLSProvider({
provide: 'scopedPrisma',
clientToken: 'prisma', // if you register the base client directly
clientProperty: undefined, // set to undefined when the token is the client itself
contextToken: 'tenantContext',
config: {
useTransaction: true,
sessionVariables: {
tenantId: 'myapp.tenant_id',
userId: 'myapp.user_id',
role: 'myapp.user_role',
},
},
});
```

### Admin / bypass client

```ts
import { createAdminClient } from 'glasswork';

const adminPrisma = createAdminClient(prisma);
await adminPrisma.project.deleteMany(); // runs with app.bypass_rls = true
```

## Testing utilities

- `withTenant(prisma, tenantContext | tenantId, fn, options?)` — runs `fn` with a scoped client.
- `seedTenant(prisma, tenantId, seedFn, options?)` — sets `app.bypass_rls` and `app.tenant_id` inside a transaction, then executes `seedFn`.

Example:

```ts
import { seedTenant, withTenant } from 'glasswork';

await seedTenant(prisma, 'tenant-1', async (tx) => {
await tx.project.create({ data: { id: 'p1', name: 'One' } });
});

await withTenant(prisma, 'tenant-1', async (tenantPrisma) => {
const projects = await tenantPrisma.project.findMany();
expect(projects).toHaveLength(1);
});
```

## Performance notes

- Wrapping each query in a transaction adds a small overhead; keep it enabled unless you manage session variables per connection yourself.
- For bulk operations, batch work inside a single `prisma.$transaction` to set variables once.

## CLI status

A `glasswork generate rls` helper is planned but not shipped yet. Until then:
- Keep tenant fields consistent (`tenantId` with an index).
- Generate policies manually using the SQL snippets above.
- If you need automation, mirror the `formatSetStatement` pattern to build your own migration scripts.
2 changes: 2 additions & 0 deletions src/hono.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Augment Hono's context with Glasswork-specific variables
*/
import type { TenantContext } from './rls/types.js';
import type { OpenAPIResponseHook } from './types.js';

export interface Session {
Expand All @@ -13,5 +14,6 @@ declare module 'hono' {
interface ContextVariableMap {
session?: Session;
openapiResponseHooks?: OpenAPIResponseHook[];
tenantContext?: TenantContext;
}
}
20 changes: 19 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ export {

// Middleware
export { createRateLimitMiddleware } from './middleware/rate-limit.js';

// OpenAPI
export { defaultOpenAPIComponents } from './openapi/defaults.js';
export { configureOpenAPI } from './openapi/openapi.js';
Expand All @@ -156,6 +155,25 @@ export {
paginationHeadersProcessor,
responseHeadersProcessor,
} from './openapi/openapi-processors.js';
// RLS
export {
type AdminClientOptions,
createAdminClient,
createRLSClient,
createRLSProvider,
type RLSConfig,
type RLSMiddlewareOptions,
type RLSProviderOptions,
rlsMiddleware,
type SeedTenantOptions,
type SessionVariableNames,
seedTenant,
type TenantContext,
type TenantContextExtractor,
type TenantRole,
type WithTenantOptions,
withTenant,
} from './rls/index.js';

// Utilities
export { deepMerge } from './utils/deep-merge.js';
Expand Down
151 changes: 151 additions & 0 deletions src/rls/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { PrismaClient } from '@prisma/client';
import type { RLSConfig, SessionVariableNames, TenantContext } from './types.js';
import { assertTenantContext, formatSetStatement } from './utils.js';

const DEFAULT_SESSION_VARIABLES: SessionVariableNames = {
tenantId: 'app.tenant_id',
userId: 'app.user_id',
role: 'app.user_role',
bypass: 'app.bypass_rls',
};

const DEFAULT_CONFIG: RLSConfig = {
sessionVariables: DEFAULT_SESSION_VARIABLES,
useTransaction: true,
};

type RawExecutor = {
$executeRawUnsafe: (query: string) => Promise<unknown>;
};

type OperationInvoker = (args: unknown) => unknown;

/**
* Create a Prisma client extension that sets RLS session variables
* for every query.
*/
export function createRLSClient<TClient extends PrismaClient>(
prisma: TClient,
context: TenantContext,
config?: Partial<RLSConfig>
): TClient {
const mergedConfig = buildConfig(config);
const tenantContext = assertTenantContext(context, 'tenantContext');
const statements = createStatements(tenantContext, mergedConfig.sessionVariables);

return prisma.$extends({
name: 'rls',
query: {
$allOperations: async ({ model, operation, args, query }) => {
if (mergedConfig.useTransaction) {
return prisma.$transaction(async (tx) => {
await applyStatements(tx, statements);
const operationFn = findOperation(tx, model, operation);
if (operationFn) {
return operationFn(args);
}
return query(args);
});
}

await applyStatements(prisma, statements);
return query(args);
},
},
}) as TClient;
}

export interface AdminClientOptions {
bypassVariable?: string;
useTransaction?: boolean;
}

/**
* Create a Prisma client that sets a bypass flag for administrative operations.
*/
export function createAdminClient<TClient extends PrismaClient>(
prisma: TClient,
options: AdminClientOptions = {}
): TClient {
const bypassVariable =
options.bypassVariable ?? DEFAULT_SESSION_VARIABLES.bypass ?? 'app.bypass_rls';
const useTransaction = options.useTransaction ?? true;
const statements = [formatSetStatement(bypassVariable, 'true')];

return prisma.$extends({
name: 'rls-admin-bypass',
query: {
$allOperations: async ({ model, operation, args, query }) => {
if (useTransaction) {
return prisma.$transaction(async (tx) => {
await applyStatements(tx, statements);
const operationFn = findOperation(tx, model, operation);
if (operationFn) {
return operationFn(args);
}
return query(args);
});
}

await applyStatements(prisma, statements);
return query(args);
},
},
}) as TClient;
}

function buildConfig(config?: Partial<RLSConfig>): RLSConfig {
return {
sessionVariables: {
...DEFAULT_SESSION_VARIABLES,
...config?.sessionVariables,
},
useTransaction: config?.useTransaction ?? DEFAULT_CONFIG.useTransaction,
};
}

function createStatements(
context: TenantContext,
sessionVariables: SessionVariableNames
): string[] {
return [
formatSetStatement(sessionVariables.tenantId, context.tenantId),
formatSetStatement(sessionVariables.userId, context.userId),
formatSetStatement(sessionVariables.role, context.role),
];
}

async function applyStatements(target: RawExecutor, statements: string[]): Promise<void> {
for (const statement of statements) {
await target.$executeRawUnsafe(statement);
}
}

function findOperation(
client: unknown,
model: string | undefined,
operation: string
): OperationInvoker | undefined {
if (!client || typeof client !== 'object') {
return undefined;
}

const scope = model ? (client as Record<string, unknown>)[model] : client;

if (!scope || typeof scope !== 'object') {
return undefined;
}

const candidate = (scope as Record<string, unknown>)[operation];
return typeof candidate === 'function' ? (candidate as OperationInvoker) : undefined;
}

/**
* @internal Exported for test utilities.
*/
export const __private__ = {
buildConfig,
createStatements,
applyStatements,
findOperation,
};
19 changes: 19 additions & 0 deletions src/rls/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export {
type AdminClientOptions,
createAdminClient,
createRLSClient,
} from './client.js';
export { rlsMiddleware } from './middleware.js';
export { createRLSProvider } from './provider.js';
export { seedTenant, withTenant } from './testing.js';
export type {
RLSConfig,
RLSMiddlewareOptions,
RLSProviderOptions,
SeedTenantOptions,
SessionVariableNames,
TenantContext,
TenantContextExtractor,
TenantRole,
WithTenantOptions,
} from './types.js';
Loading