@zerotal/arch 1.7.0
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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Authorization (Roles & Permissions)
|
|
3
|
+
description: Authorize users against database-backed roles and permissions composed onto your models.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Authorization (Roles & Permissions)
|
|
7
|
+
|
|
8
|
+
Authorize users against database-backed roles and permissions. You compose two mixins onto a model, then check abilities synchronously with `can()` and `hasRole()`.
|
|
9
|
+
|
|
10
|
+
Zerotal's authorization is **relational and DB-backed**, composed from two
|
|
11
|
+
independent mixins you apply to a model:
|
|
12
|
+
|
|
13
|
+
- **`Roles`** — gives the model roles (a polymorphic `model_roles` pivot).
|
|
14
|
+
Roles carry their own permissions, so this alone supports `hasRole()` and
|
|
15
|
+
permission checks granted _through_ roles.
|
|
16
|
+
- **`Permissions`** — gives the model permissions granted **directly**
|
|
17
|
+
(a polymorphic `model_permissions` pivot).
|
|
18
|
+
|
|
19
|
+
Apply either on its own, or both together — in any order. When both are present,
|
|
20
|
+
`can()` resolves over the **union** of direct and role-derived permissions.
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// app/models/User.ts
|
|
24
|
+
import { AuthUser, Roles, Permissions } from "@zerotal/auth";
|
|
25
|
+
import { column, table } from "@zerotal/orm";
|
|
26
|
+
|
|
27
|
+
@(table("users").withTimestamps())
|
|
28
|
+
export class User extends Roles(Permissions(AuthUser)) {
|
|
29
|
+
@column() name!: string;
|
|
30
|
+
@column() email!: string;
|
|
31
|
+
// note: do NOT also declare a json `roles` column — the relation is named `roles`.
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Getting Started
|
|
36
|
+
|
|
37
|
+
Roles and permissions ship inside `@zerotal/auth` — if you have followed
|
|
38
|
+
[Authentication](/docs/authentication) there is nothing further to install or
|
|
39
|
+
register.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# in your project root
|
|
43
|
+
bun add @zerotal/auth
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`AuthProvider` registers the `Gate` and the RBAC relations, so adding it to
|
|
47
|
+
`bootstrap/providers.ts` is all the setup authorization needs. The pivot tables
|
|
48
|
+
come from the package's migrations — see [Schema](#schema).
|
|
49
|
+
|
|
50
|
+
## Composing the mixins
|
|
51
|
+
|
|
52
|
+
The mixins come from `@zerotal/auth`; `AuthUser` is the authenticatable base
|
|
53
|
+
(`Authenticatable(Model)`). Compose them in either the nested form or the
|
|
54
|
+
flat `Model.using` form — both are equivalent:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// app/models/User.ts — nested form
|
|
58
|
+
import { AuthUser, Roles, Permissions } from "@zerotal/auth";
|
|
59
|
+
|
|
60
|
+
class User extends Roles(Permissions(AuthUser)) {}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// app/models/User.ts — flat form (no wrapper nesting)
|
|
65
|
+
import { Model } from "@zerotal/orm";
|
|
66
|
+
import { Authenticatable, Roles, Permissions } from "@zerotal/auth";
|
|
67
|
+
|
|
68
|
+
class User extends Model.using(Authenticatable, Permissions, Roles) {}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Which mixins do I need?
|
|
72
|
+
|
|
73
|
+
- **Roles + direct permissions** (`Roles(Permissions(AuthUser))`) — the common
|
|
74
|
+
case for app users.
|
|
75
|
+
- **Roles only** (`Roles(AuthUser)`) — permissions still work, granted through
|
|
76
|
+
roles. Good for coarse models like a `Team`.
|
|
77
|
+
- **Direct permissions only** (`Permissions(AuthUser)`) — no roles, e.g. API
|
|
78
|
+
keys or service accounts.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// app/models/*.ts
|
|
82
|
+
class User extends Roles(Permissions(AuthUser)) {} // roles + direct permissions
|
|
83
|
+
class Team extends AuthUser.using(Roles) {} // roles only (permissions via roles)
|
|
84
|
+
class ApiKey extends AuthUser.using(Permissions) {} // direct permissions only
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Each mixin exposes a per-model static flag to toggle its eager loading:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// app/models/User.ts
|
|
91
|
+
class User extends Roles(Permissions(AuthUser)) {
|
|
92
|
+
static withRoles = true; // default — eager-load roles (+ their permissions)
|
|
93
|
+
static withPermissions = true; // default — eager-load direct permissions
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Schema
|
|
98
|
+
|
|
99
|
+
The `roles` and `permissions` tables are ORM models, so they're created by
|
|
100
|
+
schema sync / your generated migrations. The three **polymorphic pivots** aren't
|
|
101
|
+
models — `AuthProvider` provisions them on boot (idempotently) the moment a
|
|
102
|
+
registered model composes `Roles` or `Permissions`, so RBAC works with no extra
|
|
103
|
+
migration:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
roles id · name · guard · label · timestamps (ORM model)
|
|
107
|
+
permissions id · name · guard · label · timestamps (ORM model)
|
|
108
|
+
role_permissions role_id · permission_id (auto-provisioned pivot)
|
|
109
|
+
model_roles role_id · model_type · model_id (auto-provisioned pivot)
|
|
110
|
+
model_permissions permission_id · model_type · model_id (auto-provisioned pivot)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The pivots are **polymorphic** (`model_type` + `model_id`), so any model — not
|
|
114
|
+
just `User` — can hold roles and permissions.
|
|
115
|
+
|
|
116
|
+
> **Note** — Pivot provisioning runs in the `web` and `test` environments and
|
|
117
|
+
> swallows DDL errors, so a DB-less runtime still boots. Generate real migrations
|
|
118
|
+
> for production if you'd rather control the schema explicitly.
|
|
119
|
+
|
|
120
|
+
## Eager loading is on by default
|
|
121
|
+
|
|
122
|
+
Each mixin registers a global scope (`rolesEagerLoad`, `permissionsEagerLoad`) so
|
|
123
|
+
every query **auto-eager-loads** its relations — roles (and each role's
|
|
124
|
+
permissions) and direct permissions. That means `can()` / `hasRole()` resolve from
|
|
125
|
+
memory with **no extra queries**, even across a whole collection (no N+1):
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// in a controller
|
|
129
|
+
const users = await User.query().get(); // roles + permissions already loaded
|
|
130
|
+
for (const u of users) u.can("post.publish"); // synchronous, zero further queries
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Turn it off per model with the static flags (`static withRoles = false`), or per
|
|
134
|
+
query with `User.query().withoutGlobalScope("rolesEagerLoad")` (and/or
|
|
135
|
+
`"permissionsEagerLoad"`). When the relations aren't loaded, call
|
|
136
|
+
`await user.loadAuthorization()` once to warm the memo (or eager-load explicitly
|
|
137
|
+
with `User.query().with("roles.permissions", "permissions")`) — the synchronous
|
|
138
|
+
checks then read from memory.
|
|
139
|
+
|
|
140
|
+
## Assigning roles & permissions
|
|
141
|
+
|
|
142
|
+
Names are auto-created the first time you use them. Writes are `await`-ed and
|
|
143
|
+
refresh the in-memory memo automatically:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// in a controller / seeder
|
|
147
|
+
import { Role } from "@zerotal/auth";
|
|
148
|
+
|
|
149
|
+
// Roles (Roles mixin)
|
|
150
|
+
await user.assignRole("editor", "viewer"); // one or many
|
|
151
|
+
await user.removeRole("viewer");
|
|
152
|
+
await user.syncRoles(["admin"]); // replace the whole set
|
|
153
|
+
|
|
154
|
+
// Permissions on a role
|
|
155
|
+
const editor = await Role.resolve("editor");
|
|
156
|
+
await editor.givePermissionTo("post.create", "post.update");
|
|
157
|
+
await editor.revokePermissionTo("post.update");
|
|
158
|
+
await editor.syncPermissions(["post.create", "post.publish"]);
|
|
159
|
+
|
|
160
|
+
// Direct permissions on a user (bypassing roles) (Permissions mixin)
|
|
161
|
+
await user.givePermissionTo("billing.refund");
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Checking
|
|
165
|
+
|
|
166
|
+
Checks are **synchronous** — they read from the eager-loaded relations (on by
|
|
167
|
+
default) or the per-instance memo:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
// in a controller
|
|
171
|
+
user.hasRole("editor"); // boolean (Roles mixin)
|
|
172
|
+
user.hasAnyRole(["editor", "admin"]);
|
|
173
|
+
user.hasAllRoles(["editor", "author"]);
|
|
174
|
+
|
|
175
|
+
user.hasPermissionTo("post.create"); // direct OR via a role
|
|
176
|
+
user.can("post.update"); // alias of hasPermissionTo
|
|
177
|
+
user.getRoleNames(); // string[] (Roles mixin)
|
|
178
|
+
user.getAllPermissions(); // string[] — effective set (direct ∪ via-role)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
> **Warning** — Checks throw if the model wasn't eager-loaded and `loadAuthorization()` hasn't run. Fetch via a query (eager loading is on by default) or call `await user.loadAuthorization()` first. Writes auto-refresh the data, so a check immediately after `await user.assignRole(...)` works.
|
|
182
|
+
|
|
183
|
+
### From the Auth facade
|
|
184
|
+
|
|
185
|
+
The same checks are available on the [`Auth`](/docs/authentication) facade for the
|
|
186
|
+
**current** request's user (and return `false`/`[]` for guests, so you never need
|
|
187
|
+
a null check):
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
// in a controller
|
|
191
|
+
import { Auth } from "@zerotal/auth";
|
|
192
|
+
|
|
193
|
+
Auth.hasRole("editor");
|
|
194
|
+
Auth.hasAnyRole(["editor", "admin"]);
|
|
195
|
+
Auth.can("post.update");
|
|
196
|
+
Auth.hasPermission("post.publish"); // alias of can()
|
|
197
|
+
Auth.roles(); // string[]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
These delegate to whatever the user model implements — so `hasRole`/`roles`
|
|
201
|
+
require the `Roles` mixin, while `can`/`hasPermission` work with either mixin.
|
|
202
|
+
|
|
203
|
+
### Wildcards
|
|
204
|
+
|
|
205
|
+
A permission can be a wildcard. `*` grants everything; `post.*` grants every
|
|
206
|
+
`post.…` ability:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
// in a seeder
|
|
210
|
+
import { Role } from "@zerotal/auth";
|
|
211
|
+
|
|
212
|
+
const admin = await Role.resolve("admin");
|
|
213
|
+
await admin.givePermissionTo("post.*"); // post.create, post.delete, …
|
|
214
|
+
const root = await Role.resolve("super-admin");
|
|
215
|
+
await root.givePermissionTo("*"); // everything
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Guarding routes
|
|
219
|
+
|
|
220
|
+
Two route-guard middleware enforce abilities and roles before the controller
|
|
221
|
+
runs. Both throw `UnauthorizedError` (401) when the request is unauthenticated
|
|
222
|
+
and `ForbiddenError` (403) when the user is authenticated but lacks access:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// routes/web.ts
|
|
226
|
+
import { RequirePermissionMiddleware, RequireRoleMiddleware } from "@zerotal/auth";
|
|
227
|
+
|
|
228
|
+
Router.post("/posts/:id/publish", PostController, "publish", [
|
|
229
|
+
RequirePermissionMiddleware.for("post.publish"), // 401 if guest, 403 if denied
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
Router.get("/admin", AdminController, "index", [
|
|
233
|
+
RequireRoleMiddleware.for("admin"), // 403 unless the user hasRole('admin')
|
|
234
|
+
]);
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`RequirePermissionMiddleware` reads `user.can()` (so the model needs `Roles`
|
|
238
|
+
or `Permissions`); `RequireRoleMiddleware` reads `user.hasRole()` (needs
|
|
239
|
+
`Roles`). Either denies with `ForbiddenError` if the model doesn't expose the
|
|
240
|
+
method. Both `.for(...)` factories accept several names with **OR** semantics.
|
|
241
|
+
|
|
242
|
+
> **Tip** — Check **permissions** in your code (`can('post.update')`), not roles. Roles are just bundles of permissions, and what a role grants should be free to change without editing code. Reserve `hasRole` for coarse, role-shaped decisions (e.g. "is this an admin area?").
|
|
243
|
+
|
|
244
|
+
## Gate & policies
|
|
245
|
+
|
|
246
|
+
Permissions flow through the existing [`Gate`](/docs/authentication) automatically —
|
|
247
|
+
any ability name not handled by a closure or a model policy falls back to the
|
|
248
|
+
user's `can()` (wildcard aware), so you don't have to register anything:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// in a controller
|
|
252
|
+
import { Gate } from "@zerotal/auth";
|
|
253
|
+
|
|
254
|
+
Gate.allows("post.publish"); // true if the user has the permission
|
|
255
|
+
Gate.authorize("post.publish"); // throws ForbiddenError if not
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
In a model policy, defer to permissions:
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
// app/policies/PostPolicy.ts
|
|
262
|
+
import { Policy } from "@zerotal/auth";
|
|
263
|
+
|
|
264
|
+
class PostPolicy extends Policy<Post> {
|
|
265
|
+
update(user: User, post: Post) {
|
|
266
|
+
return user.id === post.authorId || user.can("post.update");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
Gate.registerPolicy(Post, PostPolicy);
|
|
270
|
+
Gate.allows("update", post);
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
> **Tip** — Policies are auto-discovered. Drop `PostPolicy extends Policy<Post>` in `app/policies/` and it's registered for `Post` automatically (the `Policy` suffix is matched to the model; override with `static model = Post`). No `Gate.registerPolicy(...)` call needed — that explicit form stays available for policies you'd rather wire by hand. See [Conventions](/docs/conventions).
|
|
274
|
+
|
|
275
|
+
Give a role unconditional access (super admin) with one line — it registers a
|
|
276
|
+
before-hook that short-circuits every Gate check, including policies:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
// app/providers/AppProvider.ts (boot)
|
|
280
|
+
Gate.superAdmin(); // users with the 'super-admin' role bypass all checks
|
|
281
|
+
Gate.superAdmin("owner"); // or name your own bypass role
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
And on the `Auth` facade, `authorize` throws for the current user:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// in a controller
|
|
288
|
+
Auth.authorize("post.publish"); // ForbiddenError if the current user can't
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Declaring permissions in code
|
|
292
|
+
|
|
293
|
+
Declare the permission names your app uses (reviewable in source, present on every
|
|
294
|
+
deploy), then sync them to the database:
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
// app/providers/AppProvider.ts (boot)
|
|
298
|
+
import { definePermission } from "@zerotal/auth";
|
|
299
|
+
|
|
300
|
+
definePermission("post.create", "post.update", "post.publish", "user.manage");
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
# in your project root
|
|
305
|
+
bun zt auth:sync-permissions # idempotent — creates any missing permissions
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`auth:sync-permissions` accepts a `--guard` flag (default `web`).
|
|
309
|
+
`Role.resolve` / `Permission.resolve` keep a process-level name→id cache, so bulk
|
|
310
|
+
assignment and seeding don't re-query for the same names.
|
|
311
|
+
|
|
312
|
+
## Seeding
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
// database/seeders/RbacSeeder.ts
|
|
316
|
+
import { Seeder } from "@zerotal/orm";
|
|
317
|
+
import { Role, Permission } from "@zerotal/auth";
|
|
318
|
+
|
|
319
|
+
export class RbacSeeder extends Seeder {
|
|
320
|
+
async run(): Promise<void> {
|
|
321
|
+
await Permission.resolve("post.create");
|
|
322
|
+
await Permission.resolve("post.publish");
|
|
323
|
+
|
|
324
|
+
const editor = await Role.resolve("editor");
|
|
325
|
+
await editor.givePermissionTo("post.create");
|
|
326
|
+
|
|
327
|
+
const admin = await Role.resolve("admin");
|
|
328
|
+
await admin.givePermissionTo("post.*");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Admin UI
|
|
334
|
+
|
|
335
|
+
When [`@zerotal/admin`](/docs/admin) is installed, **Roles** and **Permissions**
|
|
336
|
+
appear under an **Auth** group automatically, so you can manage them at runtime.
|
|
337
|
+
Many-to-many relations (a user's roles, a role's permissions) render as a
|
|
338
|
+
**multi-select** in the create/edit form — so you can assign roles to a user or
|
|
339
|
+
permissions to a role right in the admin. Relations you mark `readonly` (e.g.
|
|
340
|
+
roles gated to super admins) are read-only there too.
|
|
341
|
+
|
|
342
|
+
## Migrating from JSON roles
|
|
343
|
+
|
|
344
|
+
If you started with a simple `roles: string[]` column, move to relational by:
|
|
345
|
+
|
|
346
|
+
1. Provision the RBAC tables (`roles`/`permissions` via schema sync or a
|
|
347
|
+
migration; the pivots are auto-created on boot).
|
|
348
|
+
2. Switch the model to `extends Roles(Permissions(AuthUser))` and **remove** the
|
|
349
|
+
json `roles` column.
|
|
350
|
+
3. Backfill, reading the old values before you drop the column:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// database/seeders/BackfillRolesSeeder.ts
|
|
354
|
+
for (const u of await User.query().get()) {
|
|
355
|
+
const legacy = (u as any).roles ?? (u as any).role;
|
|
356
|
+
const names = Array.isArray(legacy) ? legacy : legacy ? [legacy] : [];
|
|
357
|
+
if (names.length) await u.assignRole(...names);
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## Troubleshooting
|
|
362
|
+
|
|
363
|
+
**"Authorization is not loaded on this instance."** A synchronous check ran on a
|
|
364
|
+
model that wasn't eager-loaded and whose memo wasn't warmed — e.g. a freshly
|
|
365
|
+
`new`-ed instance, or one fetched with `withRoles = false`. Fetch it via a query
|
|
366
|
+
(eager loading is on by default), or `await user.loadAuthorization()` first.
|
|
367
|
+
Writes refresh automatically, so a check right after `await user.assignRole(...)`
|
|
368
|
+
is fine.
|
|
369
|
+
|
|
370
|
+
**A permission check always returns `false`.** The permission row may not exist
|
|
371
|
+
yet. Names are auto-created by `assignRole`/`givePermissionTo`, but if you check
|
|
372
|
+
before granting, declare it with `definePermission(...)` and run
|
|
373
|
+
`auth:sync-permissions`. Also confirm the **guard** matches (default `web`).
|
|
374
|
+
|
|
375
|
+
**`can()` ignores a role's new permission.** Effective permissions are memoised
|
|
376
|
+
per instance. After changing a _role's_ permissions, re-fetch the user (or call
|
|
377
|
+
`await user.loadAuthorization()`) so the memo rebuilds — assigning/revoking on the
|
|
378
|
+
user itself refreshes automatically.
|
|
379
|
+
|
|
380
|
+
**`hasRole`/`assignRole` is undefined.** That model wasn't composed with
|
|
381
|
+
`Roles` (likewise `givePermissionTo` needs `Permissions`). Add the mixin:
|
|
382
|
+
`class User extends Roles(Permissions(AuthUser))`.
|
|
383
|
+
|
|
384
|
+
**Don't add a json `roles` column.** A `Roles` model already has a `roles`
|
|
385
|
+
relation; a same-named json column would collide.
|
|
386
|
+
|
|
387
|
+
## Testing
|
|
388
|
+
|
|
389
|
+
Set your suite up once as described in [Testing](/docs/testing) — everything
|
|
390
|
+
below assumes `createApp()` from your `tests/helpers.ts`.
|
|
391
|
+
|
|
392
|
+
**A policy is a plain class**, so the cheapest and most valuable test needs no
|
|
393
|
+
application at all:
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
// tests/policies/PostPolicy.test.ts
|
|
397
|
+
import { test, expect } from "bun:test";
|
|
398
|
+
import { PostPolicy } from "../../app/policies/PostPolicy.ts";
|
|
399
|
+
|
|
400
|
+
test("only the author may update a post", () => {
|
|
401
|
+
const policy = new PostPolicy();
|
|
402
|
+
const post = { id: 1, userId: 7 };
|
|
403
|
+
|
|
404
|
+
expect(policy.update({ id: 7 }, post)).toBe(true);
|
|
405
|
+
expect(policy.update({ id: 8 }, post)).toBe(false);
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**Roles and permissions are database rows**, so grant them in the test and check
|
|
410
|
+
them the way a controller would:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
// tests/authorization/roles.test.ts
|
|
414
|
+
import { test, expect } from "bun:test";
|
|
415
|
+
import { createApp } from "../helpers.ts";
|
|
416
|
+
import { User } from "../../app/models/User.ts";
|
|
417
|
+
|
|
418
|
+
test("an editor inherits the role's permissions", async () => {
|
|
419
|
+
const app = await createApp();
|
|
420
|
+
const user = await User.create({ email: "editor@example.com" });
|
|
421
|
+
await user.assignRole("editor"); // the editor role grants posts.publish
|
|
422
|
+
|
|
423
|
+
expect(await user.can("posts.publish")).toBe(true);
|
|
424
|
+
expect(await user.can("users.delete")).toBe(false);
|
|
425
|
+
|
|
426
|
+
await app.close();
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
**A guarded route is checked through `actingAs()`** — `assertForbidden()` is the
|
|
431
|
+
assertion that proves the gate is wired, not just defined:
|
|
432
|
+
|
|
433
|
+
```typescript
|
|
434
|
+
// tests/http/posts.test.ts
|
|
435
|
+
const res = await app.actingAs(stranger).delete("/posts/1");
|
|
436
|
+
|
|
437
|
+
res.assertForbidden();
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
> **Warning** — Assert with the async forms (`allowsAsync`, `authorizeAsync`,
|
|
441
|
+
> `can`) whenever an ability touches the database. The synchronous forms treat a
|
|
442
|
+
> returned Promise as truthy, so a test written against them **passes even when
|
|
443
|
+
> the ability should deny** — the one failure mode in authorization that a green
|
|
444
|
+
> suite will hide from you.
|
|
445
|
+
|
|
446
|
+
## References
|
|
447
|
+
|
|
448
|
+
Synchronous reads (`hasRole`, `can`, …) require the relations to be loaded; the
|
|
449
|
+
async writes refresh the memo for you.
|
|
450
|
+
|
|
451
|
+
### On a model — `Roles` mixin
|
|
452
|
+
|
|
453
|
+
| Method | Signature | Description |
|
|
454
|
+
| -------------- | --------------------------------------------------------- | ---------------------------------------------- |
|
|
455
|
+
| `assignRole` | `(...roles: (string \| number \| Role)[]): Promise<this>` | Assign one or more roles (created if missing). |
|
|
456
|
+
| `removeRole` | `(...roles: (string \| number \| Role)[]): Promise<this>` | Remove one or more roles. |
|
|
457
|
+
| `syncRoles` | `(roles: (string \| number \| Role)[]): Promise<this>` | Replace the whole role set. |
|
|
458
|
+
| `hasRole` | `(role: string): boolean` | True if the model holds the role. |
|
|
459
|
+
| `hasAnyRole` | `(roles: string[]): boolean` | True if it holds at least one of the roles. |
|
|
460
|
+
| `hasAllRoles` | `(roles: string[]): boolean` | True if it holds every one of the roles. |
|
|
461
|
+
| `getRoleNames` | `(): string[]` | The model's role names. |
|
|
462
|
+
| `loadRoles` | `(): Promise<this>` | Warm the role slice of the memo from the DB. |
|
|
463
|
+
|
|
464
|
+
### On a model — `Permissions` mixin
|
|
465
|
+
|
|
466
|
+
| Method | Signature | Description |
|
|
467
|
+
| -------------------- | --------------------------------------------------------------- | ---------------------------------------------- |
|
|
468
|
+
| `givePermissionTo` | `(...perms: (string \| number \| Permission)[]): Promise<this>` | Grant direct permissions (created if missing). |
|
|
469
|
+
| `revokePermissionTo` | `(...perms: (string \| number \| Permission)[]): Promise<this>` | Revoke direct permissions. |
|
|
470
|
+
| `syncPermissions` | `(perms: (string \| number \| Permission)[]): Promise<this>` | Replace the direct-permission set. |
|
|
471
|
+
| `loadPermissions` | `(): Promise<this>` | Warm the direct-permission memo from the DB. |
|
|
472
|
+
|
|
473
|
+
### On a model — effective checks (either mixin)
|
|
474
|
+
|
|
475
|
+
| Method | Signature | Description |
|
|
476
|
+
| ------------------- | ---------------------------- | -------------------------------------------------------- |
|
|
477
|
+
| `can` | `(ability: string): boolean` | Alias of `hasPermissionTo` — the canonical check. |
|
|
478
|
+
| `hasPermissionTo` | `(ability: string): boolean` | True if granted directly or via a role (wildcard-aware). |
|
|
479
|
+
| `getAllPermissions` | `(): string[]` | Effective permission set (direct ∪ via-role). |
|
|
480
|
+
| `loadAuthorization` | `(): Promise<this>` | Warm every slice the model composed in. |
|
|
481
|
+
|
|
482
|
+
### `Auth` facade (current user)
|
|
483
|
+
|
|
484
|
+
| Method | Signature | Description |
|
|
485
|
+
| -------------------- | ---------------------------- | ----------------------------------------------- |
|
|
486
|
+
| `Auth.hasRole` | `(role: string): boolean` | Current user has the role (`false` for guests). |
|
|
487
|
+
| `Auth.hasAnyRole` | `(roles: string[]): boolean` | Current user has any of the roles. |
|
|
488
|
+
| `Auth.hasAllRoles` | `(roles: string[]): boolean` | Current user has all of the roles. |
|
|
489
|
+
| `Auth.can` | `(ability: string): boolean` | Current user has the ability. |
|
|
490
|
+
| `Auth.hasPermission` | `(ability: string): boolean` | Alias of `Auth.can`. |
|
|
491
|
+
| `Auth.authorize` | `(ability: string): void` | Throws `ForbiddenError` unless allowed. |
|
|
492
|
+
| `Auth.roles` | `(): string[]` | Current user's role names (`[]` for guests). |
|
|
493
|
+
|
|
494
|
+
### `Role` (model)
|
|
495
|
+
|
|
496
|
+
| Method | Signature | Description |
|
|
497
|
+
| -------------------- | --------------------------------------------------------------- | ----------------------------------------------- |
|
|
498
|
+
| `Role.resolve` | `(name: string, guard?: string): Promise<Role>` | Find-or-create a role by name (+ guard). |
|
|
499
|
+
| `givePermissionTo` | `(...perms: (string \| number \| Permission)[]): Promise<this>` | Grant permissions to the role. |
|
|
500
|
+
| `revokePermissionTo` | `(...perms: (string \| number \| Permission)[]): Promise<this>` | Revoke permissions from the role. |
|
|
501
|
+
| `syncPermissions` | `(perms: (string \| number \| Permission)[]): Promise<this>` | Replace the role's permissions. |
|
|
502
|
+
| `permissionNames` | `(): Promise<string[]>` | Names of the permissions on the role. |
|
|
503
|
+
| `hasPermission` | `(name: string): Promise<boolean>` | Exact-match check for a permission on the role. |
|
|
504
|
+
|
|
505
|
+
### `Permission` (model)
|
|
506
|
+
|
|
507
|
+
| Method | Signature | Description |
|
|
508
|
+
| -------------------- | ----------------------------------------------------- | ------------------------------------ |
|
|
509
|
+
| `Permission.resolve` | `(name: string, guard?: string): Promise<Permission>` | Find-or-create a permission by name. |
|
|
510
|
+
| `definePermission` | `(...names: (string \| string[])[]): void` | Declare permission names in code. |
|
|
511
|
+
|
|
512
|
+
### `Gate` facade
|
|
513
|
+
|
|
514
|
+
| Method | Signature | Description |
|
|
515
|
+
| --------------------- | -------------------------------------------- | ----------------------------------------------------- |
|
|
516
|
+
| `Gate.allows` | `(ability: string, model?: object): boolean` | True if the current user may perform the ability. |
|
|
517
|
+
| `Gate.authorize` | `(ability: string, model?: object): void` | Throws `ForbiddenError` if denied. |
|
|
518
|
+
| `Gate.registerPolicy` | `(ModelClass, PolicyClass): void` | Register a policy for a model. |
|
|
519
|
+
| `Gate.superAdmin` | `(role?: string): this` | Bypass all checks for a role (default `super-admin`). |
|
|
520
|
+
| `Gate.defineAbility` | `(ability: string, cb): this` | Define a closure-based ability. |
|
|
521
|
+
| `Gate.before` | `(hook): this` | Add a before-hook that can short-circuit checks. |
|
|
522
|
+
| `Gate.via` | `(PolicyClass): { allows, authorize }` | Check against an explicit policy class. |
|
|
523
|
+
|
|
524
|
+
## Next steps
|
|
525
|
+
|
|
526
|
+
- [Authentication](/docs/authentication) — sign users in before authorizing them.
|
|
527
|
+
- [Roles and 2FA](/docs/roles-and-2fa) — deeper coverage of role workflows.
|
|
528
|
+
- [Middleware](/docs/middleware) — how the route-guard middleware fits the pipeline.
|
|
529
|
+
- [Conventions](/docs/conventions) — auto-discovery rules for policies.
|