@nxgt/shared-graphql 2.0.1 → 3.0.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 +196 -81
- package/dist/directives/authenticated.d.ts +42 -0
- package/dist/directives/authenticated.d.ts.map +1 -0
- package/dist/directives/federation.d.ts +6 -0
- package/dist/directives/federation.d.ts.map +1 -1
- package/dist/directives/index.d.ts +5 -1
- package/dist/directives/index.d.ts.map +1 -1
- package/dist/directives/paths.d.ts +20 -0
- package/dist/directives/paths.d.ts.map +1 -0
- package/dist/directives/permission.d.ts +39 -0
- package/dist/directives/permission.d.ts.map +1 -0
- package/dist/directives/removed-check.d.ts +15 -0
- package/dist/directives/removed-check.d.ts.map +1 -0
- package/dist/directives/scope.d.ts +19 -0
- package/dist/directives/scope.d.ts.map +1 -0
- package/dist/directives/sdl.d.ts +26 -0
- package/dist/directives/sdl.d.ts.map +1 -0
- package/dist/directives/validate.d.ts +23 -0
- package/dist/directives/validate.d.ts.map +1 -0
- package/dist/index.js +1048 -564
- package/dist/index.js.map +37 -20
- package/dist/integrations/hono.d.ts +2 -2
- package/dist/integrations/hono.d.ts.map +1 -1
- package/dist/plugins/apply-authenticated.d.ts +21 -0
- package/dist/plugins/apply-authenticated.d.ts.map +1 -0
- package/dist/plugins/apply-keto-checks.d.ts +20 -0
- package/dist/plugins/apply-keto-checks.d.ts.map +1 -0
- package/dist/plugins/auth.d.ts +17 -1
- package/dist/plugins/auth.d.ts.map +1 -1
- package/dist/plugins/authenticated.d.ts +14 -0
- package/dist/plugins/authenticated.d.ts.map +1 -0
- package/dist/plugins/extract-jwt.d.ts +19 -6
- package/dist/plugins/extract-jwt.d.ts.map +1 -1
- package/dist/plugins/field-guard.d.ts +30 -0
- package/dist/plugins/field-guard.d.ts.map +1 -0
- package/dist/plugins/gateway-trust.d.ts +47 -0
- package/dist/plugins/gateway-trust.d.ts.map +1 -0
- package/dist/plugins/index.d.ts +6 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/keto-checker.d.ts +24 -0
- package/dist/plugins/keto-checker.d.ts.map +1 -0
- package/dist/plugins/keto-checks.d.ts +11 -34
- package/dist/plugins/keto-checks.d.ts.map +1 -1
- package/dist/plugins/keto-helpers.d.ts +38 -0
- package/dist/plugins/keto-helpers.d.ts.map +1 -0
- package/dist/plugins/ory-auth.d.ts +5 -5
- package/dist/plugins/ory-auth.d.ts.map +1 -1
- package/dist/scalars/custom/utils.d.ts.map +1 -1
- package/dist/shared.d.ts +1 -1
- package/dist/shared.d.ts.map +1 -1
- package/dist/utils/errors/create-error.d.ts +3 -0
- package/dist/utils/errors/create-error.d.ts.map +1 -0
- package/dist/utils/errors/denial.d.ts +20 -0
- package/dist/utils/errors/denial.d.ts.map +1 -0
- package/dist/utils/errors/format-error.d.ts +12 -0
- package/dist/utils/errors/format-error.d.ts.map +1 -0
- package/dist/utils/errors/index.d.ts +6 -0
- package/dist/utils/errors/index.d.ts.map +1 -0
- package/dist/utils/errors/mask-error.d.ts +25 -0
- package/dist/utils/errors/mask-error.d.ts.map +1 -0
- package/dist/utils/errors/ory-unavailable.d.ts +19 -0
- package/dist/utils/errors/ory-unavailable.d.ts.map +1 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/ws-context.d.ts +1 -1
- package/docs/README.md +13 -0
- package/docs/guide/authentication.md +187 -0
- package/docs/guide/errors.md +136 -0
- package/docs/guide/migrating-to-3.md +276 -0
- package/docs/guide/permissions.md +220 -0
- package/docs/roadmap.md +71 -0
- package/docs/troubleshooting.md +273 -0
- package/graphql/directives/permission.graphqls +73 -0
- package/package.json +11 -13
- package/dist/directives/check.d.ts +0 -40
- package/dist/directives/check.d.ts.map +0 -1
- package/dist/utils/errors.utils.d.ts +0 -23
- package/dist/utils/errors.utils.d.ts.map +0 -1
- package/graphql/directives/check.graphqls +0 -86
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Permissions: `@permission`, `useKetoChecks`
|
|
2
|
+
|
|
3
|
+
An Ory-native API asks Keto one question per object: **may this caller
|
|
4
|
+
`view` `Note:n1`**. This page is how a schema asks it, what answers it, and
|
|
5
|
+
what happens when the answer cannot be had.
|
|
6
|
+
|
|
7
|
+
## Wiring
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createOry } from '@nxgt/ory-sdk';
|
|
11
|
+
import {
|
|
12
|
+
createMaskError,
|
|
13
|
+
type OryGraphQLContext,
|
|
14
|
+
SHARED_SCHEMA_PATH,
|
|
15
|
+
loadTypeDefs,
|
|
16
|
+
useKetoChecks,
|
|
17
|
+
useOryAuth,
|
|
18
|
+
} from '@nxgt/shared-graphql';
|
|
19
|
+
import { createSchema, createYoga } from 'graphql-yoga';
|
|
20
|
+
|
|
21
|
+
const ory = createOry({ /* … */ });
|
|
22
|
+
|
|
23
|
+
const yoga = createYoga<{}, OryGraphQLContext>({
|
|
24
|
+
schema: createSchema({
|
|
25
|
+
typeDefs: loadTypeDefs(SHARED_SCHEMA_PATH, './src/**/*.graphqls'),
|
|
26
|
+
resolvers,
|
|
27
|
+
}),
|
|
28
|
+
plugins: [
|
|
29
|
+
useOryAuth(ory), // puts `ory`, `user`, `claims`, `token` on the context
|
|
30
|
+
useKetoChecks(ory, { namespaces: ['Note', 'Folder'] }),
|
|
31
|
+
],
|
|
32
|
+
maskedErrors: { maskError: createMaskError(translate) },
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`useOryAuth` is needed: a guarded field reads `ory.subject`, which it puts on
|
|
37
|
+
the context — without it every guarded field answers `UNAUTHENTICATED`.
|
|
38
|
+
`createMaskError` is optional for the denials, which carry their own status,
|
|
39
|
+
and is what translates their message with your `translate` and turns an
|
|
40
|
+
outage into a 503 — see [errors](./errors.md).
|
|
41
|
+
|
|
42
|
+
A schema assembled in code, with no `SHARED_SCHEMA_PATH`, takes the
|
|
43
|
+
declaration as a string:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { PERMISSION_DIRECTIVE_SDL } from '@nxgt/shared-graphql';
|
|
47
|
+
|
|
48
|
+
createSchema({ typeDefs: [PERMISSION_DIRECTIVE_SDL, typeDefs], resolvers });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It equals `graphql/directives/permission.graphqls` — a spec parses and prints
|
|
52
|
+
both. `KETO_DIRECTIVES_SDL` is the same string.
|
|
53
|
+
|
|
54
|
+
## `@permission`
|
|
55
|
+
|
|
56
|
+
```graphql
|
|
57
|
+
directive @permission(
|
|
58
|
+
name: String!
|
|
59
|
+
type: String!
|
|
60
|
+
id: String
|
|
61
|
+
onDeny: PermissionDenial! = NOT_FOUND
|
|
62
|
+
message: String
|
|
63
|
+
) repeatable on FIELD_DEFINITION
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Argument | Meaning |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `name` | the permit asked of Keto — `view`, `edit`; a plain relation works too |
|
|
69
|
+
| `type` | the Keto namespace — `Note` |
|
|
70
|
+
| `id` | where the object id is read: `args.<path>` or `parent.<path>`; `args.id` when omitted |
|
|
71
|
+
| `onDeny` | `NOT_FOUND` (default) or `FORBIDDEN` |
|
|
72
|
+
| `message` | the i18n key the denial carries |
|
|
73
|
+
|
|
74
|
+
### The 404-then-403 ladder
|
|
75
|
+
|
|
76
|
+
Repeated `@permission`s are AND, evaluated **in declaration order**, and each
|
|
77
|
+
answers its own `onDeny`:
|
|
78
|
+
|
|
79
|
+
```graphql
|
|
80
|
+
updateNote(id: ID!, input: UpdateNoteInput!): Note!
|
|
81
|
+
@permission(name: "view", type: "Note")
|
|
82
|
+
@permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Caller holds | Answer |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| nothing | `NOT_FOUND` (404) — the same as an id that never existed, so ids cannot be probed |
|
|
88
|
+
| `view` | `FORBIDDEN` (403) — they can already see it, so "you may not change it" is honest |
|
|
89
|
+
| `view` and `edit` | the resolver runs |
|
|
90
|
+
|
|
91
|
+
An anonymous caller is `UNAUTHENTICATED` (401) before Keto is asked anything.
|
|
92
|
+
|
|
93
|
+
### Where the id comes from
|
|
94
|
+
|
|
95
|
+
```graphql
|
|
96
|
+
note(id: ID!): Note @permission(name: "view", type: "Note")
|
|
97
|
+
noteByKey(key: ID!): Note @permission(name: "view", type: "Note", id: "args.key")
|
|
98
|
+
deleteNotes(ids: [ID!]!): Boolean @permission(name: "delete", type: "Note", id: "args.ids")
|
|
99
|
+
|
|
100
|
+
type Note {
|
|
101
|
+
ownerId: ID!
|
|
102
|
+
owner: Person @permission(name: "view", type: "Person", id: "parent.ownerId")
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A **list** requires the permit on every element, and stops at the first
|
|
107
|
+
refusal. `source.<path>` is accepted as the same root as `parent.<path>`.
|
|
108
|
+
|
|
109
|
+
A path that resolves no id at request time — a nullable argument nobody
|
|
110
|
+
passed, a parent field that was not loaded — is a wiring error (500), never an
|
|
111
|
+
allow.
|
|
112
|
+
|
|
113
|
+
### Or, in the model
|
|
114
|
+
|
|
115
|
+
There is no OR between `@permission`s. When a field is open to owners *or*
|
|
116
|
+
editors, say so in the Keto model — a permit that unions the two relations —
|
|
117
|
+
and ask that permit.
|
|
118
|
+
|
|
119
|
+
### Wording a refusal
|
|
120
|
+
|
|
121
|
+
`message` defaults to the shared `errors.not-found` /
|
|
122
|
+
`errors.insufficient-permissions`. Set it whenever the service behind the field
|
|
123
|
+
refuses the same object with a domain message:
|
|
124
|
+
|
|
125
|
+
```graphql
|
|
126
|
+
note(id: ID!): Note
|
|
127
|
+
@permission(name: "view", type: "Note", message: "notes.errors.not-found")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Two layers guard these fields — the directive and the service's own access
|
|
131
|
+
check — and if they word the same 404 differently, the wording tells a caller
|
|
132
|
+
which one refused: a generic message means "you may not", a domain one "it is
|
|
133
|
+
gone". That is the distinction `NOT_FOUND` exists to hide.
|
|
134
|
+
|
|
135
|
+
## `@check` — removed in 3.0
|
|
136
|
+
|
|
137
|
+
The list-of-lists form is gone. Rewrite each term as a `@permission`, and move
|
|
138
|
+
an OR into the Keto model — [the migration guide](./migrating-to-3.md#2-check-is-removed)
|
|
139
|
+
has the before and after. A `@check` left in a schema does not boot: see
|
|
140
|
+
[troubleshooting](../troubleshooting.md).
|
|
141
|
+
|
|
142
|
+
## Refused at build
|
|
143
|
+
|
|
144
|
+
`applyKetoChecks` — which `useKetoChecks` runs on every schema change — reads
|
|
145
|
+
every `@permission` when the schema is built, and throws a `TypeError` naming
|
|
146
|
+
`Type.field` for:
|
|
147
|
+
|
|
148
|
+
| Mistake | Message starts |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| a path naming no root | ``@permission on Query.note: `id` must be "args.<path>", …`` |
|
|
151
|
+
| an argument the field does not declare | ``… `id` reads "args.id", but the field declares noteId`` |
|
|
152
|
+
| an empty `name` or `type` | ``… every term needs `namespace`, `permit` and `id` …`` |
|
|
153
|
+
| a namespace outside `namespaces` | `… unknown namespace "Noet" — known: Note, Folder` |
|
|
154
|
+
| a guard on an interface field | `Node.id: @permission on an interface field guards nothing …` |
|
|
155
|
+
| a `@check` of 2.x's shape | `@check on Query.note: @check was removed in @nxgt/shared-graphql 3.0 …` |
|
|
156
|
+
|
|
157
|
+
`namespaces` is optional. Without it a namespace is taken on trust, and a
|
|
158
|
+
misspelt one answers `false` for ever — Keto does not error on a namespace it
|
|
159
|
+
does not know. Pass the namespaces of your OPL document to make that a boot
|
|
160
|
+
failure.
|
|
161
|
+
|
|
162
|
+
`applyKetoChecks` leaves a field it already guarded alone, so a field is never
|
|
163
|
+
wrapped twice, and a field it has not — the other half of a merged schema —
|
|
164
|
+
is guarded when it runs again — as is a field whose resolver was replaced
|
|
165
|
+
since (`addResolversToSchema`, a merge with resolvers).
|
|
166
|
+
|
|
167
|
+
On a subscription, a `@permission` whose `id` reads `args.*` is asked before
|
|
168
|
+
`subscribe`, so a refused subscription opens no stream. One that reads
|
|
169
|
+
`parent.*` has no event to read yet: it is asked of each event instead, and a
|
|
170
|
+
refused event answers its denial while the stream stays open.
|
|
171
|
+
|
|
172
|
+
## From a resolver
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { can, denial, type OryGraphQLContext, requireUser } from '@nxgt/shared-graphql';
|
|
176
|
+
import { ErrorCode } from '@nxgt/shared-exceptions';
|
|
177
|
+
|
|
178
|
+
const resolvers = {
|
|
179
|
+
Mutation: {
|
|
180
|
+
archive: async (_: unknown, { id }: { id: string }, ctx: OryGraphQLContext) => {
|
|
181
|
+
const user = requireUser(ctx);
|
|
182
|
+
if (!(await can(ctx, { name: 'edit', type: 'Note', id }))) {
|
|
183
|
+
throw denial(ErrorCode.Forbidden, 'notes.errors.read-only');
|
|
184
|
+
}
|
|
185
|
+
return notes.archive(id, user.sub);
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- `requireUser(ctx)` returns `ctx.user`, or throws an `UNAUTHENTICATED`
|
|
192
|
+
denial (401).
|
|
193
|
+
- `can(ctx, { name, type, id })` returns Keto's answer. It throws
|
|
194
|
+
`UNAUTHENTICATED` with no caller, names the missing plugin without
|
|
195
|
+
`useKetoChecks`, and lets `OryUnavailable` through on an outage — it never
|
|
196
|
+
answers `false` for a question it could not ask.
|
|
197
|
+
|
|
198
|
+
## The per-request memo
|
|
199
|
+
|
|
200
|
+
`useKetoChecks` puts one `ketoChecks` function on each request's context. It
|
|
201
|
+
**batches** the distinct questions of one tick into a single
|
|
202
|
+
`POST /relation-tuples/batch/check` and **memoises** answers for the request.
|
|
203
|
+
A field guarded by `@permission(view)` and a service that asks the same
|
|
204
|
+
question through `can` pay for one round trip between them.
|
|
205
|
+
|
|
206
|
+
A batch that failed is not remembered: every question in it rejects with the
|
|
207
|
+
outage, and the next ask goes back to Keto.
|
|
208
|
+
|
|
209
|
+
## An outage is not a no
|
|
210
|
+
|
|
211
|
+
`@nxgt/ory-sdk` throws `OryUnavailable` when Keto cannot answer. Nothing in
|
|
212
|
+
this package catches it as a denial: the directive, `can` and the memo all
|
|
213
|
+
let it through, and `createMaskError` / `createFormatError` answer it
|
|
214
|
+
`SERVICE_UNAVAILABLE` with HTTP 503. See [errors](./errors.md).
|
|
215
|
+
|
|
216
|
+
## Not a check: lists
|
|
217
|
+
|
|
218
|
+
"Which notes may I see" is a Keto query folded into the database filter before
|
|
219
|
+
the read, not a directive. A directive on a list field would have to fetch
|
|
220
|
+
everything and filter after, which makes `totalCount` and the cursors lie.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@nxgt/shared-graphql` does now, what is planned, and what it will not do.
|
|
4
|
+
|
|
5
|
+
## Now
|
|
6
|
+
|
|
7
|
+
The GraphQL layer for Yoga and Apollo services: shared SDL, scalars and
|
|
8
|
+
directives shipped in `graphql/`, `useOryAuth`, `useAuthenticated` and
|
|
9
|
+
`useKetoChecks` for an Ory-native API, `useAuth` behind a trusted gateway,
|
|
10
|
+
denials that carry their status, `createMaskError` / `createFormatError`,
|
|
11
|
+
dataloaders, subscriptions over Redis, uploads and the Hono integration.
|
|
12
|
+
|
|
13
|
+
## Next
|
|
14
|
+
|
|
15
|
+
Nothing planned that changes what a consumer gets. See Later.
|
|
16
|
+
|
|
17
|
+
## Later
|
|
18
|
+
|
|
19
|
+
- **Batching a list argument's ids** into one Keto batch rather than one
|
|
20
|
+
question per id in order.
|
|
21
|
+
|
|
22
|
+
## Not planned
|
|
23
|
+
|
|
24
|
+
- **A directive on a list field** that filters after the read. "Which objects
|
|
25
|
+
may I see" is a Keto query folded into the database filter.
|
|
26
|
+
- **OR between directives.** It belongs in the Keto model.
|
|
27
|
+
- **Turning an Ory outage into a denial or an anonymous caller**, under any
|
|
28
|
+
option. It is a 503.
|
|
29
|
+
|
|
30
|
+
## Shipped
|
|
31
|
+
|
|
32
|
+
### 3.0 — [migration guide](./guide/migrating-to-3.md)
|
|
33
|
+
|
|
34
|
+
- **The caller comes from a verified source only**: `useAuth()` and
|
|
35
|
+
`extractJwtPlugin()` read `extensions` only for a request
|
|
36
|
+
`trustedGateway` vouches for — `gatewaySecret({ secret })` by default — and
|
|
37
|
+
refuse to start without one.
|
|
38
|
+
- **`@check` removed**, leaving `@permission`; a `@check` left in a schema is
|
|
39
|
+
refused at build, and the mistakes 2.x only warned about on it are
|
|
40
|
+
`TypeError`s.
|
|
41
|
+
- **`@authenticated(type: [String!])`**, enforced by `useAuthenticated()`:
|
|
42
|
+
401 for no caller, 403 for a caller of another type. Federation's
|
|
43
|
+
argument-less declaration is read as "any caller".
|
|
44
|
+
- **Denials are `GraphQLError`s** with `extensions { code, http { status } }`
|
|
45
|
+
— `denial(code, message?)` — so a server without `createMaskError` answers
|
|
46
|
+
401/403/404 rather than a masked 500.
|
|
47
|
+
- **Five unused dependencies dropped**: `@graphql-hive/gateway`,
|
|
48
|
+
`@envelop/generic-auth`, `@envelop/extended-validation`,
|
|
49
|
+
`@hono/zod-validator`, `zod`.
|
|
50
|
+
- **Peers bounded**: `@nxgt/ory-sdk` `>=0.1.0 <1`, `graphql` `^16.9.0 ||
|
|
51
|
+
^17.0.0`.
|
|
52
|
+
- **`sandboxExplorer`**, the sandbox helper's corrected name.
|
|
53
|
+
|
|
54
|
+
### 2.1
|
|
55
|
+
|
|
56
|
+
- **`@permission(name, type, id, onDeny, message)`**, the flat form, answered
|
|
57
|
+
with `@check` in declaration order; `@check` deprecated in its favour.
|
|
58
|
+
- **More refused at build**: an argument the field does not declare, a
|
|
59
|
+
namespace outside the model (`namespaces`), a guard on an interface field —
|
|
60
|
+
each a `TypeError` naming the field for `@permission`, and a warning for
|
|
61
|
+
`@check`, which booted with the first and last before.
|
|
62
|
+
- **`requireUser`, `can` and `OryGraphQLContext`** for resolvers, through the
|
|
63
|
+
same per-request memo.
|
|
64
|
+
- **The directive SDL as strings** — `PERMISSION_DIRECTIVE_SDL`,
|
|
65
|
+
`CHECK_DIRECTIVE_SDL`, `KETO_DIRECTIVES_SDL` — held equal to the files.
|
|
66
|
+
- **graphql 17**: `graphql` is a peer, `^16.4.2 || ^17.0.0`, and the suite
|
|
67
|
+
runs on both.
|
|
68
|
+
- **An outage under Apollo is a 503 too**, and one from a second copy of
|
|
69
|
+
`@nxgt/ory-sdk` is still recognised.
|
|
70
|
+
- **Internal messages masked**: a plain `Error` a resolver threw no longer
|
|
71
|
+
reaches the client through `createMaskError`.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Troubleshooting `@nxgt/shared-graphql`
|
|
2
|
+
|
|
3
|
+
Each entry is headed by the message you see; the parts in `<angle brackets>`
|
|
4
|
+
vary. Messages thrown when the server starts or the schema is built are
|
|
5
|
+
`TypeError`s, and stop the server from booting; the schema ones name
|
|
6
|
+
`Type.field` after the directive that carries the mistake. The last section
|
|
7
|
+
holds the traps that throw nothing.
|
|
8
|
+
|
|
9
|
+
Upgrading from 2.x, most of these come from [the
|
|
10
|
+
migration](./guide/migrating-to-3.md).
|
|
11
|
+
|
|
12
|
+
## When the server starts
|
|
13
|
+
|
|
14
|
+
### ``useAuth(): name the gateway allowed to set the caller — useAuth({ trustedGateway: gatewaySecret({ secret }) }). …``
|
|
15
|
+
|
|
16
|
+
Also `extractJwtPlugin(): name the gateway allowed to set the caller — …`.
|
|
17
|
+
|
|
18
|
+
Since 3.0 both need to know which gateway may put the caller in the request's
|
|
19
|
+
`extensions`, which a client can otherwise write. Pass one:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const trustedGateway = gatewaySecret({ secret: process.env.GATEWAY_SECRET! });
|
|
23
|
+
createYoga({ plugins: [useAuth({ trustedGateway })] });
|
|
24
|
+
new ApolloServer({ plugins: [extractJwtPlugin({ trustedGateway })] });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`extractJwtPlugin` is a function now: `plugins: [extractJwtPlugin]` passes
|
|
28
|
+
the function itself, not a plugin. An API that authenticates its own callers
|
|
29
|
+
uses `useOryAuth(ory)` and needs neither.
|
|
30
|
+
|
|
31
|
+
### ``gatewaySecret(): `secret` must be a string of at least 16 characters — is its environment variable set?``
|
|
32
|
+
|
|
33
|
+
The secret is missing or shorter than 16 characters — most often an
|
|
34
|
+
environment variable that is not set where the server runs. Set it, to the
|
|
35
|
+
same value the gateway sends.
|
|
36
|
+
|
|
37
|
+
### `Cannot find module '@envelop/generic-auth'`
|
|
38
|
+
|
|
39
|
+
Or `@graphql-hive/gateway`, `@envelop/extended-validation`,
|
|
40
|
+
`@hono/zod-validator`, `zod`. 3.0 dropped them from its dependencies — nothing
|
|
41
|
+
here imported them — and your app was using the copy this package brought.
|
|
42
|
+
Declare it yourself:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
bun add @envelop/generic-auth
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### ``Module '"@nxgt/shared-graphql"' has no exported member 'sandboxExpolorer'. Did you mean 'sandboxExplorer'?``
|
|
49
|
+
|
|
50
|
+
The typo was corrected in 3.0, with no alias. Import `sandboxExplorer`.
|
|
51
|
+
|
|
52
|
+
## When the schema is built
|
|
53
|
+
|
|
54
|
+
### ``@check on <Type.field>: @check was removed in @nxgt/shared-graphql 3.0 — write one @permission(name: "<permit>", type: "<namespace>") per term, and move an OR into the Keto model``
|
|
55
|
+
|
|
56
|
+
The schema declares 2.x's `@check` itself and a field still uses it. This
|
|
57
|
+
package no longer answers it, so the field would boot unguarded; it is refused
|
|
58
|
+
instead. Rewrite it:
|
|
59
|
+
|
|
60
|
+
```graphql
|
|
61
|
+
# before
|
|
62
|
+
note(id: ID!): Note @check(permissions: [[{ namespace: "Note", permit: "view" }]])
|
|
63
|
+
# after
|
|
64
|
+
note(id: ID!): Note @permission(name: "view", type: "Note")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then drop your `@check` declaration. [The migration guide](./guide/migrating-to-3.md#2-check-is-removed)
|
|
68
|
+
covers an AND and an OR.
|
|
69
|
+
|
|
70
|
+
### `Unknown directive "@check".`
|
|
71
|
+
|
|
72
|
+
The same, in a schema that loads this package's `graphql/` and nothing else:
|
|
73
|
+
3.0 no longer ships `@check`'s declaration. Rewrite the field as above.
|
|
74
|
+
|
|
75
|
+
### ``@permission on <Type.field>: `id` must be "args.<path>", "parent.<path>" or "source.<path>", got "<id>"``
|
|
76
|
+
|
|
77
|
+
The `id` argument names no root. Prefix it:
|
|
78
|
+
|
|
79
|
+
```graphql
|
|
80
|
+
note(noteId: ID!): Note @permission(name: "view", type: "Note", id: "args.noteId")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### ``@permission on <Type.field>: `id` reads "args.<name>", but the field declares <arguments>``
|
|
84
|
+
|
|
85
|
+
The path reads an argument the field does not have — usually the default
|
|
86
|
+
`args.id` on a field whose argument is named otherwise. graphql-js never puts
|
|
87
|
+
an undeclared argument on `args`, so every request would have failed. Name the
|
|
88
|
+
argument in `id`, or read the parent with `parent.<path>`.
|
|
89
|
+
|
|
90
|
+
In 2.x a `@check` booted with this and logged a warning; a `@permission`
|
|
91
|
+
refuses it.
|
|
92
|
+
|
|
93
|
+
### ``@permission on <Type.field>: every term needs `namespace`, `permit` and `id`, got <term>``
|
|
94
|
+
|
|
95
|
+
`@permission(name: "", type: "Note")`: an empty `name` or `type`. Name both.
|
|
96
|
+
|
|
97
|
+
### `@permission on <Type.field>: unknown namespace "<type>" — known: <namespaces>`
|
|
98
|
+
|
|
99
|
+
You passed `namespaces` to `useKetoChecks` / `applyKetoChecks`, and the field
|
|
100
|
+
names one outside them — most often a typo. Keto would have answered `false`
|
|
101
|
+
for ever, without an error. Fix the name, or add the namespace to the list
|
|
102
|
+
when the OPL document gained it.
|
|
103
|
+
|
|
104
|
+
### `<Type.field>: @permission on an interface field guards nothing — no resolver runs there. Put it on each implementing type's field`
|
|
105
|
+
|
|
106
|
+
A resolver runs on an object type's field, never on an interface's. Move the
|
|
107
|
+
directive to every implementing type:
|
|
108
|
+
|
|
109
|
+
```graphql
|
|
110
|
+
interface Node { id: ID! }
|
|
111
|
+
type Note implements Node {
|
|
112
|
+
id: ID!
|
|
113
|
+
body: String @permission(name: "view", type: "Note", id: "parent.id")
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
In 2.x a `@check` there booted with a warning; a `@permission` is refused.
|
|
118
|
+
|
|
119
|
+
### ``@authenticated on <where>: `type: []` admits no caller — name a type, or drop `type` ``
|
|
120
|
+
|
|
121
|
+
`<where>` is where the directive sits: `Type.field`, or a type, interface,
|
|
122
|
+
scalar or enum name.
|
|
123
|
+
|
|
124
|
+
An empty list admits nobody. Name the kinds of caller, or drop `type` to
|
|
125
|
+
admit any caller.
|
|
126
|
+
|
|
127
|
+
### `@authenticated on <where>: unknown type "<type>" — known: <types>`
|
|
128
|
+
|
|
129
|
+
`type:` names a value outside the caller types `useAuthenticated` knows —
|
|
130
|
+
`session` and `token` by default. Fix the name, or list your own:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
useAuthenticated({ types: [...CALLER_TYPES, 'service'] });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### ``@authenticated on <Type.field>: the field's, its type's and its interfaces' `type`s have none in common — no caller could pass``
|
|
137
|
+
|
|
138
|
+
Every `@authenticated` that applies to a field must hold — the field's, its
|
|
139
|
+
type's, its interfaces'. Two of them name disjoint types, so no caller could
|
|
140
|
+
reach the field. Drop or widen one.
|
|
141
|
+
|
|
142
|
+
### `Unknown argument "type" on directive "@authenticated".`
|
|
143
|
+
|
|
144
|
+
The schema declares `@authenticated` without an argument — federation's
|
|
145
|
+
declaration, imported through `@link`, or your own — and a field writes
|
|
146
|
+
`type:`. In a subgraph keep federation's form and drop `type:`; in a schema
|
|
147
|
+
of your own, load `SHARED_TYPE_DEFS` or `AUTHENTICATED_DIRECTIVE_SDL` in place
|
|
148
|
+
of your declaration.
|
|
149
|
+
|
|
150
|
+
### `@nxgt/shared-graphql: no package root`
|
|
151
|
+
|
|
152
|
+
Thrown when the package is imported, if no `package.json` sits anywhere
|
|
153
|
+
above its files — a bundle copied into an image without one. The more common
|
|
154
|
+
case throws nothing: a bundler inlined the package into your app's bundle, the
|
|
155
|
+
walk finds your app's `package.json`, and `SHARED_SCHEMA_PATH` points at
|
|
156
|
+
`<app>/graphql/**`, which loads no SDL. Either way, mark
|
|
157
|
+
`@nxgt/shared-graphql` external in the bundler so it stays on disk as
|
|
158
|
+
installed.
|
|
159
|
+
|
|
160
|
+
### `Directive "@permission" argument "name" of type "String!" is required, but it was not provided.`
|
|
161
|
+
|
|
162
|
+
On graphql 17: `Argument "@permission(name:)" of type "String!" is required, but it was not provided.`
|
|
163
|
+
|
|
164
|
+
Your schema declares its own `@permission`, and you load this package's
|
|
165
|
+
`graphql/**/*.graphqls` (`SHARED_SCHEMA_PATH`) beside it: `mergeTypeDefs`
|
|
166
|
+
merges the two declarations into one with both sets of arguments, and your
|
|
167
|
+
usages lack `name` and `type`. `@permission` and `PermissionDenial` are
|
|
168
|
+
names this package ships. Load only the folders you need —
|
|
169
|
+
`graphql/scalars`, `graphql/schema` — or rename your directive.
|
|
170
|
+
`useKetoChecks` itself leaves a `@permission` without `name` and `type`
|
|
171
|
+
alone.
|
|
172
|
+
|
|
173
|
+
### `Cannot use GraphQLSchema "<schema>" from another module or realm.`
|
|
174
|
+
|
|
175
|
+
Two copies of `graphql` are installed: `graphql` is a peer of this package
|
|
176
|
+
(`^16.9.0 || ^17.0.0`), so your app must declare it once, and every GraphQL
|
|
177
|
+
library must resolve that one. Add `graphql` to your `dependencies` if it was
|
|
178
|
+
only there through this package, and check with `bun pm ls graphql` (or
|
|
179
|
+
`npm ls graphql`) that one version remains. graphql runs this check only
|
|
180
|
+
when `NODE_ENV` is not `production`; in production two copies fail later,
|
|
181
|
+
with less telling errors.
|
|
182
|
+
|
|
183
|
+
## When a request runs
|
|
184
|
+
|
|
185
|
+
### `<Type.field>: "<path>" resolved no object id`
|
|
186
|
+
|
|
187
|
+
A plain `Error` thrown inside a resolver: `createMaskError` answers the client
|
|
188
|
+
`Unexpected error.` (`INTERNAL_SERVER_ERROR`); the text is in the server log,
|
|
189
|
+
and in `extensions.debugMessage` when `isDev` is on.
|
|
190
|
+
|
|
191
|
+
The path was valid, but at request time it pointed at nothing — a nullable
|
|
192
|
+
argument nobody passed, an empty list, a parent field the parent resolver did
|
|
193
|
+
not load. It is a 500, never an allow. Make the argument required, or make
|
|
194
|
+
sure the parent object carries the field.
|
|
195
|
+
|
|
196
|
+
### `<Type.field>: no checker on the context — useKetoChecks(ory) is not registered`
|
|
197
|
+
|
|
198
|
+
The schema was transformed with `applyKetoChecks` but the plugin that puts the
|
|
199
|
+
per-request checker on the context is missing. Register
|
|
200
|
+
`useKetoChecks(ory)` after `useOryAuth(ory)`. Answered as the previous entry.
|
|
201
|
+
|
|
202
|
+
### `can(): no checker on the context — useKetoChecks(ory) is not registered`
|
|
203
|
+
|
|
204
|
+
The same, from `can`.
|
|
205
|
+
|
|
206
|
+
### `ory: keto is unavailable` with `SERVICE_UNAVAILABLE` and HTTP 503
|
|
207
|
+
|
|
208
|
+
Keto (or Kratos, Hydra — the name varies) did not answer. This is on
|
|
209
|
+
purpose: an outage is never turned into a denial or an anonymous caller. The
|
|
210
|
+
client can retry; `extensions.debugMessage` carries what the SDK saw.
|
|
211
|
+
|
|
212
|
+
### A denial's message reads `notes.errors.not-found`
|
|
213
|
+
|
|
214
|
+
A `@permission(message: "notes.errors.not-found")`, or your own
|
|
215
|
+
`denial(code, key)`, reached the client untranslated. A denial translates its
|
|
216
|
+
key with `@nxgt/i18n`'s own resources, which do not hold your app's keys.
|
|
217
|
+
Register the mask with your translator:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
createYoga({ maskedErrors: { maskError: createMaskError(translate) } });
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Under Apollo Server, the counterpart is `formatError: createFormatError(translate)`.
|
|
224
|
+
|
|
225
|
+
### `Unexpected error.` with `INTERNAL_SERVER_ERROR` where a service's 404 or 403 was expected
|
|
226
|
+
|
|
227
|
+
A service threw a `CustomException` and `createMaskError` is not registered,
|
|
228
|
+
so Yoga masks it as any non-GraphQL error. The directives and `requireUser` /
|
|
229
|
+
`can` are not concerned — their denials carry their own status — but a
|
|
230
|
+
service's `CustomException.notFound()` is. Register `createMaskError`, as
|
|
231
|
+
above, or throw `denial(code, message)` from the resolver.
|
|
232
|
+
|
|
233
|
+
### `Unexpected error.` where a resolver's own message used to reach the client
|
|
234
|
+
|
|
235
|
+
`createMaskError` masks a plain `Error` a resolver threw, as Yoga's default
|
|
236
|
+
does. Throw a `CustomException`, a `denial()` or a `GraphQLError` for a
|
|
237
|
+
message meant for the caller.
|
|
238
|
+
|
|
239
|
+
## Traps that throw nothing
|
|
240
|
+
|
|
241
|
+
### Every caller is anonymous behind the gateway
|
|
242
|
+
|
|
243
|
+
`useAuth({ trustedGateway })` reads no caller from a request that does not
|
|
244
|
+
carry the gateway's proof. The gateway is not sending the header — or sends
|
|
245
|
+
another secret — on its subgraph requests. Make it add
|
|
246
|
+
`x-gateway-secret: <secret>` (or your `header`) to every one, with the same
|
|
247
|
+
value the subgraph was given.
|
|
248
|
+
|
|
249
|
+
### `FORBIDDEN` from `@authenticated(type:)` for a caller who should pass
|
|
250
|
+
|
|
251
|
+
The caller's type is `context.ory.kind`, or `context.user.tokenType` without
|
|
252
|
+
Ory. Behind a gateway that writes no `tokenType` in the `user` it forwards,
|
|
253
|
+
every caller has no type, and every `type:` refuses them. Make the gateway
|
|
254
|
+
write it, or drop `type:` on those fields.
|
|
255
|
+
|
|
256
|
+
### A guarded field answers unguarded in the supergraph
|
|
257
|
+
|
|
258
|
+
A subgraph composed by federation drops a directive it was not told to keep.
|
|
259
|
+
Add `@composeDirective(name: "@permission")` and import the directive in the
|
|
260
|
+
subgraph's `@link`.
|
|
261
|
+
|
|
262
|
+
### `@authenticated(type:)` in a subgraph
|
|
263
|
+
|
|
264
|
+
Federation declares `@authenticated` without an argument, and a subgraph
|
|
265
|
+
imports that declaration. Keep it: `useAuthenticated` enforces it as "any
|
|
266
|
+
caller". A `type:` restriction is for a schema that declares `@authenticated`
|
|
267
|
+
through `SHARED_TYPE_DEFS` or `AUTHENTICATED_DIRECTIVE_SDL`.
|
|
268
|
+
|
|
269
|
+
### `OryForbidden` from `ory.requireAllowed` is a 500
|
|
270
|
+
|
|
271
|
+
`createMaskError` does not map `OryForbidden`: whether a refusal is a 404 or a
|
|
272
|
+
403 is the caller's decision. In a resolver, use `can` and throw the
|
|
273
|
+
`denial` you mean.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# The flat form of @check, and the name to write in a new schema.
|
|
2
|
+
#
|
|
3
|
+
# One @permission is one Keto question — "may the caller `name` this `type`" —
|
|
4
|
+
# the same vocabulary as @nxgt/janus-graphql's @permission. Repeated, they are
|
|
5
|
+
# AND, evaluated in declaration order with @check, each with its own onDeny.
|
|
6
|
+
# An OR belongs in the Keto model (a permit that unions two relations), not in
|
|
7
|
+
# the schema; @check keeps the list-of-lists form for the schemas that still
|
|
8
|
+
# write one, and is deprecated in its favour.
|
|
9
|
+
#
|
|
10
|
+
# `useKetoChecks(ory)` answers both. The string export PERMISSION_DIRECTIVE_SDL
|
|
11
|
+
# carries the same declaration, and a spec holds the two equal.
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
What a refused @permission looks like from outside.
|
|
15
|
+
"""
|
|
16
|
+
enum PermissionDenial {
|
|
17
|
+
"""
|
|
18
|
+
The same answer as for an id that never existed, so ids cannot be probed.
|
|
19
|
+
The default.
|
|
20
|
+
"""
|
|
21
|
+
NOT_FOUND
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
For a permission asked of an object the caller can already see: a viewer
|
|
25
|
+
asked to edit already knows it exists, and "you may not change it" is
|
|
26
|
+
honest.
|
|
27
|
+
"""
|
|
28
|
+
FORBIDDEN
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
The permission a field requires: the caller must hold the permit `name` on the
|
|
33
|
+
`type` object whose id `id` points at.
|
|
34
|
+
|
|
35
|
+
Repeatable, and repeated ones are AND, evaluated in declaration order — the
|
|
36
|
+
404-then-403 ladder:
|
|
37
|
+
|
|
38
|
+
updateNote(id: ID!, input: UpdateNoteInput!): Note!
|
|
39
|
+
@permission(name: "view", type: "Note")
|
|
40
|
+
@permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
|
|
41
|
+
|
|
42
|
+
NOT for a field that answers a LIST the caller is entitled to: fold a Keto
|
|
43
|
+
query into the database filter before the read instead.
|
|
44
|
+
"""
|
|
45
|
+
directive @permission(
|
|
46
|
+
"""
|
|
47
|
+
The permit asked of Keto, e.g. "view". A plain relation works too; Keto
|
|
48
|
+
answers `false`, not an error, for a name it does not know.
|
|
49
|
+
"""
|
|
50
|
+
name: String!
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
The Keto namespace, e.g. "Note" — from the stack's OPL document.
|
|
54
|
+
"""
|
|
55
|
+
type: String!
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
Where the object id is read: `args.<path>` or `parent.<path>`, dotted
|
|
59
|
+
paths allowed, `args.id` when omitted. A value that turns out to be a LIST
|
|
60
|
+
requires the permit on every element.
|
|
61
|
+
"""
|
|
62
|
+
id: String
|
|
63
|
+
|
|
64
|
+
onDeny: PermissionDenial! = NOT_FOUND
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
The i18n key the denial carries, e.g. "notes.errors.not-found". Defaults to
|
|
68
|
+
`errors.not-found` / `errors.insufficient-permissions`, the shared keys.
|
|
69
|
+
Set it when the service layer words the same refusal with a domain message,
|
|
70
|
+
so the wording does not tell a caller which layer refused.
|
|
71
|
+
"""
|
|
72
|
+
message: String
|
|
73
|
+
) repeatable on FIELD_DEFINITION
|