buro26-strapi-graphql 0.1.0 → 0.3.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/README.md +173 -16
- package/dist/server/index.d.ts +1 -1
- package/dist/server/nexus/plugins/field-authorize.plugin.d.ts +21 -0
- package/dist/server/nexus/plugins/field-authorize.plugin.js +68 -35
- package/dist/server/nexus/plugins/middleware.plugin.d.ts +10 -2
- package/dist/server/nexus/plugins/middleware.plugin.js +20 -33
- package/dist/server/services/file-service.d.ts +7 -6
- package/dist/server/services/file-service.js +36 -36
- package/dist/server/services/index.d.ts +1 -1
- package/dist/server/types.d.ts +18 -3
- package/dist/server/utils/collection-type.d.ts +13 -2
- package/dist/server/utils/collection-type.js +13 -2
- package/dist/server/utils/field-builder/builder.d.ts +13 -4
- package/dist/server/utils/field-builder/builder.js +214 -119
- package/dist/server/utils/field-builder/helpers.d.ts +21 -6
- package/dist/server/utils/field-builder/helpers.js +26 -4
- package/dist/server/utils/field-builder/types.d.ts +297 -76
- package/dist/server/utils/graphql-extension.d.ts +24 -3
- package/dist/server/utils/graphql-extension.js +73 -7
- package/dist/server/utils/index.d.ts +2 -0
- package/dist/server/utils/index.js +11 -0
- package/dist/server/utils/loaders.d.ts +110 -0
- package/dist/server/utils/loaders.js +222 -0
- package/dist/server/utils/misc.d.ts +6 -0
- package/dist/server/utils/misc.js +6 -0
- package/dist/server/utils/override.d.ts +32 -0
- package/dist/server/utils/override.js +54 -26
- package/dist/server/utils/registry/field-protection.d.ts +43 -0
- package/dist/server/utils/registry/field-protection.js +65 -0
- package/dist/server/utils/registry/global-registry.d.ts +69 -14
- package/dist/server/utils/registry/global-registry.js +53 -7
- package/dist/server/utils/registry/index.d.ts +2 -0
- package/dist/server/utils/registry/index.js +2 -0
- package/dist/server/utils/registry/resolve-field.d.ts +1 -0
- package/dist/server/utils/registry/resolve-field.js +67 -0
- package/dist/server/utils/shadow-crud.d.ts +28 -1
- package/dist/server/utils/shadow-crud.js +32 -1
- package/dist/server/utils/single-type.d.ts +30 -1
- package/dist/server/utils/single-type.js +25 -0
- package/dist/server/utils/strapi-compat.d.ts +90 -0
- package/dist/server/utils/strapi-compat.js +203 -0
- package/dist/server/utils/types.d.ts +25 -1
- package/dist/tsconfig.server.tsbuildinfo +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -30,13 +30,145 @@ This fluent, type-safe builder gives you the best DX, composability, and testabi
|
|
|
30
30
|
import { createGraphQLFieldBuilder } from 'buro26-strapi-graphql';
|
|
31
31
|
import { nonNull, stringArg } from 'nexus';
|
|
32
32
|
|
|
33
|
-
export default createGraphQLFieldBuilder(
|
|
33
|
+
export default createGraphQLFieldBuilder()
|
|
34
|
+
.rootType('Query')
|
|
34
35
|
.fieldName('helloWorld')
|
|
35
36
|
.args({ name: nonNull(stringArg()) })
|
|
36
37
|
.outputType(nonNull('String'))
|
|
37
38
|
.description('Returns a personalized greeting')
|
|
38
|
-
.
|
|
39
|
-
.
|
|
39
|
+
.withAuth(false)
|
|
40
|
+
.resolver(async (parent, args) => `Hello World ${args.name}`);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> **Canonical style:** `createGraphQLFieldBuilder().rootType(...)`, `.withAuth(...)` for authentication and
|
|
44
|
+
> `.authorize(...)` for authorization. `createGraphQLFieldBuilder('Query')` and `.resolverConfig({ auth })`
|
|
45
|
+
> still work but are not used in new code.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 🔗 Relations
|
|
50
|
+
|
|
51
|
+
Use `.relation()` to define (or protect) a relation field. It produces exactly the field Strapi generates:
|
|
52
|
+
the same output type, filter/pagination arguments, resolver (with query validation and output sanitizing)
|
|
53
|
+
and, for relations, the `<target>.find` permission scope, on **Strapi v4 and v5**.
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
export default createGraphQLFieldBuilder()
|
|
57
|
+
.rootType('Order')
|
|
58
|
+
.relation('api::order.order', 'customer')
|
|
59
|
+
.authorize(isOrderCustomer);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| | Strapi v4 | Strapi v5 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| to-one | `XEntityResponse` | `X` |
|
|
65
|
+
| to-many | `XRelationResponseCollection` | `[X]!` **and** `<field>_connection` (both get the same auth/middleware) |
|
|
66
|
+
|
|
67
|
+
Media attributes (`type: 'media'`) work too: `.relation('api::customer.customer', 'avatar')` uses
|
|
68
|
+
`UploadFileEntityResponse` (v4) or `UploadFile` (v5); a `multiple: true` media field uses
|
|
69
|
+
`UploadFileRelationResponseCollection` (v4) or `[UploadFile]!` plus `<field>_connection` (v5). Strapi
|
|
70
|
+
attaches no permission scope to the media fields it generates, so `.relation()` adds none either.
|
|
71
|
+
|
|
72
|
+
For to-one **and** to-many relations and for single or multiple media you can pass `{ batch: true }` to load
|
|
73
|
+
the relation of all parents in the request with a single query (see [Data loading](#-data-loading)). A batched
|
|
74
|
+
to-many field returns the same relation response collection as Strapi's own resolver; on v5 both the list
|
|
75
|
+
field and `<field>_connection` use it.
|
|
76
|
+
|
|
77
|
+
Batched loads cannot apply per-parent query arguments. When a client passes `filters`, `pagination`, `sort` or
|
|
78
|
+
(v4 only) `publicationState`, that call falls back to Strapi's normal per-parent association resolver, so the
|
|
79
|
+
arguments keep working; only those calls lose the batching benefit.
|
|
80
|
+
|
|
81
|
+
Batched relations go through the [relation loader](#-data-loading), so draft & publish content is loaded as the
|
|
82
|
+
published version. Media are files, so draft & publish never applies to them.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 🛡️ Protecting Strapi's Generated Fields
|
|
87
|
+
|
|
88
|
+
`protectFields()` adds authorization and/or middleware to fields Strapi generates for a content type —
|
|
89
|
+
scalars, enums, media, components and relations — **without redefining them**. Strapi's types, arguments and
|
|
90
|
+
resolvers stay untouched, so it behaves the same on v4 and v5 (for v5 to-many relations the
|
|
91
|
+
`<field>_connection` field is protected too).
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// e.g. src/api/customer/graphql/_protect.ts
|
|
95
|
+
import { protectFields } from 'buro26-strapi-graphql';
|
|
96
|
+
|
|
97
|
+
protectFields<Customer>('api::customer.customer', ['street', 'postalCode', 'iban', 'orders'])
|
|
98
|
+
.authorize((customer, _args, ctx) => isSelf(customer, ctx.state.user))
|
|
99
|
+
.middleware(auditLog);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The checks are attached by the package's Nexus plugins when the schema is built, so the file only needs to be
|
|
103
|
+
loaded from a `graphql` folder; it does not have to export anything.
|
|
104
|
+
|
|
105
|
+
> **Non-null fields:** a denied non-null field makes GraphQL null its parent object (standard error propagation).
|
|
106
|
+
> Strapi generates required attributes and v5 to-many relations (`[X]!`) as non-null, so protecting those hides the
|
|
107
|
+
> whole parent for users without access. A warning is logged when the schema is built. Prefer this over `overrideField` when you
|
|
108
|
+
only want to restrict access.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## ⚡ Data Loading
|
|
113
|
+
|
|
114
|
+
`loaders(ctx)` gives every request its own batching cache (DataLoader-style, no extra dependency). Loads from the
|
|
115
|
+
same tick are combined into one query; results are cached until the request ends.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { loaders, sanitizeOutput } from 'buro26-strapi-graphql';
|
|
119
|
+
|
|
120
|
+
// One findMany ... where id IN (...) for all parents in the request
|
|
121
|
+
const order = await loaders(ctx).entity(ORDER, { populate: { customer: true } }).load(parent.id);
|
|
122
|
+
|
|
123
|
+
// The relation of many parents in one query. Raw rows: sanitize before returning them.
|
|
124
|
+
const customer = await loaders(ctx).relation(ORDER, 'customer').load(parent.id);
|
|
125
|
+
|
|
126
|
+
// Media work the same way: a file row (single) or an array (multiple)
|
|
127
|
+
const avatar = await loaders(ctx).relation(CUSTOMER, 'avatar').load(parent.id);
|
|
128
|
+
|
|
129
|
+
// Or let the loader sanitize every row for the content API
|
|
130
|
+
const safeOrder = await loaders(ctx).entity(ORDER, { sanitize: true }).load(parent.id);
|
|
131
|
+
|
|
132
|
+
// Your own batch function, e.g. for an external API
|
|
133
|
+
const profile = await loaders(ctx).custom('profile', (ids: readonly number[]) => fetchProfiles(ids)).load(parent.id);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Loaders use `strapi.db.query()` (identical in v4 and v5) and return **unsanitized** database rows by default,
|
|
137
|
+
including private fields. Pass `{ sanitize: true }` to run each row through `sanitizeOutput` first, or sanitize
|
|
138
|
+
the rows yourself. For draft & publish content types they load the published version unless `status: 'draft'`
|
|
139
|
+
is passed; files have no draft & publish status.
|
|
140
|
+
|
|
141
|
+
Media attributes are stored as a morph relation to `plugin::upload.file`. `relation()` populates them in the
|
|
142
|
+
same query and returns the file row for a single media attribute, or an array (empty when there is none) for
|
|
143
|
+
`multiple: true`. With `{ sanitize: true }` files are sanitized against `plugin::upload.file`.
|
|
144
|
+
|
|
145
|
+
Loaders are cached per request and per options value. Options are compared by value: property order does not
|
|
146
|
+
matter and `select` is treated as a set, so `{ select: ['id', 'title'] }` and `{ select: ['title', 'id'] }` share
|
|
147
|
+
one loader. Loaders with a different `populate` never share a batch, because they load different rows.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 🏷️ Typing Your Project
|
|
152
|
+
|
|
153
|
+
Register your user type and entity types once, so `context.state.user` and `parent` are typed everywhere:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
declare module 'buro26-strapi-graphql' {
|
|
157
|
+
interface Register {
|
|
158
|
+
user: User;
|
|
159
|
+
types: { Customer: Customer; Order: Order };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
createGraphQLFieldBuilder()
|
|
164
|
+
.rootType('Customer') // parent is now typed as Customer
|
|
165
|
+
.fieldName('fullName')
|
|
166
|
+
.outputType('String')
|
|
167
|
+
.resolver((customer) => `${customer?.firstName} ${customer?.lastName}`);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
For registered entity types, `parent` is `Partial<Entity>` with `id` required: Strapi always passes the entity,
|
|
171
|
+
but relations are only present when populated. Unregistered `Query`/`Mutation` fields keep `parent: any`.
|
|
40
172
|
```
|
|
41
173
|
|
|
42
174
|
---
|
|
@@ -141,10 +273,24 @@ t.field('myField', {
|
|
|
141
273
|
You can register **global middleware** and **global authorization** that apply to multiple fields based on type, field
|
|
142
274
|
name, or tags.
|
|
143
275
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
276
|
+
Global middleware and authorization are matched when the GraphQL schema is built, so they apply regardless of
|
|
277
|
+
the order in which files are loaded. The matcher receives `{ type, fieldName, tags, contentType, isBuilderField }`.
|
|
278
|
+
|
|
279
|
+
By default globals only apply to fields created with the field builder. Pass `{ includeNonBuilderFields: true }`
|
|
280
|
+
to also apply them to Strapi's generated fields and plain Nexus fields.
|
|
281
|
+
|
|
282
|
+
> **Cost: globals run once per resolved field, not once per request.** A query that resolves 50 fields runs every
|
|
283
|
+
> matching global check and middleware 50 times. Keep them cheap and free of per-request side effects. Rate
|
|
284
|
+
> limiting, request logging and similar per-request concerns belong in a Koa/Strapi middleware:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// src/middlewares/rate-limit.ts — runs once per HTTP request, before GraphQL resolves anything
|
|
288
|
+
export default () => async (ctx, next) => {
|
|
289
|
+
const key = ctx.state.user?.id ?? ctx.ip;
|
|
290
|
+
if (!(await hits.allow(key))) return ctx.throw(429, 'Too many requests');
|
|
291
|
+
return next();
|
|
292
|
+
};
|
|
293
|
+
```
|
|
148
294
|
|
|
149
295
|
**Example:**
|
|
150
296
|
|
|
@@ -166,6 +312,12 @@ registerGlobalAuthorize(
|
|
|
166
312
|
async (parent, args, ctx) => ctx.state.user?.isAdmin
|
|
167
313
|
);
|
|
168
314
|
|
|
315
|
+
// Or for every field of one content type, e.g. requiring a subscription
|
|
316
|
+
registerGlobalAuthorize(
|
|
317
|
+
({ contentType }) => contentType === 'api::customer.customer',
|
|
318
|
+
(_parent, _args, ctx) => Boolean(ctx.state.user?.subscription)
|
|
319
|
+
);
|
|
320
|
+
|
|
169
321
|
// Now define fields that will use these global middlewares/authorizations
|
|
170
322
|
export default createGraphQLFieldBuilder('Query')
|
|
171
323
|
.fieldName('deleteUser')
|
|
@@ -174,11 +326,9 @@ export default createGraphQLFieldBuilder('Query')
|
|
|
174
326
|
.resolver(() => true);
|
|
175
327
|
```
|
|
176
328
|
|
|
177
|
-
**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
exports).
|
|
181
|
-
- This guarantees that all fields will be able to consume the global logic.
|
|
329
|
+
**Order of execution per field:** authentication (`withAuth(true)`) → global authorizes → the field's own
|
|
330
|
+
`authorize` → `protectFields` authorizes → global middlewares → the field's own middlewares → `protectFields`
|
|
331
|
+
middlewares → resolver (argument validation, hooks, tracing).
|
|
182
332
|
|
|
183
333
|
---
|
|
184
334
|
|
|
@@ -187,14 +337,14 @@ export default createGraphQLFieldBuilder('Query')
|
|
|
187
337
|
- Use the **builder’s `.authorize()` and `.middleware()`** methods for most use-cases—they automatically set the correct
|
|
188
338
|
`extensions` properties.
|
|
189
339
|
- Use the `extensions` property directly for advanced or manual Nexus usage.
|
|
190
|
-
-
|
|
340
|
+
- Use **`protectFields()`** to restrict Strapi's generated fields and **`.relation()`** for relation fields.
|
|
191
341
|
- Use **tags** to target groups of fields for global logic.
|
|
192
342
|
|
|
193
343
|
---
|
|
194
344
|
|
|
195
345
|
### **Validation, Tracing, and Testing**
|
|
196
346
|
|
|
197
|
-
**Argument validation with Zod
|
|
347
|
+
**Argument validation with Zod** (also types the resolver's `args` as the parsed schema output):
|
|
198
348
|
|
|
199
349
|
```typescript
|
|
200
350
|
import { z } from 'zod';
|
|
@@ -222,7 +372,8 @@ export default createGraphQLFieldBuilder('Query')
|
|
|
222
372
|
});
|
|
223
373
|
```
|
|
224
374
|
|
|
225
|
-
**Testing in isolation
|
|
375
|
+
**Testing in isolation** (runs the same pipeline as in production: global and field authorization, `withAuth`,
|
|
376
|
+
middlewares, argument validation and hooks; pass `skipAuth` / `skipMiddleware` to leave parts out):
|
|
226
377
|
|
|
227
378
|
```typescript
|
|
228
379
|
const field = createGraphQLFieldBuilder('Query')
|
|
@@ -316,7 +467,11 @@ export default createGraphQLExtension()
|
|
|
316
467
|
|
|
317
468
|
## 🧩 Overriding and Extending Fields
|
|
318
469
|
|
|
319
|
-
|
|
470
|
+
> To only restrict access to generated fields, use [`protectFields()`](#️-protecting-strapis-generated-fields);
|
|
471
|
+
> for relations, use [`.relation()`](#-relations). Use `overrideField` when you need to replace a field's resolver.
|
|
472
|
+
|
|
473
|
+
You can override or extend fields in existing types using the `overrideField` helper. Pass authorization via
|
|
474
|
+
`authorize` (or `extensions.authorize`); relation fields are generated for both Strapi v4 and v5:
|
|
320
475
|
|
|
321
476
|
```typescript
|
|
322
477
|
import { createGraphQLExtension, overrideField } from 'buro26-strapi-graphql';
|
|
@@ -461,6 +616,8 @@ To run tests:
|
|
|
461
616
|
bun test
|
|
462
617
|
```
|
|
463
618
|
|
|
619
|
+
Tests live in `test/` and run against real Nexus/GraphQL schemas with a minimal `strapi` mock.
|
|
620
|
+
|
|
464
621
|
---
|
|
465
622
|
|
|
466
623
|
## 🤝 Contributing
|
package/dist/server/index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ declare const _default: {
|
|
|
27
27
|
fileService: ({ strapi }: {
|
|
28
28
|
strapi: Required<import("@strapi/types").Strapi>;
|
|
29
29
|
}) => {
|
|
30
|
-
getAllFilesInGraphQlDirectory(directoryPath: string
|
|
30
|
+
getAllFilesInGraphQlDirectory(directoryPath: string): Promise<GraphQLExtension[]>;
|
|
31
31
|
};
|
|
32
32
|
graphQlService: ({ strapi }: {
|
|
33
33
|
strapi: Required<import("@strapi/types").Strapi>;
|
|
@@ -8,8 +8,29 @@ export interface FieldAuthorizePluginErrorConfig {
|
|
|
8
8
|
ctx: GetGen<'context'>;
|
|
9
9
|
info: GraphQLResolveInfo;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Options for {@link nexusFieldAuthorizePlugin}.
|
|
13
|
+
*/
|
|
11
14
|
export interface FieldAuthorizePluginConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Turn a denied authorization into the error sent to the client.
|
|
17
|
+
* @default {@link defaultFormatError}
|
|
18
|
+
*/
|
|
12
19
|
formatError?: (authConfig: FieldAuthorizePluginErrorConfig) => Error;
|
|
13
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Default error for denied authorization: a Strapi `ForbiddenError` with the original message and
|
|
23
|
+
* trace details (type, field, user id, args, timestamp) in `details.trace`.
|
|
24
|
+
*/
|
|
14
25
|
export declare const defaultFormatError: ({ error, root, args, ctx, info }: FieldAuthorizePluginErrorConfig) => Error;
|
|
26
|
+
/**
|
|
27
|
+
* Field-level authentication and authorization.
|
|
28
|
+
*
|
|
29
|
+
* Applies to every field in the schema (including the ones Strapi generates) and combines:
|
|
30
|
+
* `withAuth(true)` of builder fields, global authorizes, the field's own `authorize` (in `extensions` or
|
|
31
|
+
* as top-level field option) and `protectFields()` registrations.
|
|
32
|
+
*
|
|
33
|
+
* Registered automatically by this Strapi plugin; only add it yourself when building a schema outside
|
|
34
|
+
* Strapi (e.g. in tests).
|
|
35
|
+
*/
|
|
15
36
|
export declare const nexusFieldAuthorizePlugin: (authConfig?: FieldAuthorizePluginConfig) => import("nexus/dist/plugin").NexusPlugin;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.nexusFieldAuthorizePlugin = exports.defaultFormatError = void 0;
|
|
3
|
+
exports.nexusFieldAuthorizePlugin = exports.runAuthorization = exports.defaultFormatError = void 0;
|
|
4
4
|
const nexus_1 = require("nexus");
|
|
5
5
|
const utils_1 = require("nexus/dist/utils");
|
|
6
6
|
const utils_2 = require("@strapi/utils");
|
|
7
|
+
const graphql_1 = require("graphql");
|
|
8
|
+
const resolve_field_1 = require("../../utils/registry/resolve-field");
|
|
7
9
|
const fieldDefTypes = (0, utils_1.printedGenTyping)({
|
|
8
10
|
optional: true,
|
|
9
11
|
name: 'authorize',
|
|
@@ -17,6 +19,10 @@ const fieldDefTypes = (0, utils_1.printedGenTyping)({
|
|
|
17
19
|
`,
|
|
18
20
|
type: 'FieldAuthorizeResolver<TypeName, FieldName>',
|
|
19
21
|
});
|
|
22
|
+
/**
|
|
23
|
+
* Default error for denied authorization: a Strapi `ForbiddenError` with the original message and
|
|
24
|
+
* trace details (type, field, user id, args, timestamp) in `details.trace`.
|
|
25
|
+
*/
|
|
20
26
|
const defaultFormatError = ({ error, root, args, ctx, info }) => {
|
|
21
27
|
var _a, _b;
|
|
22
28
|
// Add rich tracing info
|
|
@@ -32,9 +38,15 @@ const defaultFormatError = ({ error, root, args, ctx, info }) => {
|
|
|
32
38
|
return new utils_2.errors.ForbiddenError((error === null || error === void 0 ? void 0 : error.message) || 'Not authorized', { trace });
|
|
33
39
|
};
|
|
34
40
|
exports.defaultFormatError = defaultFormatError;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Run the authentication + authorization part of a field pipeline.
|
|
43
|
+
* Resolves when allowed, rejects with the (formatted) error otherwise.
|
|
44
|
+
*
|
|
45
|
+
* @internal Used by the authorize plugin and `.test()`.
|
|
46
|
+
*/
|
|
47
|
+
const runAuthorization = async (pipeline, root, args, ctx, info, formatError = exports.defaultFormatError) => {
|
|
48
|
+
var _a, _b;
|
|
49
|
+
const fail = (error) => {
|
|
38
50
|
const finalErr = formatError({ error, root, args, ctx, info });
|
|
39
51
|
if (finalErr instanceof Error) {
|
|
40
52
|
throw finalErr;
|
|
@@ -42,46 +54,67 @@ const nexusFieldAuthorizePlugin = (authConfig = {}) => {
|
|
|
42
54
|
console.error(`Non-Error value ${finalErr} returned from custom formatError in authorize plugin`);
|
|
43
55
|
throw new utils_2.errors.ForbiddenError('Not authorized');
|
|
44
56
|
};
|
|
57
|
+
if (pipeline.requireAuth && !((_a = ctx === null || ctx === void 0 ? void 0 : ctx.state) === null || _a === void 0 ? void 0 : _a.user)) {
|
|
58
|
+
// Same error as before, but now evaluated before any authorize function runs
|
|
59
|
+
throw new utils_2.errors.ForbiddenError('Not authenticated');
|
|
60
|
+
}
|
|
61
|
+
for (const authorize of pipeline.authorizes) {
|
|
62
|
+
let authResult;
|
|
63
|
+
try {
|
|
64
|
+
authResult = await authorize(root, args, ctx, info);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
fail(e instanceof Error ? e : new utils_2.errors.ForbiddenError(String(e)));
|
|
68
|
+
}
|
|
69
|
+
if (authResult === true)
|
|
70
|
+
continue;
|
|
71
|
+
if (authResult instanceof Error)
|
|
72
|
+
fail(authResult);
|
|
73
|
+
if (authResult === false)
|
|
74
|
+
fail(new utils_2.errors.ForbiddenError('Not authorized'));
|
|
75
|
+
fail(new utils_2.errors.ForbiddenError(`Authorize for ${(_b = info === null || info === void 0 ? void 0 : info.parentType) === null || _b === void 0 ? void 0 : _b.name}.${info === null || info === void 0 ? void 0 : info.fieldName} Expected a boolean or Error, saw ${authResult}`));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
exports.runAuthorization = runAuthorization;
|
|
79
|
+
/**
|
|
80
|
+
* Field-level authentication and authorization.
|
|
81
|
+
*
|
|
82
|
+
* Applies to every field in the schema (including the ones Strapi generates) and combines:
|
|
83
|
+
* `withAuth(true)` of builder fields, global authorizes, the field's own `authorize` (in `extensions` or
|
|
84
|
+
* as top-level field option) and `protectFields()` registrations.
|
|
85
|
+
*
|
|
86
|
+
* Registered automatically by this Strapi plugin; only add it yourself when building a schema outside
|
|
87
|
+
* Strapi (e.g. in tests).
|
|
88
|
+
*/
|
|
89
|
+
const nexusFieldAuthorizePlugin = (authConfig = {}) => {
|
|
90
|
+
const { formatError = exports.defaultFormatError } = authConfig;
|
|
45
91
|
return (0, nexus_1.plugin)({
|
|
46
92
|
name: 'CustomFieldAuthorize',
|
|
47
93
|
description: 'Custom field-level authorization for Strapi GraphQL, with tracing.',
|
|
48
94
|
fieldDefTypes: fieldDefTypes,
|
|
49
95
|
onCreateFieldResolver(config) {
|
|
50
|
-
var _a;
|
|
96
|
+
var _a, _b, _c;
|
|
51
97
|
// @ts-ignore
|
|
52
|
-
const
|
|
53
|
-
if (authorize
|
|
54
|
-
|
|
98
|
+
const extensions = config.fieldConfig.extensions;
|
|
99
|
+
if ((extensions === null || extensions === void 0 ? void 0 : extensions.authorize) != null && typeof extensions.authorize !== 'function') {
|
|
100
|
+
console.error(new Error(`The authorize property provided to ${config.fieldConfig.name} with type ${config.fieldConfig.type} should be a function, saw ${typeof extensions.authorize}`));
|
|
101
|
+
// Fail closed: a broken authorize must not silently disable the other checks on this field
|
|
102
|
+
return async function () {
|
|
103
|
+
throw new utils_2.errors.ForbiddenError('Not authorized');
|
|
104
|
+
};
|
|
55
105
|
}
|
|
56
|
-
|
|
57
|
-
|
|
106
|
+
const pipeline = (0, resolve_field_1.resolveFieldPipeline)(config.parentTypeConfig.name, config.fieldConfig.name, extensions);
|
|
107
|
+
if (!pipeline.requireAuth && pipeline.authorizes.length === 0) {
|
|
58
108
|
return;
|
|
59
109
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
return nexus_1.plugin.completeValue(toComplete, (authResult) => {
|
|
70
|
-
if (authResult === true) {
|
|
71
|
-
return next(root, args, ctx, info);
|
|
72
|
-
}
|
|
73
|
-
const finalFormatError = ensureError(root, args, ctx, info);
|
|
74
|
-
if (authResult instanceof Error) {
|
|
75
|
-
finalFormatError(authResult);
|
|
76
|
-
}
|
|
77
|
-
if (authResult === false) {
|
|
78
|
-
finalFormatError(new utils_2.errors.ForbiddenError('Not authorized'));
|
|
79
|
-
}
|
|
80
|
-
const { fieldName, parentType: { name: parentTypeName } } = info;
|
|
81
|
-
finalFormatError(new utils_2.errors.ForbiddenError(`Authorize for ${parentTypeName}.${fieldName} Expected a boolean or Error, saw ${authResult}`));
|
|
82
|
-
}, (err) => {
|
|
83
|
-
ensureError(root, args, ctx, info)(err);
|
|
84
|
-
});
|
|
110
|
+
if (pipeline.isProtected && (0, graphql_1.isNonNullType)(config.fieldConfig.type)) {
|
|
111
|
+
const warn = (_c = (_b = (_a = globalThis.strapi) === null || _a === void 0 ? void 0 : _a.log) === null || _b === void 0 ? void 0 : _b.warn) !== null && _c !== void 0 ? _c : console.warn;
|
|
112
|
+
warn(`[buro26-strapi-graphql] protectFields: "${config.parentTypeConfig.name}.${config.fieldConfig.name}" is non-null; ` +
|
|
113
|
+
`when access is denied GraphQL nulls the parent object instead of only this field.`);
|
|
114
|
+
}
|
|
115
|
+
return async function (root, args, ctx, info, next) {
|
|
116
|
+
await (0, exports.runAuthorization)(pipeline, root, args, ctx, info, formatError);
|
|
117
|
+
return next(root, args, ctx, info);
|
|
85
118
|
};
|
|
86
119
|
}
|
|
87
120
|
});
|
|
@@ -9,9 +9,17 @@ export type Resolver = <Parent extends any, Args extends any, Context = ResolveC
|
|
|
9
9
|
* Middleware is a function that takes a resolver and returns a new resolver.
|
|
10
10
|
*/
|
|
11
11
|
export type Middleware<TParent = any, TArgs = any> = (resolver: ResolverFunction<TParent, TArgs>) => ResolverFunction<TParent, TArgs>;
|
|
12
|
+
/**
|
|
13
|
+
* Field-level middlewares: global middlewares, the field's own `middlewares` (in `extensions` or as
|
|
14
|
+
* top-level field option) and `protectFields()` middlewares, outermost first.
|
|
15
|
+
*
|
|
16
|
+
* Registered automatically by this Strapi plugin; only add it yourself when building a schema outside
|
|
17
|
+
* Strapi (e.g. in tests).
|
|
18
|
+
*/
|
|
12
19
|
export declare const nexusMiddlewarePlugin: () => import("nexus/dist/plugin").NexusPlugin;
|
|
13
20
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* Example middleware that logs every resolve, including the result.
|
|
22
|
+
*
|
|
23
|
+
* Development only: it logs resolved values, which may contain personal data.
|
|
16
24
|
*/
|
|
17
25
|
export declare const loggingMiddleware: Middleware;
|
|
@@ -3,19 +3,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.loggingMiddleware = exports.nexusMiddlewarePlugin = void 0;
|
|
4
4
|
const nexus_1 = require("nexus");
|
|
5
5
|
const utils_1 = require("nexus/dist/utils");
|
|
6
|
+
const resolve_field_1 = require("../../utils/registry/resolve-field");
|
|
6
7
|
const fieldDefTypes = (0, utils_1.printedGenTyping)({
|
|
7
8
|
optional: true,
|
|
8
9
|
name: 'middlewares',
|
|
9
10
|
description: `
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Returning "false" or "Promise<false>" will respond
|
|
13
|
-
with a "Not Authorized" error for the field.
|
|
14
|
-
Returning or throwing an error will also prevent the
|
|
15
|
-
resolver from executing.
|
|
11
|
+
Middlewares for an individual field. Each middleware receives the
|
|
12
|
+
next resolver and returns a new resolver.
|
|
16
13
|
`,
|
|
17
14
|
type: 'any'
|
|
18
15
|
});
|
|
16
|
+
/**
|
|
17
|
+
* Field-level middlewares: global middlewares, the field's own `middlewares` (in `extensions` or as
|
|
18
|
+
* top-level field option) and `protectFields()` middlewares, outermost first.
|
|
19
|
+
*
|
|
20
|
+
* Registered automatically by this Strapi plugin; only add it yourself when building a schema outside
|
|
21
|
+
* Strapi (e.g. in tests).
|
|
22
|
+
*/
|
|
19
23
|
const nexusMiddlewarePlugin = () => (0, nexus_1.plugin)({
|
|
20
24
|
name: 'NexusMiddlewarePlugin',
|
|
21
25
|
description: 'The middleware plugin allows attaching middleware functions to individual fields.',
|
|
@@ -27,43 +31,26 @@ const nexusMiddlewarePlugin = () => (0, nexus_1.plugin)({
|
|
|
27
31
|
* Hook into field resolver creation to apply middleware.
|
|
28
32
|
*/
|
|
29
33
|
onCreateFieldResolver(config) {
|
|
30
|
-
var _a;
|
|
31
34
|
const { fieldConfig } = config;
|
|
35
|
+
const { middlewares } = (0, resolve_field_1.resolveFieldPipeline)(config.parentTypeConfig.name, fieldConfig.name,
|
|
32
36
|
// @ts-ignore
|
|
33
|
-
|
|
37
|
+
fieldConfig.extensions);
|
|
34
38
|
if (middlewares.length === 0) {
|
|
35
39
|
return undefined; // No middleware to apply
|
|
36
40
|
}
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
};
|
|
43
|
-
});
|
|
44
|
-
// Compose the middleware functions into a single MiddlewareFn
|
|
45
|
-
return async (source, args, context, info, next) => {
|
|
46
|
-
let index = -1;
|
|
47
|
-
const dispatch = async (i) => {
|
|
48
|
-
if (i <= index)
|
|
49
|
-
throw new Error('next() called multiple times');
|
|
50
|
-
index = i;
|
|
51
|
-
const middlewareFn = middlewareFns[i];
|
|
52
|
-
if (middlewareFn) {
|
|
53
|
-
return middlewareFn(source, args, context, info, () => dispatch(i + 1));
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
return next(source, args, context, info);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
return dispatch(0);
|
|
41
|
+
// Outermost first. Each middleware may call the next resolver with changed parent/args/context,
|
|
42
|
+
// which are passed on (same composition as `.test()`).
|
|
43
|
+
return (source, args, context, info, next) => {
|
|
44
|
+
const resolver = middlewares.reduceRight((inner, middleware) => middleware(inner), (s, a, c, i) => next(s, a, c, i));
|
|
45
|
+
return resolver(source, args, context, info);
|
|
60
46
|
};
|
|
61
47
|
}
|
|
62
48
|
});
|
|
63
49
|
exports.nexusMiddlewarePlugin = nexusMiddlewarePlugin;
|
|
64
50
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
51
|
+
* Example middleware that logs every resolve, including the result.
|
|
52
|
+
*
|
|
53
|
+
* Development only: it logs resolved values, which may contain personal data.
|
|
67
54
|
*/
|
|
68
55
|
const loggingMiddleware = (resolver) => {
|
|
69
56
|
return async (parent, args, context, info) => {
|
|
@@ -3,13 +3,14 @@ declare const _default: ({ strapi }: {
|
|
|
3
3
|
strapi: Strapi;
|
|
4
4
|
}) => {
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* Recursively searches for files in the graphql directory
|
|
8
|
-
* Returns an array of GraphQLExtension objects
|
|
6
|
+
* Load all extensions from `graphql` folders below `directoryPath` (recursively).
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Files are loaded in a stable order (sorted by name, depth-first), so the generated schema and the
|
|
9
|
+
* order of side effects such as `protectFields()` / `registerGlobalAuthorize()` calls are the same on
|
|
10
|
+
* every start.
|
|
11
|
+
*
|
|
12
|
+
* @param directoryPath - Usually `strapi.dirs.dist.src`.
|
|
12
13
|
*/
|
|
13
|
-
getAllFilesInGraphQlDirectory(directoryPath: string
|
|
14
|
+
getAllFilesInGraphQlDirectory(directoryPath: string): Promise<GraphQLExtension[]>;
|
|
14
15
|
};
|
|
15
16
|
export default _default;
|
|
@@ -25,50 +25,50 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
26
|
const fs_1 = require("fs");
|
|
27
27
|
const path = __importStar(require("path"));
|
|
28
|
+
const GRAPHQL_FOLDER = 'graphql';
|
|
29
|
+
const isInGraphQLFolder = (directoryPath) => directoryPath.endsWith(path.sep + GRAPHQL_FOLDER) || directoryPath.includes(path.sep + GRAPHQL_FOLDER + path.sep);
|
|
30
|
+
const isLoadableFile = (file) => file.endsWith('.js');
|
|
31
|
+
/**
|
|
32
|
+
* Turn a module's export into extensions. Builders (anything with `build()`) are built; arrays are flattened.
|
|
33
|
+
*/
|
|
34
|
+
const toExtensions = (graphqlModule) => {
|
|
35
|
+
var _a;
|
|
36
|
+
const exported = (_a = graphqlModule === null || graphqlModule === void 0 ? void 0 : graphqlModule.default) !== null && _a !== void 0 ? _a : graphqlModule;
|
|
37
|
+
if (!exported)
|
|
38
|
+
return [];
|
|
39
|
+
const build = (item) => (item && typeof item.build === 'function' ? item.build() : item);
|
|
40
|
+
if (Array.isArray(exported)) {
|
|
41
|
+
return exported.map(build).filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
// Modules without a default export (e.g. one that only calls `protectFields`) yield an empty extension,
|
|
44
|
+
// which registers nothing.
|
|
45
|
+
return [build(exported)];
|
|
46
|
+
};
|
|
28
47
|
exports.default = ({ strapi }) => ({
|
|
29
48
|
/**
|
|
30
|
-
*
|
|
31
|
-
* Recursively searches for files in the graphql directory
|
|
32
|
-
* Returns an array of GraphQLExtension objects
|
|
49
|
+
* Load all extensions from `graphql` folders below `directoryPath` (recursively).
|
|
33
50
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
51
|
+
* Files are loaded in a stable order (sorted by name, depth-first), so the generated schema and the
|
|
52
|
+
* order of side effects such as `protectFields()` / `registerGlobalAuthorize()` calls are the same on
|
|
53
|
+
* every start.
|
|
54
|
+
*
|
|
55
|
+
* @param directoryPath - Usually `strapi.dirs.dist.src`.
|
|
36
56
|
*/
|
|
37
|
-
async getAllFilesInGraphQlDirectory(directoryPath
|
|
38
|
-
const fileEntries = await fs_1.promises.readdir(directoryPath);
|
|
39
|
-
|
|
40
|
-
const
|
|
57
|
+
async getAllFilesInGraphQlDirectory(directoryPath) {
|
|
58
|
+
const fileEntries = [...await fs_1.promises.readdir(directoryPath)].sort();
|
|
59
|
+
const collected = [];
|
|
60
|
+
for (const file of fileEntries) {
|
|
41
61
|
const filePath = path.join(directoryPath, file);
|
|
42
62
|
const fileStats = await fs_1.promises.stat(filePath);
|
|
43
63
|
if (fileStats.isDirectory()) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
const folderName = 'graphql';
|
|
48
|
-
if (!directoryPath.endsWith(path.sep + folderName) &&
|
|
49
|
-
!directoryPath.includes(path.sep + folderName + path.sep)) {
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const graphqlModule = require(filePath.replace('.js', ''));
|
|
53
|
-
if (!graphqlModule) {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
// Support both ES module default export and CommonJS exports
|
|
57
|
-
let extension = graphqlModule.default || graphqlModule;
|
|
58
|
-
// If the extension is a builder (has a build function), call build() automatically
|
|
59
|
-
if (extension && typeof extension.build === 'function') {
|
|
60
|
-
extension = extension.build();
|
|
61
|
-
}
|
|
62
|
-
// If it's an array, flatten and push all
|
|
63
|
-
if (Array.isArray(extension)) {
|
|
64
|
-
collectedExtensions.push(...extension.map(ext => ext.build ? ext.build() : ext));
|
|
64
|
+
collected.push(...await this.getAllFilesInGraphQlDirectory(filePath));
|
|
65
|
+
continue;
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
if (!isInGraphQLFolder(directoryPath) || !isLoadableFile(file)) {
|
|
68
|
+
continue;
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return collectedExtensions;
|
|
70
|
+
collected.push(...toExtensions(require(filePath)));
|
|
71
|
+
}
|
|
72
|
+
return collected;
|
|
73
73
|
}
|
|
74
74
|
});
|