|
| 1 | +--- |
| 2 | +title: Permissions |
| 3 | +description: Reactive type-safe permissions for Pyreon — universal, signal-driven, works with any authorization model |
| 4 | +--- |
| 5 | + |
| 6 | +# @pyreon/permissions |
| 7 | + |
| 8 | +Reactive permissions primitive for Pyreon. A permission is either a boolean or a function — check with `can()`, reactive in effects, computeds, and JSX. Works with any authorization model: RBAC, ABAC, feature flags, subscription tiers. |
| 9 | + |
| 10 | +## Installation |
| 11 | + |
| 12 | +```bash |
| 13 | +bun add @pyreon/permissions |
| 14 | +``` |
| 15 | + |
| 16 | +Peer dependencies: `@pyreon/reactivity`, `@pyreon/core` |
| 17 | + |
| 18 | +## Quick Start |
| 19 | + |
| 20 | +```tsx |
| 21 | +import { createPermissions } from '@pyreon/permissions' |
| 22 | + |
| 23 | +const can = createPermissions({ |
| 24 | + 'posts.read': true, |
| 25 | + 'posts.create': true, |
| 26 | + 'posts.update': (post: Post) => post.authorId === currentUserId(), |
| 27 | + 'users.manage': false, |
| 28 | +}) |
| 29 | + |
| 30 | +// Check — reactive in effects/computeds/JSX |
| 31 | +can('posts.read') // true |
| 32 | +can('posts.update', myPost) // evaluates predicate |
| 33 | +can('users.manage') // false |
| 34 | +``` |
| 35 | + |
| 36 | +## Core Concepts |
| 37 | + |
| 38 | +### Permission Values |
| 39 | + |
| 40 | +A permission value is either: |
| 41 | +- **`true` / `false`** — static grant or denial |
| 42 | +- **`(context?) => boolean`** — predicate, evaluated per-check with optional context |
| 43 | + |
| 44 | +```tsx |
| 45 | +const can = createPermissions({ |
| 46 | + // Static |
| 47 | + 'posts.read': true, |
| 48 | + 'billing.export': false, |
| 49 | + |
| 50 | + // Predicate — instance-level check |
| 51 | + 'posts.update': (post: Post) => post.authorId === userId(), |
| 52 | + |
| 53 | + // Predicate — derived from reactive state |
| 54 | + 'users.manage': () => currentUser()?.role === 'admin', |
| 55 | +}) |
| 56 | +``` |
| 57 | + |
| 58 | +### Checking Permissions |
| 59 | + |
| 60 | +`can()` returns a boolean. It's reactive when called inside effects, computeds, or JSX `{() => ...}` wrappers. |
| 61 | + |
| 62 | +```tsx |
| 63 | +// Static check |
| 64 | +can('posts.read') // true |
| 65 | + |
| 66 | +// Instance check — passes context to predicate |
| 67 | +can('posts.update', somePost) // evaluates (post) => post.authorId === userId() |
| 68 | + |
| 69 | +// In JSX — reactive, updates when permissions change |
| 70 | +{() => can('posts.read') && <PostList />} |
| 71 | +{() => can('posts.update', post) && <EditButton />} |
| 72 | + |
| 73 | +// In effects — reactive |
| 74 | +effect(() => { |
| 75 | + if (can('users.manage')) showAdminTools() |
| 76 | +}) |
| 77 | + |
| 78 | +// In computeds — reactive |
| 79 | +const isAdmin = computed(() => can('users.manage')) |
| 80 | +``` |
| 81 | + |
| 82 | +### Inverse and Multi-Checks |
| 83 | + |
| 84 | +```tsx |
| 85 | +// Inverse |
| 86 | +can.not('billing.export') // true if denied |
| 87 | + |
| 88 | +// All must be true |
| 89 | +can.all('posts.read', 'posts.create') // true if both granted |
| 90 | + |
| 91 | +// At least one must be true |
| 92 | +can.any('posts.update', 'posts.delete') // true if either granted |
| 93 | +``` |
| 94 | + |
| 95 | +## Updating Permissions |
| 96 | + |
| 97 | +Permissions are reactive — all `can()` reads update automatically when the source changes. |
| 98 | + |
| 99 | +### `can.set(map)` — Replace All |
| 100 | + |
| 101 | +```tsx |
| 102 | +// After login — set permissions from server response |
| 103 | +can.set({ |
| 104 | + 'posts.read': true, |
| 105 | + 'posts.create': true, |
| 106 | + 'users.manage': true, |
| 107 | +}) |
| 108 | + |
| 109 | +// Role change — replace everything |
| 110 | +can.set(fromRole('viewer')) |
| 111 | +``` |
| 112 | + |
| 113 | +### `can.patch(map)` — Merge |
| 114 | + |
| 115 | +```tsx |
| 116 | +// Subscription upgrade — add new permissions |
| 117 | +can.patch({ 'billing.export': true }) |
| 118 | + |
| 119 | +// Feature flag toggle |
| 120 | +can.patch({ 'feature.new-editor': false }) |
| 121 | +``` |
| 122 | + |
| 123 | +## Wildcard Matching |
| 124 | + |
| 125 | +Wildcards allow grouping permissions under a prefix. |
| 126 | + |
| 127 | +```tsx |
| 128 | +const can = createPermissions({ |
| 129 | + 'posts.*': true, // matches posts.read, posts.create, posts.delete, etc. |
| 130 | + 'posts.delete': false, // exact match overrides wildcard |
| 131 | +}) |
| 132 | + |
| 133 | +can('posts.read') // true — matched by 'posts.*' |
| 134 | +can('posts.create') // true — matched by 'posts.*' |
| 135 | +can('posts.delete') // false — exact match takes precedence |
| 136 | +``` |
| 137 | + |
| 138 | +### Resolution Order |
| 139 | + |
| 140 | +1. **Exact match** — `'posts.update'` → use it |
| 141 | +2. **Prefix wildcard** — `'posts.*'` → use it |
| 142 | +3. **Global wildcard** — `'*'` → use it (superadmin) |
| 143 | +4. **No match** → `false` (denied) |
| 144 | + |
| 145 | +```tsx |
| 146 | +// Superadmin — global wildcard grants everything |
| 147 | +const can = createPermissions({ '*': true }) |
| 148 | +can('literally.anything') // true |
| 149 | +``` |
| 150 | + |
| 151 | +## Introspection |
| 152 | + |
| 153 | +For help dialogs, admin dashboards, or debugging. |
| 154 | + |
| 155 | +```tsx |
| 156 | +// All granted permission keys — reactive Computed<string[]> |
| 157 | +can.granted() // ['posts.read', 'posts.create', 'users.manage'] |
| 158 | + |
| 159 | +// All entries as [key, value] pairs — reactive Computed |
| 160 | +can.entries() // [['posts.read', true], ['users.manage', false], ...] |
| 161 | +``` |
| 162 | + |
| 163 | +## Context Pattern (SSR / Testing) |
| 164 | + |
| 165 | +For SSR isolation or testing, use the provider to scope a permissions instance. |
| 166 | + |
| 167 | +```tsx |
| 168 | +import { PermissionsProvider, usePermissions } from '@pyreon/permissions' |
| 169 | + |
| 170 | +// Provide |
| 171 | +<PermissionsProvider instance={can}> |
| 172 | + <App /> |
| 173 | +</PermissionsProvider> |
| 174 | + |
| 175 | +// Consume |
| 176 | +function AdminPanel() { |
| 177 | + const can = usePermissions() |
| 178 | + return () => can('admin') && <AdminDashboard /> |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +## Real-World Patterns |
| 183 | + |
| 184 | +### Role-Based Access Control (RBAC) |
| 185 | + |
| 186 | +```tsx |
| 187 | +function fromRole(role: string): Record<string, boolean> { |
| 188 | + const roles: Record<string, Record<string, boolean>> = { |
| 189 | + admin: { '*': true }, |
| 190 | + editor: { |
| 191 | + 'posts.read': true, |
| 192 | + 'posts.create': true, |
| 193 | + 'posts.update': true, |
| 194 | + 'users.read': true, |
| 195 | + }, |
| 196 | + viewer: { 'posts.read': true, 'users.read': true }, |
| 197 | + } |
| 198 | + return roles[role] ?? {} |
| 199 | +} |
| 200 | + |
| 201 | +const can = createPermissions(fromRole('editor')) |
| 202 | + |
| 203 | +// After role change |
| 204 | +can.set(fromRole('admin')) |
| 205 | +``` |
| 206 | + |
| 207 | +### Server Response / JWT Claims |
| 208 | + |
| 209 | +```tsx |
| 210 | +// Server returns permission strings |
| 211 | +const response = await fetch('/api/me') |
| 212 | +const { permissions } = await response.json() |
| 213 | +// permissions: ['posts:read', 'posts:create', 'users:manage'] |
| 214 | + |
| 215 | +// Transform to permission map |
| 216 | +can.set( |
| 217 | + Object.fromEntries( |
| 218 | + permissions.map((p: string) => [p.replace(':', '.'), true]) |
| 219 | + ) |
| 220 | +) |
| 221 | +``` |
| 222 | + |
| 223 | +### Feature Flags |
| 224 | + |
| 225 | +```tsx |
| 226 | +const can = createPermissions({ |
| 227 | + // Access control |
| 228 | + 'posts.read': true, |
| 229 | + 'posts.create': true, |
| 230 | + |
| 231 | + // Feature flags |
| 232 | + 'feature.new-editor': true, |
| 233 | + 'feature.dark-mode': false, |
| 234 | + |
| 235 | + // Subscription tier |
| 236 | + 'tier.pro': true, |
| 237 | + 'tier.enterprise': false, |
| 238 | +}) |
| 239 | + |
| 240 | +{() => can('feature.new-editor') && <NewEditor />} |
| 241 | +{() => can('tier.pro') && <ExportButton />} |
| 242 | +``` |
| 243 | + |
| 244 | +### Instance-Level Ownership |
| 245 | + |
| 246 | +```tsx |
| 247 | +const can = createPermissions({ |
| 248 | + 'posts.read': true, |
| 249 | + 'posts.update': (post: Post) => post.authorId === currentUserId(), |
| 250 | + 'posts.delete': (post: Post) => |
| 251 | + post.authorId === currentUserId() && post.status === 'draft', |
| 252 | +}) |
| 253 | + |
| 254 | +function PostRow({ post }: { post: Post }) { |
| 255 | + return ( |
| 256 | + <tr> |
| 257 | + <td>{post.title}</td> |
| 258 | + <td> |
| 259 | + {() => can('posts.update', post) && <EditButton post={post} />} |
| 260 | + {() => can('posts.delete', post) && <DeleteButton post={post} />} |
| 261 | + </td> |
| 262 | + </tr> |
| 263 | + ) |
| 264 | +} |
| 265 | +``` |
| 266 | + |
| 267 | +### Multi-Tenant with Key Prefixes |
| 268 | + |
| 269 | +```tsx |
| 270 | +const can = createPermissions({ |
| 271 | + 'org:acme.admin': true, |
| 272 | + 'ws:design.posts.*': true, |
| 273 | + 'ws:engineering.posts.read': true, |
| 274 | +}) |
| 275 | + |
| 276 | +can('ws:design.posts.delete') // true — wildcard match |
| 277 | +can('ws:engineering.posts.delete') // false — only read granted |
| 278 | +``` |
| 279 | + |
| 280 | +### Reactive Role Switching |
| 281 | + |
| 282 | +```tsx |
| 283 | +const can = createPermissions(fromRole('viewer')) |
| 284 | + |
| 285 | +// Permissions automatically update in all components |
| 286 | +effect(() => { |
| 287 | + can.set(fromRole(currentRole())) |
| 288 | +}) |
| 289 | + |
| 290 | +// Every can() check in the app reacts to role changes |
| 291 | +``` |
| 292 | + |
| 293 | +### With useQuery — Conditional Fetching |
| 294 | + |
| 295 | +```tsx |
| 296 | +const { data } = useQuery(() => ({ |
| 297 | + queryKey: ['users'], |
| 298 | + queryFn: fetchUsers, |
| 299 | + enabled: can('users.read'), |
| 300 | +})) |
| 301 | +``` |
| 302 | + |
| 303 | +## Type Exports |
| 304 | + |
| 305 | +```tsx |
| 306 | +import type { |
| 307 | + Permissions, // The callable permissions instance |
| 308 | + PermissionMap, // Record<string, PermissionValue> |
| 309 | + PermissionValue, // boolean | (context?) => boolean |
| 310 | + PermissionPredicate, // (context?) => boolean |
| 311 | +} from '@pyreon/permissions' |
| 312 | +``` |
| 313 | + |
| 314 | +## API Reference |
| 315 | + |
| 316 | +| API | Description | |
| 317 | +|---|---| |
| 318 | +| `createPermissions(initial?)` | Create a reactive permissions instance | |
| 319 | +| `can(key, context?)` | Check permission — reactive in effects/computeds/JSX | |
| 320 | +| `can.not(key, context?)` | Inverse check | |
| 321 | +| `can.all(...keys)` | True if all permissions granted | |
| 322 | +| `can.any(...keys)` | True if any permission granted | |
| 323 | +| `can.set(map)` | Replace all permissions | |
| 324 | +| `can.patch(map)` | Merge into existing permissions | |
| 325 | +| `can.granted()` | `Computed<string[]>` — all granted keys | |
| 326 | +| `can.entries()` | `Computed<[string, PermissionValue][]>` — all entries | |
| 327 | +| `PermissionsProvider` | Context provider for SSR/testing | |
| 328 | +| `usePermissions()` | Access permissions from context | |
0 commit comments