@rebasepro/agent-skills 0.0.1-canary.4829d6e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/package.json +21 -0
- package/skills/rebase-admin/SKILL.md +816 -0
- package/skills/rebase-api/SKILL.md +673 -0
- package/skills/rebase-api/references/.gitkeep +3 -0
- package/skills/rebase-auth/SKILL.md +1323 -0
- package/skills/rebase-auth/references/.gitkeep +3 -0
- package/skills/rebase-backend-postgres/SKILL.md +633 -0
- package/skills/rebase-backend-postgres/references/.gitkeep +3 -0
- package/skills/rebase-basics/SKILL.md +757 -0
- package/skills/rebase-basics/references/.gitkeep +3 -0
- package/skills/rebase-collections/SKILL.md +1421 -0
- package/skills/rebase-collections/references/.gitkeep +3 -0
- package/skills/rebase-cron-jobs/SKILL.md +704 -0
- package/skills/rebase-cron-jobs/references/.gitkeep +1 -0
- package/skills/rebase-custom-functions/SKILL.md +281 -0
- package/skills/rebase-deployment/SKILL.md +894 -0
- package/skills/rebase-deployment/references/.gitkeep +3 -0
- package/skills/rebase-design-language/SKILL.md +692 -0
- package/skills/rebase-email/SKILL.md +701 -0
- package/skills/rebase-email/references/.gitkeep +1 -0
- package/skills/rebase-entity-history/SKILL.md +485 -0
- package/skills/rebase-entity-history/references/.gitkeep +1 -0
- package/skills/rebase-local-env-setup/SKILL.md +189 -0
- package/skills/rebase-local-env-setup/references/.gitkeep +3 -0
- package/skills/rebase-realtime/SKILL.md +759 -0
- package/skills/rebase-realtime/references/.gitkeep +3 -0
- package/skills/rebase-sdk/SKILL.md +617 -0
- package/skills/rebase-sdk/references/.gitkeep +0 -0
- package/skills/rebase-security/SKILL.md +646 -0
- package/skills/rebase-storage/SKILL.md +989 -0
- package/skills/rebase-storage/references/.gitkeep +3 -0
- package/skills/rebase-studio/SKILL.md +772 -0
- package/skills/rebase-studio/references/.gitkeep +3 -0
- package/skills/rebase-ui-components/SKILL.md +1579 -0
- package/skills/rebase-ui-components/references/.gitkeep +3 -0
- package/skills/rebase-webhooks/SKILL.md +618 -0
- package/skills/rebase-webhooks/references/.gitkeep +1 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rebase-security
|
|
3
|
+
description: Comprehensive guide to the Rebase backend security architecture. Use this skill when the user asks about securing their application, backend-level access control, request interception, DataHooks for security, fail-closed design, or how security works without database-level RLS. Also use when the user needs to implement PII masking, tenant isolation, role-based access control at the API layer, or cross-cutting security concerns.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rebase Security Architecture
|
|
7
|
+
|
|
8
|
+
Rebase implements a **multi-layered, defense-in-depth** security architecture. Security is enforced at the **application level** — not just at the database level. This means your data is protected regardless of whether the underlying database supports native Row-Level Security (RLS) or not.
|
|
9
|
+
|
|
10
|
+
> **IMPORTANT FOR AGENTS:** Always read the `rebase-basics` and `rebase-auth` skills for auth configuration details. This skill focuses on the **security architecture** and **backend-level enforcement mechanisms**.
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
- [Security Architecture Overview](#security-architecture-overview)
|
|
15
|
+
- [Request Pipeline](#request-pipeline)
|
|
16
|
+
- [Layer 1: Auth Middleware](#layer-1-auth-middleware)
|
|
17
|
+
- [Layer 2: API Key Permission Guard](#layer-2-api-key-permission-guard)
|
|
18
|
+
- [Layer 3: DataHooks (API Boundary)](#layer-3-datahooks-api-boundary)
|
|
19
|
+
- [Layer 4: Scoped DataDriver](#layer-4-scoped-datadriver)
|
|
20
|
+
- [Layer 5: Collection Callbacks](#layer-5-collection-callbacks)
|
|
21
|
+
- [Fail-Closed Design](#fail-closed-design)
|
|
22
|
+
- [Securing Without Database RLS](#securing-without-database-rls)
|
|
23
|
+
- [Common Security Patterns](#common-security-patterns)
|
|
24
|
+
- [Security Checklist](#security-checklist)
|
|
25
|
+
- [References](#references)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Security Architecture Overview
|
|
30
|
+
|
|
31
|
+
Every request — REST, GraphQL, and WebSocket — passes through **5 security layers** before data is returned to the client. These layers are enforced at the **application level** and work independently of any database-native security mechanism.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
┌──────────────────────────────────────────────────────┐
|
|
35
|
+
│ CLIENT REQUEST │
|
|
36
|
+
│ (REST / GraphQL / WebSocket) │
|
|
37
|
+
└──────────────┬───────────────────────────────────────┘
|
|
38
|
+
│
|
|
39
|
+
┌──────────────▼───────────────────────────────────────┐
|
|
40
|
+
│ Layer 1: Auth Middleware │
|
|
41
|
+
│ JWT / Service Key / API Key / Custom AuthAdapter │
|
|
42
|
+
│ → Identifies user, scopes the DataDriver │
|
|
43
|
+
└──────────────┬───────────────────────────────────────┘
|
|
44
|
+
│
|
|
45
|
+
┌──────────────▼───────────────────────────────────────┐
|
|
46
|
+
│ Layer 2: API Key Permission Guard │
|
|
47
|
+
│ Per-collection, per-operation permission check │
|
|
48
|
+
│ (only for API key requests) │
|
|
49
|
+
└──────────────┬───────────────────────────────────────┘
|
|
50
|
+
│
|
|
51
|
+
┌──────────────▼───────────────────────────────────────┐
|
|
52
|
+
│ Layer 3: DataHooks (API Boundary) │
|
|
53
|
+
│ Cross-cutting interception for ALL collections │
|
|
54
|
+
│ afterRead / beforeSave / beforeDelete │
|
|
55
|
+
└──────────────┬───────────────────────────────────────┘
|
|
56
|
+
│
|
|
57
|
+
┌──────────────▼───────────────────────────────────────┐
|
|
58
|
+
│ Layer 4: Scoped DataDriver │
|
|
59
|
+
│ driver.withAuth(user) — applies RLS policies │
|
|
60
|
+
│ (PostgreSQL: SET LOCAL session vars + native RLS) │
|
|
61
|
+
└──────────────┬───────────────────────────────────────┘
|
|
62
|
+
│
|
|
63
|
+
┌──────────────▼───────────────────────────────────────┐
|
|
64
|
+
│ Layer 5: Collection Callbacks (Per-Collection) │
|
|
65
|
+
│ beforeSave / afterRead / beforeDelete │
|
|
66
|
+
│ Per-collection hooks inside the DataDriver │
|
|
67
|
+
└──────────────────────────────────────────────────────┘
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
> **KEY INSIGHT:** Layers 1–3 and Layer 5 are **application-level** — they don't depend on the database. Even if you cannot configure database-level RLS policies, these layers provide full security coverage.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Request Pipeline
|
|
75
|
+
|
|
76
|
+
All three protocols share the same security middleware stack:
|
|
77
|
+
|
|
78
|
+
### REST
|
|
79
|
+
```
|
|
80
|
+
HTTP Request → Auth Middleware → API Key Guard → DataHooks → Scoped Driver → Collection Callbacks → Response
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### GraphQL
|
|
84
|
+
```
|
|
85
|
+
GraphQL Request → Auth Middleware → API Key Guard → Scoped Driver → Collection Callbacks → Response
|
|
86
|
+
```
|
|
87
|
+
GraphQL shares the same Hono middleware chain as REST. Resolvers extract the scoped driver from context and throw if unavailable.
|
|
88
|
+
|
|
89
|
+
### WebSocket
|
|
90
|
+
```
|
|
91
|
+
WS Connect → AUTHENTICATE message → Token Verification → Per-Operation Scoped Driver → Response
|
|
92
|
+
```
|
|
93
|
+
WebSocket auth follows a message-based flow:
|
|
94
|
+
1. Client sends an `AUTHENTICATE` message with a JWT token.
|
|
95
|
+
2. Token is verified via `extractUserFromToken(token)`.
|
|
96
|
+
3. Session is marked as authenticated.
|
|
97
|
+
4. Each subsequent data operation calls `getScopedDelegate()` to create a user-scoped driver.
|
|
98
|
+
5. Admin-only operations (e.g., `EXECUTE_SQL`) require `isAdminSession()`.
|
|
99
|
+
6. Rate limiting: 2000 messages per 60 seconds.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Layer 1: Auth Middleware
|
|
104
|
+
|
|
105
|
+
The auth middleware is the **first line of defense**. It runs on every request and does two things:
|
|
106
|
+
|
|
107
|
+
1. **Identifies the user** — Extracts credentials from the `Authorization` header (JWT, service key, API key, or custom adapter).
|
|
108
|
+
2. **Scopes the DataDriver** — Calls `scopeDataDriver(driver, user)` which invokes `driver.withAuth(user)` to return a security-scoped clone.
|
|
109
|
+
|
|
110
|
+
The scoped driver is placed into `c.set("driver", scopedDriver)`. The raw, unscoped driver is **never** placed in the request context.
|
|
111
|
+
|
|
112
|
+
### How Scoping Works
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// From packages/server/src/auth/rls-scope.ts
|
|
116
|
+
export async function scopeDataDriver(
|
|
117
|
+
driver: DataDriver,
|
|
118
|
+
user: { uid: string; roles?: string[] }
|
|
119
|
+
): Promise<DataDriver> {
|
|
120
|
+
if (isRLSScopedDriver(driver)) {
|
|
121
|
+
// Fail closed — if withAuth() throws, the request is DENIED
|
|
122
|
+
return await driver.withAuth(user);
|
|
123
|
+
}
|
|
124
|
+
return driver;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Identity Types
|
|
129
|
+
|
|
130
|
+
| Auth Method | `uid` | `roles` | RLS Behavior |
|
|
131
|
+
|---|---|---|---|
|
|
132
|
+
| JWT (authenticated user) | User's ID | User's app roles | Full RLS enforcement |
|
|
133
|
+
| Service Key | `"service"` | `["admin"]` | Bypasses RLS (admin access) |
|
|
134
|
+
| API Key (default) | `"api-key:{id}"` | `["service"]` | Bypasses RLS, scoped by permissions |
|
|
135
|
+
| API Key (admin) | `"api-key:{id}"` | `["admin", "service"]` | Bypasses RLS, full admin access |
|
|
136
|
+
| Anonymous (`requireAuth: false`) | `"anon"` | `["anon"]` | RLS with anonymous identity |
|
|
137
|
+
| No token + `requireAuth: true` | — | — | **Rejected (401)** |
|
|
138
|
+
|
|
139
|
+
> **IMPORTANT FOR AGENTS:** These are **reserved system identity values** that the middleware injects automatically. When writing DataHooks or Collection Callbacks, developers should use these identities to gate behavior:
|
|
140
|
+
> - `uid: "service"` + `roles: ["admin"]` — Server-side `rebase.data` calls (cron jobs, custom functions using `rebase.data`, webhooks). These go through the full middleware pipeline authenticated with the service key.
|
|
141
|
+
> - `uid: "anon"` + `roles: ["anon"]` — Unauthenticated requests when `requireAuth: false`. **Note:** for anonymous REST requests, `context.user` in Collection Callbacks may be `undefined`; only the DataDriver is scoped with the anon identity. For WebSocket connections, a full `User` object with `uid: "anon"` is provided.
|
|
142
|
+
> - `uid: "api-key:{id}"` + `roles: ["service"]` (or `["admin", "service"]`) — API key requests.
|
|
143
|
+
> - Real user IDs and roles for JWT-authenticated requests.
|
|
144
|
+
>
|
|
145
|
+
> **Key insight:** `rebase.data` (the server-side singleton) is NOT a raw admin driver — it round-trips through the REST API using the service key, so all DataHooks and Collection Callbacks fire with `uid: "service"`, `roles: ["admin"]`. This means callbacks can distinguish server-internal reads from end-user reads by checking `context.user?.roles?.includes("admin")`.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Layer 2: API Key Permission Guard
|
|
150
|
+
|
|
151
|
+
When a request is authenticated via an API key (prefixed `rk_`), the permission guard enforces **per-collection, per-operation** access control:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
interface ApiKeyPermission {
|
|
155
|
+
collection: string; // Collection slug, or "*" for all
|
|
156
|
+
operations: ("read" | "write" | "delete")[];
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- `GET` → requires `"read"` permission
|
|
161
|
+
- `POST` / `PUT` / `PATCH` → requires `"write"` permission
|
|
162
|
+
- `DELETE` → requires `"delete"` permission
|
|
163
|
+
|
|
164
|
+
This layer runs in both REST and GraphQL. If the API key lacks the required permission, the request is rejected with **403 Forbidden**.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Layer 3: DataHooks (API Boundary)
|
|
169
|
+
|
|
170
|
+
DataHooks are the **primary mechanism for backend-level security** when you cannot or do not want to use database-level RLS. They intercept **all** collection operations at the REST API boundary — a single cross-cutting point for every collection.
|
|
171
|
+
|
|
172
|
+
### Configuration
|
|
173
|
+
|
|
174
|
+
DataHooks are configured via the `hooks` property of `initializeRebaseBackend()`:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { initializeRebaseBackend } from "@rebasepro/server";
|
|
178
|
+
import type { BackendHooks, BackendHookContext } from "@rebasepro/types";
|
|
179
|
+
|
|
180
|
+
const hooks: BackendHooks = {
|
|
181
|
+
data: {
|
|
182
|
+
// Intercept ALL reads across ALL collections
|
|
183
|
+
afterRead(slug, entity, ctx) {
|
|
184
|
+
// Return entity to allow, return null to filter out
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
// Intercept ALL writes across ALL collections
|
|
188
|
+
beforeSave(slug, values, entityId, ctx) {
|
|
189
|
+
// Return values to allow, throw to reject
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// Intercept ALL deletes across ALL collections
|
|
193
|
+
beforeDelete(slug, entityId, ctx) {
|
|
194
|
+
// Return void to allow, throw to reject
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// Post-write side effects
|
|
198
|
+
afterSave(slug, entity, ctx) { /* fire-and-forget */ },
|
|
199
|
+
afterDelete(slug, entityId, ctx) { /* fire-and-forget */ },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
await initializeRebaseBackend({
|
|
204
|
+
server,
|
|
205
|
+
app,
|
|
206
|
+
database: createPostgresAdapter({ connection: db, schema }),
|
|
207
|
+
auth: { jwtSecret: "...", /* ... */ },
|
|
208
|
+
hooks, // ← Backend-level security hooks
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### DataHooks Interface
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
interface DataHooks {
|
|
216
|
+
afterRead?(slug: string, entity: Record<string, unknown>, context: BackendHookContext):
|
|
217
|
+
Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
|
|
218
|
+
|
|
219
|
+
beforeSave?(slug: string, values: Record<string, unknown>, entityId: string | undefined, context: BackendHookContext):
|
|
220
|
+
Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
221
|
+
|
|
222
|
+
afterSave?(slug: string, entity: Record<string, unknown>, context: BackendHookContext):
|
|
223
|
+
void | Promise<void>;
|
|
224
|
+
|
|
225
|
+
beforeDelete?(slug: string, entityId: string, context: BackendHookContext):
|
|
226
|
+
void | Promise<void>;
|
|
227
|
+
|
|
228
|
+
afterDelete?(slug: string, entityId: string, context: BackendHookContext):
|
|
229
|
+
void | Promise<void>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface BackendHookContext {
|
|
233
|
+
requestUser?: { userId: string; roles: string[] };
|
|
234
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Blocking vs Fire-and-Forget
|
|
239
|
+
|
|
240
|
+
| Hook | Can Block? | How to Block |
|
|
241
|
+
|---|---|---|
|
|
242
|
+
| `afterRead` | Yes | Return `null` to filter out the entity |
|
|
243
|
+
| `beforeSave` | Yes | Throw an error to abort the save |
|
|
244
|
+
| `beforeDelete` | Yes | Throw an error to prevent deletion |
|
|
245
|
+
| `afterSave` | No | Fire-and-forget (errors are caught and logged) |
|
|
246
|
+
| `afterDelete` | No | Fire-and-forget |
|
|
247
|
+
|
|
248
|
+
### Execution Order
|
|
249
|
+
|
|
250
|
+
DataHooks run **after** per-collection `CollectionCallbacks` (which execute inside the DataDriver, closer to the database) and **before** the API response is sent to the client. This gives you two opportunities to enforce security:
|
|
251
|
+
|
|
252
|
+
1. **CollectionCallbacks** — per-collection, inside the driver
|
|
253
|
+
2. **DataHooks** — cross-cutting, at the API boundary
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Layer 4: Scoped DataDriver
|
|
258
|
+
|
|
259
|
+
The scoped DataDriver is the layer where database-level RLS is enforced. For PostgreSQL, `withAuth()` wraps every operation in a transaction with session variables:
|
|
260
|
+
|
|
261
|
+
```sql
|
|
262
|
+
SELECT
|
|
263
|
+
set_config('app.user_id', :userId, true),
|
|
264
|
+
set_config('app.user_roles', :rolesString, true),
|
|
265
|
+
set_config('app.jwt', :jwtClaims, true)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
PostgreSQL RLS policies use `auth.uid()`, `auth.roles()`, and `auth.jwt()` to read these session variables and enforce row-level access control.
|
|
269
|
+
|
|
270
|
+
> **IMPORTANT:** This layer is database-specific. If your project does not use PostgreSQL RLS, security is still enforced by Layers 1–3 and Layer 5. See [Securing Without Database RLS](#securing-without-database-rls).
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Layer 5: Collection Callbacks
|
|
275
|
+
|
|
276
|
+
Collection callbacks are per-collection lifecycle hooks that run **inside** the DataDriver, close to the database. They provide collection-specific security enforcement:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
const ordersCollection: PostgresCollectionConfig = {
|
|
280
|
+
name: "Orders",
|
|
281
|
+
slug: "orders",
|
|
282
|
+
table: "orders",
|
|
283
|
+
callbacks: {
|
|
284
|
+
beforeSave: async ({ values, context }) => {
|
|
285
|
+
// Enforce business rule: only admins can set high-value orders
|
|
286
|
+
const user = context.user;
|
|
287
|
+
if (values.total > 10000 && !user?.roles?.includes("admin")) {
|
|
288
|
+
throw new Error("High-value orders require admin approval");
|
|
289
|
+
}
|
|
290
|
+
return values;
|
|
291
|
+
},
|
|
292
|
+
beforeDelete: async ({ row, context }) => {
|
|
293
|
+
// Prevent deletion of fulfilled orders
|
|
294
|
+
if (row.status === "fulfilled") {
|
|
295
|
+
throw new Error("Cannot delete fulfilled orders");
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
properties: { /* ... */ }
|
|
300
|
+
};
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
For full documentation on collection callbacks, see the `rebase-collections` skill.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Fail-Closed Design
|
|
308
|
+
|
|
309
|
+
Rebase follows a **fail-closed** security model throughout the stack:
|
|
310
|
+
|
|
311
|
+
1. **Scoped driver or nothing** — The REST API's `getScopedDriver()` throws if no scoped driver is available. It **never** falls back to the unscoped driver:
|
|
312
|
+
```typescript
|
|
313
|
+
private getScopedDriver(c): DataDriver {
|
|
314
|
+
const driver = c.get("driver") as DataDriver | undefined;
|
|
315
|
+
if (!driver) throw ApiError.internal("Scoped driver not available");
|
|
316
|
+
return driver;
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
2. **RLS scoping failures are fatal** — If `driver.withAuth()` throws, the error propagates and the request is rejected with 500. The system does not silently skip RLS.
|
|
321
|
+
|
|
322
|
+
3. **Unauthenticated requests are rejected** — When `requireAuth: true` (the default), requests without a valid token receive 401. The unscoped driver never reaches the handler.
|
|
323
|
+
|
|
324
|
+
4. **API keys with missing permissions are rejected** — If an API key lacks the required permission for a collection/operation, the request is rejected with 403.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Securing Without Database RLS
|
|
329
|
+
|
|
330
|
+
If you cannot modify database-level RLS policies — or your database doesn't support them — use **DataHooks** and **Collection Callbacks** to enforce security entirely at the application level.
|
|
331
|
+
|
|
332
|
+
### Strategy: DataHooks as Your Security Layer
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
import { ApiError } from "@rebasepro/server";
|
|
336
|
+
import type { BackendHooks, BackendHookContext } from "@rebasepro/types";
|
|
337
|
+
|
|
338
|
+
const hooks: BackendHooks = {
|
|
339
|
+
data: {
|
|
340
|
+
// ── READ SECURITY ──
|
|
341
|
+
// Filter entities based on user role and ownership
|
|
342
|
+
afterRead(slug, entity, ctx) {
|
|
343
|
+
const user = ctx.requestUser;
|
|
344
|
+
|
|
345
|
+
// Admins see everything
|
|
346
|
+
if (user?.roles.includes("admin")) return entity;
|
|
347
|
+
|
|
348
|
+
// For the "orders" collection, users only see their own
|
|
349
|
+
if (slug === "orders") {
|
|
350
|
+
if (entity.user_id !== user?.userId) return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// For "internal_notes", non-admins never see them
|
|
354
|
+
if (slug === "internal_notes") return null;
|
|
355
|
+
|
|
356
|
+
return entity;
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
// ── WRITE SECURITY ──
|
|
360
|
+
// Validate and enforce ownership on creates/updates
|
|
361
|
+
beforeSave(slug, values, entityId, ctx) {
|
|
362
|
+
const user = ctx.requestUser;
|
|
363
|
+
if (!user) throw ApiError.unauthorized("Authentication required");
|
|
364
|
+
|
|
365
|
+
// Enforce ownership: stamp the user_id on creation
|
|
366
|
+
if (!entityId) {
|
|
367
|
+
values.user_id = user.userId;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Prevent role escalation: non-admins can't set role fields
|
|
371
|
+
if (!user.roles.includes("admin")) {
|
|
372
|
+
delete values.role;
|
|
373
|
+
delete values.is_admin;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return values;
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
// ── DELETE SECURITY ──
|
|
380
|
+
// Only admins can delete, or owners of their own records
|
|
381
|
+
beforeDelete(slug, entityId, ctx) {
|
|
382
|
+
const user = ctx.requestUser;
|
|
383
|
+
if (!user) throw ApiError.unauthorized("Authentication required");
|
|
384
|
+
|
|
385
|
+
if (!user.roles.includes("admin")) {
|
|
386
|
+
// For non-admins, you may need to fetch the entity first
|
|
387
|
+
// to verify ownership. Use Collection Callbacks for this pattern
|
|
388
|
+
// since they receive the full entity.
|
|
389
|
+
throw ApiError.forbidden("Only admins can delete records");
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Strategy: Collection Callbacks for Ownership Checks
|
|
397
|
+
|
|
398
|
+
Collection Callbacks receive the full entity data, making them ideal for ownership verification on deletes and updates:
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
const ordersCollection: PostgresCollectionConfig = {
|
|
402
|
+
name: "Orders",
|
|
403
|
+
slug: "orders",
|
|
404
|
+
table: "orders",
|
|
405
|
+
callbacks: {
|
|
406
|
+
beforeSave: async ({ values, id, context, previousValues }) => {
|
|
407
|
+
const user = context.user;
|
|
408
|
+
if (!user) throw new Error("Unauthorized");
|
|
409
|
+
|
|
410
|
+
// On update, verify the current user owns this order
|
|
411
|
+
if (id && previousValues) {
|
|
412
|
+
if (previousValues.user_id !== user.uid && !user.roles?.includes("admin")) {
|
|
413
|
+
throw new Error("You can only edit your own orders");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// On create, stamp ownership
|
|
418
|
+
if (!id) {
|
|
419
|
+
values.user_id = user.uid;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return values;
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
beforeDelete: async ({ row, context }) => {
|
|
426
|
+
const user = context.user;
|
|
427
|
+
if (!user) throw new Error("Unauthorized");
|
|
428
|
+
|
|
429
|
+
if (row.user_id !== user.uid && !user.roles?.includes("admin")) {
|
|
430
|
+
throw new Error("You can only delete your own orders");
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
properties: { /* ... */ }
|
|
435
|
+
};
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Combining Both Layers
|
|
439
|
+
|
|
440
|
+
For maximum security, use both DataHooks and Collection Callbacks together:
|
|
441
|
+
|
|
442
|
+
| Concern | Best Layer | Why |
|
|
443
|
+
|---|---|---|
|
|
444
|
+
| Cross-cutting read filtering | DataHooks `afterRead` | Applies to ALL collections in one place |
|
|
445
|
+
| Cross-cutting write validation | DataHooks `beforeSave` | Single enforcement point for all writes |
|
|
446
|
+
| PII masking / field redaction | DataHooks `afterRead` | Cross-cutting, role-based |
|
|
447
|
+
| Ownership checks on writes/deletes | Collection Callbacks | Has access to the full entity for comparison |
|
|
448
|
+
| Business rule validation | Collection Callbacks | Collection-specific, typed values |
|
|
449
|
+
| Audit logging | DataHooks `afterSave` / `afterDelete` | Cross-cutting, fire-and-forget |
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## Common Security Patterns
|
|
454
|
+
|
|
455
|
+
### PII Masking
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
const hooks: BackendHooks = {
|
|
459
|
+
data: {
|
|
460
|
+
afterRead(slug, entity, ctx) {
|
|
461
|
+
if (ctx.requestUser?.roles.includes("admin")) return entity;
|
|
462
|
+
|
|
463
|
+
// Mask sensitive fields across all collections
|
|
464
|
+
if (entity.email) entity.email = "***@***.***";
|
|
465
|
+
if (entity.phone) entity.phone = "***-***-****";
|
|
466
|
+
if (entity.ssn) entity.ssn = "***-**-****";
|
|
467
|
+
|
|
468
|
+
return entity;
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Tenant Isolation (Multi-Tenancy)
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
const hooks: BackendHooks = {
|
|
478
|
+
data: {
|
|
479
|
+
afterRead(slug, entity, ctx) {
|
|
480
|
+
const userTenantId = ctx.requestUser?.roles
|
|
481
|
+
.find(r => r.startsWith("tenant:"))
|
|
482
|
+
?.replace("tenant:", "");
|
|
483
|
+
|
|
484
|
+
if (!userTenantId) return null;
|
|
485
|
+
if (entity.tenant_id !== userTenantId) return null;
|
|
486
|
+
|
|
487
|
+
return entity;
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
beforeSave(slug, values, entityId, ctx) {
|
|
491
|
+
const userTenantId = ctx.requestUser?.roles
|
|
492
|
+
.find(r => r.startsWith("tenant:"))
|
|
493
|
+
?.replace("tenant:", "");
|
|
494
|
+
|
|
495
|
+
if (!userTenantId) throw ApiError.forbidden("No tenant assigned");
|
|
496
|
+
|
|
497
|
+
// Stamp tenant on creation, prevent cross-tenant writes
|
|
498
|
+
if (!entityId) {
|
|
499
|
+
values.tenant_id = userTenantId;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return values;
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
beforeDelete(slug, entityId, ctx) {
|
|
506
|
+
// Tenant isolation on deletes is best handled in Collection Callbacks
|
|
507
|
+
// where you have access to the entity's tenant_id
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Membership-Scoped Access (RLS, no N+1)
|
|
514
|
+
|
|
515
|
+
When membership lives in a **join collection** (e.g. `team_members`), prefer the
|
|
516
|
+
first-class `policy.existsIn` predicate over a per-row `afterRead` lookup. It is
|
|
517
|
+
enforced by Postgres RLS in a single correlated `EXISTS` subquery — no N+1, and
|
|
518
|
+
it cannot be bypassed by a client that skips the SDK.
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
import { policy } from "@rebasepro/types";
|
|
522
|
+
|
|
523
|
+
// config/collections/documents.ts — only members of the doc's team can read it:
|
|
524
|
+
securityRules: [
|
|
525
|
+
{
|
|
526
|
+
operation: "select",
|
|
527
|
+
condition: policy.existsIn({
|
|
528
|
+
collection: "team_members",
|
|
529
|
+
where: policy.and(
|
|
530
|
+
policy.compare(policy.field("team_id"), "eq", policy.outerField("team_id")),
|
|
531
|
+
policy.compare(policy.field("user_id"), "eq", policy.authUid()),
|
|
532
|
+
),
|
|
533
|
+
}),
|
|
534
|
+
},
|
|
535
|
+
]
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Inside `where`: `policy.field(...)` = a column of the joined collection
|
|
539
|
+
(`team_members`); `policy.outerField(...)` = a column of the row being checked
|
|
540
|
+
(`documents`); `policy.authUid()` = the caller. Reach for the `afterRead`
|
|
541
|
+
approach above only when access depends on data RLS can't see (e.g. an external
|
|
542
|
+
service). Run `rebase db push` after editing the collection to apply the policy.
|
|
543
|
+
|
|
544
|
+
### Role-Based Collection Access
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
const hooks: BackendHooks = {
|
|
548
|
+
data: {
|
|
549
|
+
// Define which roles can access which collections
|
|
550
|
+
beforeSave(slug, values, entityId, ctx) {
|
|
551
|
+
const roleAccess: Record<string, string[]> = {
|
|
552
|
+
"financial_reports": ["admin", "finance"],
|
|
553
|
+
"hr_records": ["admin", "hr"],
|
|
554
|
+
"system_config": ["admin"],
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const allowedRoles = roleAccess[slug];
|
|
558
|
+
if (allowedRoles) {
|
|
559
|
+
const userRoles = ctx.requestUser?.roles || [];
|
|
560
|
+
const hasAccess = allowedRoles.some(r => userRoles.includes(r));
|
|
561
|
+
if (!hasAccess) {
|
|
562
|
+
throw ApiError.forbidden(`Insufficient permissions for ${slug}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return values;
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
afterRead(slug, entity, ctx) {
|
|
570
|
+
const roleAccess: Record<string, string[]> = {
|
|
571
|
+
"financial_reports": ["admin", "finance"],
|
|
572
|
+
"hr_records": ["admin", "hr"],
|
|
573
|
+
"system_config": ["admin"],
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const allowedRoles = roleAccess[slug];
|
|
577
|
+
if (allowedRoles) {
|
|
578
|
+
const userRoles = ctx.requestUser?.roles || [];
|
|
579
|
+
if (!allowedRoles.some(r => userRoles.includes(r))) return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return entity;
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### Immutable Records (Soft Delete Only)
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
const hooks: BackendHooks = {
|
|
592
|
+
data: {
|
|
593
|
+
beforeDelete(slug, entityId, ctx) {
|
|
594
|
+
const immutableCollections = ["audit_logs", "transactions", "invoices"];
|
|
595
|
+
if (immutableCollections.includes(slug)) {
|
|
596
|
+
throw ApiError.forbidden(
|
|
597
|
+
`Records in "${slug}" cannot be deleted. Use soft-delete instead.`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
beforeSave(slug, values, entityId, ctx) {
|
|
603
|
+
const appendOnlyCollections = ["audit_logs"];
|
|
604
|
+
if (appendOnlyCollections.includes(slug) && entityId) {
|
|
605
|
+
throw ApiError.forbidden(
|
|
606
|
+
`Records in "${slug}" are append-only and cannot be updated.`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
return values;
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## Security Checklist
|
|
618
|
+
|
|
619
|
+
Use this checklist when setting up security for a Rebase project:
|
|
620
|
+
|
|
621
|
+
- [ ] **Auth is configured** — `auth.jwtSecret` is set with a strong secret (≥ 32 chars)
|
|
622
|
+
- [ ] **`requireAuth` is `true`** — The default. Only set to `false` if you explicitly need unauthenticated access
|
|
623
|
+
- [ ] **Service key is set** — `auth.serviceKey` with ≥ 32 chars for server-to-server auth
|
|
624
|
+
- [ ] **Default role is NOT admin** — `auth.defaultRole` must never be `"admin"` (startup error)
|
|
625
|
+
- [ ] **DataHooks enforce access control** — If not using database RLS, `hooks.data` enforces read/write/delete permissions
|
|
626
|
+
- [ ] **Sensitive fields are masked** — `afterRead` masks PII for non-admin users
|
|
627
|
+
- [ ] **Ownership is enforced** — `beforeSave` stamps `user_id` on creation; Collection Callbacks verify ownership on update/delete
|
|
628
|
+
- [ ] **API keys are scoped** — API keys have minimal permissions (specific collections + operations)
|
|
629
|
+
- [ ] **API keys are never client-side** — API keys bypass RLS; only use server-side
|
|
630
|
+
- [ ] **CORS is configured** — Restrict origins in production
|
|
631
|
+
- [ ] **Rate limiting is in place** — Default limiters apply to auth endpoints; add custom limiters for sensitive operations
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
## References
|
|
636
|
+
|
|
637
|
+
- **RLS Scope**: `packages/server/src/auth/rls-scope.ts` — `scopeDataDriver()` implementation
|
|
638
|
+
- **Auth Middleware**: `packages/server/src/auth/middleware.ts` — JWT/service key/API key middleware
|
|
639
|
+
- **Adapter Middleware**: `packages/server/src/auth/adapter-middleware.ts` — Custom auth adapter middleware
|
|
640
|
+
- **API Key Guard**: `packages/server/src/auth/api-keys/api-key-permission-guard.ts`
|
|
641
|
+
- **REST API Generator**: `packages/server/src/api/rest/api-generator.ts` — DataHooks integration
|
|
642
|
+
- **Backend Hooks Types**: `packages/types/src/types/backend_hooks.ts` — `DataHooks`, `BackendHooks` interfaces
|
|
643
|
+
- **Backend Init**: `packages/server/src/init.ts` — `hooks` config property
|
|
644
|
+
- **Reserved Identity Values**: See Identity Types table above — `"service"`, `"anon"`, `"api-key:{id}"` are system-assigned identities in `context.user` / `ctx.requestUser`
|
|
645
|
+
- **Collection Callbacks**: See `rebase-collections` skill → Collection Callbacks section
|
|
646
|
+
- **Auth Configuration**: See `rebase-auth` skill → Server-Side Configuration section
|