Files
shiftcraft/.claude/skills/pocketbase-best-practices/references/api-rules-security.md
2026-04-17 23:26:01 +00:00

490 lines
17 KiB
Markdown

# API Rules & Security
**Impact: CRITICAL**
Access control rules, filter expressions, request context usage, and security patterns. Critical for protecting data and enforcing authorization.
---
## 1. Understand API Rule Types and Defaults
**Impact: CRITICAL (Prevents unauthorized access, data leaks, and security vulnerabilities)**
PocketBase uses five collection-level rules to control access. Understanding the difference between locked (null), open (""), and expression rules is critical for security.
**Incorrect (leaving rules open unintentionally):**
```javascript
// Collection with overly permissive rules
const collection = {
name: 'messages',
listRule: '', // Anyone can list all messages!
viewRule: '', // Anyone can view any message!
createRule: '', // Anyone can create messages!
updateRule: '', // Anyone can update any message!
deleteRule: '' // Anyone can delete any message!
};
// Complete security bypass - all data exposed
```
**Correct (explicit, restrictive rules):**
```javascript
// Collection with proper access control
const collection = {
name: 'messages',
// null = locked, only superusers can access
listRule: null, // Default: locked to superusers
// '' (empty string) = open to everyone (use sparingly)
viewRule: '@request.auth.id != ""', // Any authenticated user
// Expression = conditional access
createRule: '@request.auth.id != ""', // Must be logged in
updateRule: 'author = @request.auth.id', // Only author
deleteRule: 'author = @request.auth.id' // Only author
};
```
**Rule types explained:**
| Rule Value | Meaning | Use Case |
|------------|---------|----------|
| `null` | Locked (superusers only) | Admin-only data, system tables |
| `''` (empty string) | Open to everyone | Public content, no auth required |
| `'expression'` | Conditional access | Most common - check auth, ownership |
**Common patterns:**
```javascript
// Public read, authenticated write (enforce ownership on create)
listRule: '',
viewRule: '',
createRule: '@request.auth.id != "" && @request.body.author = @request.auth.id',
updateRule: 'author = @request.auth.id',
deleteRule: 'author = @request.auth.id'
// Private to owner only
listRule: 'owner = @request.auth.id',
viewRule: 'owner = @request.auth.id',
createRule: '@request.auth.id != ""',
updateRule: 'owner = @request.auth.id',
deleteRule: 'owner = @request.auth.id'
// Read-only public data
listRule: '',
viewRule: '',
createRule: null,
updateRule: null,
deleteRule: null
```
**Error responses by rule type:**
- List rule fail: 200 with empty items
- View/Update/Delete fail: 404 (hides existence)
- Create fail: 400
- Locked rule violation: 403
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/)
## 2. Use @collection for Cross-Collection Lookups
**Impact: HIGH (Enables complex authorization without denormalization)**
The `@collection` reference allows rules to query other collections, enabling complex authorization patterns like role-based access, team membership, and resource permissions.
**Incorrect (denormalizing data for access control):**
```javascript
// Duplicating team membership in every resource
const documentsSchema = [
{ name: 'title', type: 'text' },
{ name: 'team', type: 'relation' },
// Duplicated member list for access control - gets out of sync!
{ name: 'allowedUsers', type: 'relation', options: { maxSelect: 999 } }
];
// Rule checks duplicated data
listRule: 'allowedUsers ?= @request.auth.id'
// Problem: must update allowedUsers whenever team membership changes
```
**Correct (using @collection lookup):**
```javascript
// Clean schema - no duplication
const documentsSchema = [
{ name: 'title', type: 'text' },
{ name: 'team', type: 'relation', options: { collectionId: 'teams' } }
];
// Check team membership via @collection lookup
listRule: '@collection.team_members.user ?= @request.auth.id && @collection.team_members.team ?= team'
// Alternative: check if user is in team's members array
listRule: 'team.members ?= @request.auth.id'
// Role-based access via separate roles collection
listRule: '@collection.user_roles.user = @request.auth.id && @collection.user_roles.role = "admin"'
```
**Common patterns:**
```javascript
// Team-based access
// teams: { name, members (relation to users) }
// documents: { title, team (relation to teams) }
viewRule: 'team.members ?= @request.auth.id'
// Organization hierarchy
// orgs: { name }
// org_members: { org, user, role }
// projects: { name, org }
listRule: '@collection.org_members.org = org && @collection.org_members.user = @request.auth.id'
// Permission-based access
// permissions: { resource, user, level }
updateRule: '@collection.permissions.resource = id && @collection.permissions.user = @request.auth.id && @collection.permissions.level = "write"'
// Using aliases for complex queries
listRule: '@collection.memberships:m.user = @request.auth.id && @collection.memberships:m.team = team'
```
**Performance considerations:**
- Cross-collection lookups add query complexity
- Ensure referenced fields are indexed
- Consider caching for frequently accessed permissions
- Test performance with realistic data volumes
Reference: [PocketBase Collection Reference](https://pocketbase.io/docs/api-rules-and-filters/#collection-fields)
## 3. Master Filter Expression Syntax
**Impact: CRITICAL (Enables complex access control and efficient querying)**
PocketBase filter expressions use a specific syntax for both API rules and client-side queries. Understanding operators and composition is essential.
**Incorrect (invalid filter syntax):**
```javascript
// Wrong operator syntax
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status == "published"' // Wrong: == instead of =
});
// Missing quotes around strings
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = published' // Wrong: unquoted string
});
// Wrong boolean logic
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = "published" AND featured = true' // Wrong: AND instead of &&
});
```
**Correct (proper filter syntax):**
```javascript
// Equality and comparison operators
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = "published"' // Equals
});
filter: 'views != 0' // Not equals
filter: 'views > 100' // Greater than
filter: 'views >= 100' // Greater or equal
filter: 'price < 50.00' // Less than
filter: 'created <= "2024-01-01 00:00:00"' // Less or equal
// String operators
filter: 'title ~ "hello"' // Contains (case-insensitive)
filter: 'title !~ "spam"' // Does not contain
// Logical operators
filter: 'status = "published" && featured = true' // AND
filter: 'category = "news" || category = "blog"' // OR
filter: '(status = "draft" || status = "review") && author = "abc"' // Grouping
// Array/multi-value operators (for select, relation fields)
filter: 'tags ?= "featured"' // Any tag equals "featured"
filter: 'tags ?~ "tech"' // Any tag contains "tech"
// Null checks
filter: 'deletedAt = null' // Is null
filter: 'avatar != null' // Is not null
// Date comparisons
filter: 'created > "2024-01-01 00:00:00"'
filter: 'created >= @now' // Current timestamp
filter: 'expires < @today' // Start of today (UTC)
```
**Available operators:**
| Operator | Description |
|----------|-------------|
| `=` | Equal |
| `!=` | Not equal |
| `>` `>=` `<` `<=` | Comparison |
| `~` | Contains (LIKE %value%) |
| `!~` | Does not contain |
| `?=` `?!=` `?>` `?~` | Any element matches |
| `&&` | AND |
| `\|\|` | OR |
| `()` | Grouping |
**Date macros:**
- `@now` - Current UTC datetime
- `@today` - Start of today UTC
- `@month` - Start of current month UTC
- `@year` - Start of current year UTC
**Filter functions:**
- `strftime(fmt, datetime)` - Format/extract datetime parts (v0.36+). E.g. `strftime('%Y-%m', created) = "2026-03"`. See `rules-strftime.md` for the full format specifier list.
- `length(field)` - Element count of a multi-value field (file, relation, select). E.g. `length(tags) > 0`.
- `each(field, expr)` - Iterate a multi-value field: `each(tags, ? ~ "urgent")`.
- `issetIf(field, val)` - Conditional presence check for complex rules.
Reference: [PocketBase Filters](https://pocketbase.io/docs/api-rules-and-filters/#filters-syntax)
## 4. Default to Locked Rules, Open Explicitly
**Impact: CRITICAL (Defense in depth, prevents accidental data exposure)**
New collections should start with locked (null) rules and explicitly open only what's needed. This prevents accidental data exposure and follows the principle of least privilege.
**Incorrect (starting with open rules):**
```javascript
// Dangerous: copying rules from examples without thinking
const collection = {
name: 'user_settings',
listRule: '', // Open - leaks all user settings!
viewRule: '', // Open - anyone can view any setting
createRule: '', // Open - no auth required
updateRule: '', // Open - anyone can modify!
deleteRule: '' // Open - anyone can delete!
};
// Also dangerous: using auth check when ownership needed
const collection = {
name: 'private_notes',
listRule: '@request.auth.id != ""', // Any logged-in user sees ALL notes
viewRule: '@request.auth.id != ""',
updateRule: '@request.auth.id != ""', // Any user can edit ANY note!
};
```
**Correct (locked by default, explicitly opened):**
```javascript
// Step 1: Start locked
const collection = {
name: 'user_settings',
listRule: null, // Locked - superusers only
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null
};
// Step 2: Open only what's needed with proper checks
const collection = {
name: 'user_settings',
// Users can only see their own settings
listRule: 'user = @request.auth.id',
viewRule: 'user = @request.auth.id',
// Users can only create settings for themselves
createRule: '@request.auth.id != "" && @request.body.user = @request.auth.id',
// Users can only update their own settings
updateRule: 'user = @request.auth.id',
// Prevent deletion or restrict to owner
deleteRule: 'user = @request.auth.id'
};
// For truly public data, document why it's open
const collection = {
name: 'public_announcements',
// Intentionally public - these are site-wide announcements
listRule: '',
viewRule: '',
// Only admins can manage (using custom "role" field on auth collection)
// IMPORTANT: Prevent role self-assignment in the users collection updateRule:
// updateRule: 'id = @request.auth.id && @request.body.role:isset = false'
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"'
};
```
**Rule development workflow:**
1. **Start locked** - All rules `null`
2. **Identify access needs** - Who needs what access?
3. **Write minimal rules** - Open only required operations
4. **Test thoroughly** - Verify both allowed and denied cases
5. **Document decisions** - Comment why rules are set as they are
**Security checklist:**
- [ ] No empty string rules without justification
- [ ] Ownership checks on personal data
- [ ] Auth checks on write operations
- [ ] Admin-only rules for sensitive operations
- [ ] Tested with different user contexts
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/)
## 5. Use @request Context in API Rules
**Impact: CRITICAL (Enables dynamic, user-aware access control)**
The `@request` object provides access to the current request context including authenticated user, request body, query parameters, and headers. Use it to build dynamic access rules.
**Incorrect (hardcoded or missing auth checks):**
```javascript
// No authentication check
const collection = {
listRule: '', // Anyone can see everything
createRule: '' // Anyone can create
};
// Hardcoded user ID (never do this)
const collection = {
listRule: 'owner = "specific_user_id"' // Only one user can access
};
```
**Correct (using @request context):**
```javascript
// Check if user is authenticated
createRule: '@request.auth.id != ""'
// Check ownership via auth record
listRule: 'owner = @request.auth.id'
viewRule: 'owner = @request.auth.id'
updateRule: 'owner = @request.auth.id'
deleteRule: 'owner = @request.auth.id'
// Access auth record fields
// IMPORTANT: If using custom role fields, ensure update rules prevent
// users from modifying their own role: @request.body.role:isset = false
listRule: '@request.auth.role = "admin"'
listRule: '@request.auth.verified = true'
// Validate request body on create/update
createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id'
// Prevent changing certain fields
updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false'
// WARNING: Query parameters are user-controlled and should NOT be used
// for authorization decisions. Use them only for optional filtering behavior
// where the fallback is equally safe.
// listRule: '@request.query.publicOnly = "true" || owner = @request.auth.id'
// The above is UNSAFE - users can bypass ownership by adding ?publicOnly=true
// Instead, use separate endpoints or server-side logic for public vs. private views.
listRule: 'owner = @request.auth.id || public = true' // Use a record field, not query param
// Access nested auth relations
listRule: 'team.members ?= @request.auth.id'
```
**Available @request fields:**
| Field | Description |
|-------|-------------|
| `@request.auth.id` | Authenticated user's ID (empty string if not authenticated) |
| `@request.auth.*` | Any field from auth record (role, verified, email, etc.) |
| `@request.body.*` | Request body fields (create/update only) |
| `@request.query.*` | URL query parameters |
| `@request.headers.*` | Request headers |
| `@request.method` | HTTP method (GET, POST, etc.) |
| `@request.context` | Request context: `default`, `oauth2`, `otp`, `password`, `realtime`, `protectedFile` |
**Body field modifiers:**
```javascript
// Check if field is being set
updateRule: '@request.body.status:isset = false' // Can't change status
// Check if field changed from current value
updateRule: '@request.body.owner:changed = false' // Can't change owner
// Get length of array/string
createRule: '@request.body.tags:length <= 5' // Max 5 tags
```
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/#available-fields)
## 6. Use strftime() for Date Arithmetic in Filter Expressions
**Impact: MEDIUM (strftime() (added in v0.36) replaces brittle string prefix comparisons on datetime fields)**
PocketBase v0.36 added the `strftime()` function to the filter expression grammar. It maps directly to SQLite's [strftime](https://sqlite.org/lang_datefunc.html) and is the correct way to bucket, compare, or extract parts of a datetime field. Before v0.36 people worked around this with `~` (substring) matches against the ISO string; those workarounds are fragile (they break at midnight UTC, ignore timezones, and can't handle ranges).
**Incorrect (substring match on the ISO datetime string):**
```javascript
// ❌ "matches anything whose ISO string contains 2026-04-08" - breaks as soon
// as your DB stores sub-second precision or you cross a month boundary
const todayPrefix = new Date().toISOString().slice(0, 10);
const results = await pb.collection("orders").getList(1, 50, {
filter: `created ~ "${todayPrefix}"`, // ❌
});
```
**Correct (strftime with named format specifiers):**
```javascript
// "all orders created today (UTC)"
const results = await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)`,
});
// "all orders from March 2026"
await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m', created) = "2026-03"`,
});
// "orders created this hour"
await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m-%d %H', created) = strftime('%Y-%m-%d %H', @now)`,
});
```
```javascript
// Same function is available inside API rules:
// collection "orders" - List rule:
// @request.auth.id != "" &&
// user = @request.auth.id &&
// strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)
```
**Common format specifiers:**
| Specifier | Meaning |
|---|---|
| `%Y` | 4-digit year |
| `%m` | month (01-12) |
| `%d` | day of month (01-31) |
| `%H` | hour (00-23) |
| `%M` | minute (00-59) |
| `%S` | second (00-59) |
| `%W` | ISO week (00-53) |
| `%j` | day of year (001-366) |
| `%w` | day of week (0=Sunday) |
**Other filter functions worth knowing:**
| Function | Use |
|---|---|
| `strftime(fmt, datetime)` | Format/extract datetime parts (v0.36+) |
| `length(field)` | Count elements in a multi-value field (file, relation, select) |
| `each(field, expr)` | Iterate over multi-value fields: `each(tags, ? ~ "urgent")` |
| `issetIf(field, val)` | Conditional presence check used in complex rules |
Reference: [Filter Syntax - Functions](https://pocketbase.io/docs/api-rules-and-filters/#filters) · [v0.36.0 release](https://github.com/pocketbase/pocketbase/releases/tag/v0.36.0)