@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/package.json +21 -0
  4. package/skills/rebase-admin/SKILL.md +816 -0
  5. package/skills/rebase-api/SKILL.md +673 -0
  6. package/skills/rebase-api/references/.gitkeep +3 -0
  7. package/skills/rebase-auth/SKILL.md +1323 -0
  8. package/skills/rebase-auth/references/.gitkeep +3 -0
  9. package/skills/rebase-backend-postgres/SKILL.md +633 -0
  10. package/skills/rebase-backend-postgres/references/.gitkeep +3 -0
  11. package/skills/rebase-basics/SKILL.md +757 -0
  12. package/skills/rebase-basics/references/.gitkeep +3 -0
  13. package/skills/rebase-collections/SKILL.md +1421 -0
  14. package/skills/rebase-collections/references/.gitkeep +3 -0
  15. package/skills/rebase-cron-jobs/SKILL.md +704 -0
  16. package/skills/rebase-cron-jobs/references/.gitkeep +1 -0
  17. package/skills/rebase-custom-functions/SKILL.md +281 -0
  18. package/skills/rebase-deployment/SKILL.md +894 -0
  19. package/skills/rebase-deployment/references/.gitkeep +3 -0
  20. package/skills/rebase-design-language/SKILL.md +692 -0
  21. package/skills/rebase-email/SKILL.md +701 -0
  22. package/skills/rebase-email/references/.gitkeep +1 -0
  23. package/skills/rebase-entity-history/SKILL.md +485 -0
  24. package/skills/rebase-entity-history/references/.gitkeep +1 -0
  25. package/skills/rebase-local-env-setup/SKILL.md +189 -0
  26. package/skills/rebase-local-env-setup/references/.gitkeep +3 -0
  27. package/skills/rebase-realtime/SKILL.md +759 -0
  28. package/skills/rebase-realtime/references/.gitkeep +3 -0
  29. package/skills/rebase-sdk/SKILL.md +617 -0
  30. package/skills/rebase-sdk/references/.gitkeep +0 -0
  31. package/skills/rebase-security/SKILL.md +646 -0
  32. package/skills/rebase-storage/SKILL.md +989 -0
  33. package/skills/rebase-storage/references/.gitkeep +3 -0
  34. package/skills/rebase-studio/SKILL.md +772 -0
  35. package/skills/rebase-studio/references/.gitkeep +3 -0
  36. package/skills/rebase-ui-components/SKILL.md +1579 -0
  37. package/skills/rebase-ui-components/references/.gitkeep +3 -0
  38. package/skills/rebase-webhooks/SKILL.md +618 -0
  39. 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