@smartive/graphql-magic 1.0.1
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/.eslintrc +21 -0
- package/.github/workflows/release.yml +24 -0
- package/.github/workflows/testing.yml +37 -0
- package/.nvmrc +1 -0
- package/.prettierignore +34 -0
- package/.prettierrc.json +1 -0
- package/.releaserc +27 -0
- package/CHANGELOG.md +6 -0
- package/README.md +15 -0
- package/dist/cjs/index.cjs +2646 -0
- package/dist/esm/client/gql.d.ts +1 -0
- package/dist/esm/client/gql.js +5 -0
- package/dist/esm/client/gql.js.map +1 -0
- package/dist/esm/client/index.d.ts +2 -0
- package/dist/esm/client/index.js +4 -0
- package/dist/esm/client/index.js.map +1 -0
- package/dist/esm/client/queries.d.ts +24 -0
- package/dist/esm/client/queries.js +152 -0
- package/dist/esm/client/queries.js.map +1 -0
- package/dist/esm/context.d.ts +30 -0
- package/dist/esm/context.js +2 -0
- package/dist/esm/context.js.map +1 -0
- package/dist/esm/errors.d.ts +17 -0
- package/dist/esm/errors.js +27 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/generate/generate.d.ts +7 -0
- package/dist/esm/generate/generate.js +211 -0
- package/dist/esm/generate/generate.js.map +1 -0
- package/dist/esm/generate/index.d.ts +3 -0
- package/dist/esm/generate/index.js +5 -0
- package/dist/esm/generate/index.js.map +1 -0
- package/dist/esm/generate/mutations.d.ts +2 -0
- package/dist/esm/generate/mutations.js +18 -0
- package/dist/esm/generate/mutations.js.map +1 -0
- package/dist/esm/generate/utils.d.ts +22 -0
- package/dist/esm/generate/utils.js +150 -0
- package/dist/esm/generate/utils.js.map +1 -0
- package/dist/esm/index.d.ts +10 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/migrations/generate.d.ts +28 -0
- package/dist/esm/migrations/generate.js +516 -0
- package/dist/esm/migrations/generate.js.map +1 -0
- package/dist/esm/migrations/index.d.ts +1 -0
- package/dist/esm/migrations/index.js +3 -0
- package/dist/esm/migrations/index.js.map +1 -0
- package/dist/esm/models.d.ts +170 -0
- package/dist/esm/models.js +27 -0
- package/dist/esm/models.js.map +1 -0
- package/dist/esm/permissions/check.d.ts +15 -0
- package/dist/esm/permissions/check.js +162 -0
- package/dist/esm/permissions/check.js.map +1 -0
- package/dist/esm/permissions/generate.d.ts +45 -0
- package/dist/esm/permissions/generate.js +77 -0
- package/dist/esm/permissions/generate.js.map +1 -0
- package/dist/esm/permissions/index.d.ts +2 -0
- package/dist/esm/permissions/index.js +4 -0
- package/dist/esm/permissions/index.js.map +1 -0
- package/dist/esm/resolvers/arguments.d.ts +26 -0
- package/dist/esm/resolvers/arguments.js +88 -0
- package/dist/esm/resolvers/arguments.js.map +1 -0
- package/dist/esm/resolvers/filters.d.ts +5 -0
- package/dist/esm/resolvers/filters.js +126 -0
- package/dist/esm/resolvers/filters.js.map +1 -0
- package/dist/esm/resolvers/index.d.ts +7 -0
- package/dist/esm/resolvers/index.js +9 -0
- package/dist/esm/resolvers/index.js.map +1 -0
- package/dist/esm/resolvers/mutations.d.ts +3 -0
- package/dist/esm/resolvers/mutations.js +255 -0
- package/dist/esm/resolvers/mutations.js.map +1 -0
- package/dist/esm/resolvers/node.d.ts +44 -0
- package/dist/esm/resolvers/node.js +102 -0
- package/dist/esm/resolvers/node.js.map +1 -0
- package/dist/esm/resolvers/resolver.d.ts +5 -0
- package/dist/esm/resolvers/resolver.js +143 -0
- package/dist/esm/resolvers/resolver.js.map +1 -0
- package/dist/esm/resolvers/resolvers.d.ts +9 -0
- package/dist/esm/resolvers/resolvers.js +39 -0
- package/dist/esm/resolvers/resolvers.js.map +1 -0
- package/dist/esm/resolvers/utils.d.ts +43 -0
- package/dist/esm/resolvers/utils.js +125 -0
- package/dist/esm/resolvers/utils.js.map +1 -0
- package/dist/esm/utils.d.ts +25 -0
- package/dist/esm/utils.js +159 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/esm/values.d.ts +15 -0
- package/dist/esm/values.js +7 -0
- package/dist/esm/values.js.map +1 -0
- package/jest.config.ts +12 -0
- package/package.json +66 -0
- package/renovate.json +32 -0
- package/src/client/gql.ts +7 -0
- package/src/client/index.ts +4 -0
- package/src/client/queries.ts +251 -0
- package/src/context.ts +27 -0
- package/src/errors.ts +32 -0
- package/src/generate/generate.ts +273 -0
- package/src/generate/index.ts +5 -0
- package/src/generate/mutations.ts +35 -0
- package/src/generate/utils.ts +223 -0
- package/src/index.ts +12 -0
- package/src/migrations/generate.ts +633 -0
- package/src/migrations/index.ts +3 -0
- package/src/models.ts +228 -0
- package/src/permissions/check.ts +239 -0
- package/src/permissions/generate.ts +143 -0
- package/src/permissions/index.ts +4 -0
- package/src/resolvers/arguments.ts +129 -0
- package/src/resolvers/filters.ts +163 -0
- package/src/resolvers/index.ts +9 -0
- package/src/resolvers/mutations.ts +313 -0
- package/src/resolvers/node.ts +193 -0
- package/src/resolvers/resolver.ts +223 -0
- package/src/resolvers/resolvers.ts +40 -0
- package/src/resolvers/utils.ts +188 -0
- package/src/utils.ts +186 -0
- package/src/values.ts +19 -0
- package/tests/unit/__snapshots__/generate.spec.ts.snap +105 -0
- package/tests/unit/__snapshots__/resolve.spec.ts.snap +60 -0
- package/tests/unit/generate.spec.ts +8 -0
- package/tests/unit/resolve.spec.ts +128 -0
- package/tests/unit/utils.ts +82 -0
- package/tsconfig.jest.json +13 -0
- package/tsconfig.json +13 -0
package/src/models.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { Context } from './context';
|
|
2
|
+
import type { OrderBy } from './resolvers/arguments';
|
|
3
|
+
import type { Directive, Value } from './values';
|
|
4
|
+
|
|
5
|
+
export type RawModels = RawModel[];
|
|
6
|
+
|
|
7
|
+
export type RawModel =
|
|
8
|
+
| ScalarModel
|
|
9
|
+
| EnumModel
|
|
10
|
+
| RawEnumModel
|
|
11
|
+
| InterfaceModel
|
|
12
|
+
| ObjectModel
|
|
13
|
+
| RawObjectModel
|
|
14
|
+
| JsonObjectModel;
|
|
15
|
+
|
|
16
|
+
type BaseModel = {
|
|
17
|
+
name: string;
|
|
18
|
+
plural?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ScalarModel = BaseModel & { type: 'scalar' };
|
|
23
|
+
|
|
24
|
+
export type EnumModel = BaseModel & { type: 'enum'; values: string[]; deleted?: true };
|
|
25
|
+
|
|
26
|
+
export type RawEnumModel = BaseModel & { type: 'raw-enum'; values: string[] };
|
|
27
|
+
|
|
28
|
+
export type InterfaceModel = BaseModel & { type: 'interface'; fields: ModelField[] };
|
|
29
|
+
|
|
30
|
+
export type RawObjectModel = BaseModel & {
|
|
31
|
+
type: 'raw-object';
|
|
32
|
+
fields: ModelField[];
|
|
33
|
+
rawFilters?: { name: string; type: string; list?: boolean; nonNull?: boolean }[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type JsonObjectModel = BaseModel & {
|
|
37
|
+
type: 'json-object';
|
|
38
|
+
json: true;
|
|
39
|
+
fields: Pick<ModelField, 'type' | 'name' | 'nonNull'>[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- data is derived from the models
|
|
43
|
+
export type Entity = Record<string, any>;
|
|
44
|
+
|
|
45
|
+
export type Action = 'create' | 'update' | 'delete' | 'restore';
|
|
46
|
+
|
|
47
|
+
export type MutationHook = (
|
|
48
|
+
model: Model,
|
|
49
|
+
action: Action,
|
|
50
|
+
when: 'before' | 'after',
|
|
51
|
+
data: { prev: Entity; input: Entity; normalizedInput: Entity; next: Entity },
|
|
52
|
+
ctx: Context
|
|
53
|
+
) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
export type ObjectModel = BaseModel & {
|
|
56
|
+
type: 'object';
|
|
57
|
+
interfaces?: string[];
|
|
58
|
+
// createdAt, createdBy, updatedAt, updatedBy can be null
|
|
59
|
+
nonStrict?: boolean;
|
|
60
|
+
queriable?: boolean;
|
|
61
|
+
listQueriable?: boolean;
|
|
62
|
+
creatable?: boolean;
|
|
63
|
+
updatable?: boolean;
|
|
64
|
+
deletable?: boolean;
|
|
65
|
+
displayField?: string;
|
|
66
|
+
defaultOrderBy?: OrderBy;
|
|
67
|
+
fields: ModelField[];
|
|
68
|
+
|
|
69
|
+
// temporary fields for the generation of migrations
|
|
70
|
+
deleted?: true;
|
|
71
|
+
oldName?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type InputObject = {
|
|
75
|
+
name: string;
|
|
76
|
+
type: string;
|
|
77
|
+
nonNull?: boolean;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const isObjectModel = (model: RawModel): model is ObjectModel => model.type === 'object';
|
|
81
|
+
|
|
82
|
+
export const isEnumModel = (model: RawModel): model is EnumModel => model.type === 'enum';
|
|
83
|
+
|
|
84
|
+
export const isRawEnumModel = (model: RawModel): model is RawEnumModel => model.type === 'raw-enum';
|
|
85
|
+
|
|
86
|
+
export const isScalarModel = (model: RawModel): model is ScalarModel => model.type === 'scalar';
|
|
87
|
+
|
|
88
|
+
export const isRawObjectModel = (model: RawModel): model is RawObjectModel => model.type === 'raw-object';
|
|
89
|
+
|
|
90
|
+
export const isJsonObjectModel = (model: RawModel): model is RawObjectModel => model.type === 'json-object';
|
|
91
|
+
|
|
92
|
+
export const isEnumList = (models: RawModels, field: ModelField) =>
|
|
93
|
+
field?.list === true && models.find(({ name }) => name === field.type)?.type === 'enum';
|
|
94
|
+
|
|
95
|
+
export const and =
|
|
96
|
+
(...predicates: ((field: ModelField) => boolean)[]) =>
|
|
97
|
+
(field: ModelField) =>
|
|
98
|
+
predicates.every((predicate) => predicate(field));
|
|
99
|
+
|
|
100
|
+
export const not = (predicate: (field: ModelField) => boolean) => (field: ModelField) => !predicate(field);
|
|
101
|
+
|
|
102
|
+
export const isRelation = ({ relation }: ModelField) => !!relation;
|
|
103
|
+
|
|
104
|
+
export type VisibleRelationsByRole = Record<string, Record<string, string[]>>;
|
|
105
|
+
|
|
106
|
+
export const isVisibleRelation = (visibleRelationsByRole: VisibleRelationsByRole, modelName: string, role: string) => {
|
|
107
|
+
const whitelist = visibleRelationsByRole[role]?.[modelName];
|
|
108
|
+
return ({ name }: Field) => (whitelist ? whitelist.includes(name) : true);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const isToOneRelation = ({ toOne }: ModelField) => !!toOne;
|
|
112
|
+
|
|
113
|
+
export const isQueriableField = ({ queriable }: ModelField) => queriable !== false;
|
|
114
|
+
|
|
115
|
+
export const isRaw = ({ raw }: ModelField) => !!raw;
|
|
116
|
+
|
|
117
|
+
export const isVisible = ({ hidden }: ModelField) => hidden !== true;
|
|
118
|
+
|
|
119
|
+
export const isSimpleField = and(not(isRelation), not(isRaw));
|
|
120
|
+
|
|
121
|
+
export const isUpdatable = ({ updatable }: ModelField) => !!updatable;
|
|
122
|
+
|
|
123
|
+
export const isCreatable = ({ creatable }: ModelField) => !!creatable;
|
|
124
|
+
|
|
125
|
+
export const isQueriableBy = (role: string) => (field: ModelField) =>
|
|
126
|
+
isQueriableField(field) && (!field.queriableBy || field.queriableBy.includes(role));
|
|
127
|
+
|
|
128
|
+
export const isUpdatableBy = (role: string) => (field: ModelField) =>
|
|
129
|
+
isUpdatable(field) && (!field.updatableBy || field.updatableBy.includes(role));
|
|
130
|
+
|
|
131
|
+
export const isCreatableBy = (role: string) => (field: ModelField) =>
|
|
132
|
+
isCreatable(field) && (!field.creatableBy || field.creatableBy.includes(role));
|
|
133
|
+
|
|
134
|
+
export const actionableRelations = (model: Model, action: 'create' | 'update' | 'filter') =>
|
|
135
|
+
model.fields.filter(
|
|
136
|
+
({ relation, ...field }) =>
|
|
137
|
+
relation &&
|
|
138
|
+
field[`${action === 'filter' ? action : action.slice(0, -1)}able` as 'filterable' | 'creatable' | 'updatable']
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
export type Field = {
|
|
142
|
+
name: string;
|
|
143
|
+
type: string;
|
|
144
|
+
default?: Value;
|
|
145
|
+
list?: boolean;
|
|
146
|
+
nonNull?: boolean;
|
|
147
|
+
args?: Field[];
|
|
148
|
+
directives?: Directive[];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type ModelField = Field & {
|
|
152
|
+
primary?: boolean;
|
|
153
|
+
unique?: boolean;
|
|
154
|
+
filterable?: boolean;
|
|
155
|
+
defaultFilter?: Value;
|
|
156
|
+
searchable?: boolean;
|
|
157
|
+
possibleValues?: Value[];
|
|
158
|
+
orderable?: boolean;
|
|
159
|
+
comparable?: boolean;
|
|
160
|
+
relation?: boolean;
|
|
161
|
+
onDelete?: 'cascade' | 'set-null';
|
|
162
|
+
reverse?: string;
|
|
163
|
+
toOne?: boolean;
|
|
164
|
+
foreignKey?: string;
|
|
165
|
+
queriable?: false;
|
|
166
|
+
queriableBy?: string[];
|
|
167
|
+
creatable?: boolean;
|
|
168
|
+
creatableBy?: string[];
|
|
169
|
+
updatable?: boolean;
|
|
170
|
+
updatableBy?: string[];
|
|
171
|
+
generated?: boolean;
|
|
172
|
+
raw?: boolean;
|
|
173
|
+
json?: boolean;
|
|
174
|
+
dateTimeType?: 'year' | 'date' | 'datetime' | 'year_and_month';
|
|
175
|
+
stringType?: 'email' | 'url' | 'phone';
|
|
176
|
+
floatType?: 'currency' | 'percentage';
|
|
177
|
+
unit?: 'million';
|
|
178
|
+
intType?: 'currency';
|
|
179
|
+
min?: number;
|
|
180
|
+
max?: number;
|
|
181
|
+
// The tooltip is "hidden" behind an icon in the admin forms
|
|
182
|
+
tooltip?: string;
|
|
183
|
+
// The description is always visible below the inputs in the admin forms
|
|
184
|
+
description?: string;
|
|
185
|
+
large?: true;
|
|
186
|
+
maxLength?: number;
|
|
187
|
+
double?: boolean;
|
|
188
|
+
precision?: number;
|
|
189
|
+
scale?: number;
|
|
190
|
+
defaultValue?: string | number | ReadonlyArray<string> | undefined;
|
|
191
|
+
endOfDay?: boolean;
|
|
192
|
+
obfuscate?: true;
|
|
193
|
+
// If true the field must be filled within forms but can be null in the database
|
|
194
|
+
required?: boolean;
|
|
195
|
+
indent?: boolean;
|
|
196
|
+
// If true the field is hidden in the admin interface
|
|
197
|
+
hidden?: boolean;
|
|
198
|
+
|
|
199
|
+
// temporary fields for the generation of migrations
|
|
200
|
+
deleted?: true;
|
|
201
|
+
oldName?: string;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export type Models = Model[];
|
|
205
|
+
|
|
206
|
+
export type Model = ObjectModel & {
|
|
207
|
+
fieldsByName: Record<string, ModelField>;
|
|
208
|
+
relations: Relation[];
|
|
209
|
+
relationsByName: Record<string, Relation>;
|
|
210
|
+
reverseRelations: ReverseRelation[];
|
|
211
|
+
reverseRelationsByName: Record<string, ReverseRelation>;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export type Relation = {
|
|
215
|
+
field: ModelField;
|
|
216
|
+
model: Model;
|
|
217
|
+
reverseRelation: ReverseRelation;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export type ReverseRelation = {
|
|
221
|
+
name: string;
|
|
222
|
+
type: string;
|
|
223
|
+
foreignKey: string;
|
|
224
|
+
toOne: boolean;
|
|
225
|
+
model: Model;
|
|
226
|
+
field: ModelField;
|
|
227
|
+
fieldModel: Model;
|
|
228
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Knex } from 'knex';
|
|
2
|
+
import { FullContext } from '../context';
|
|
3
|
+
import { NotFoundError, PermissionError } from '../errors';
|
|
4
|
+
import { Model } from '../models';
|
|
5
|
+
import { AliasGenerator, hash, ors } from '../resolvers/utils';
|
|
6
|
+
import { get, getModelPlural, summonByName } from '../utils';
|
|
7
|
+
import { BasicValue } from '../values';
|
|
8
|
+
import { PermissionAction, PermissionLink, PermissionStack } from './generate';
|
|
9
|
+
|
|
10
|
+
export const getPermissionStack = (
|
|
11
|
+
ctx: Pick<FullContext, 'permissions' | 'user'>,
|
|
12
|
+
type: string,
|
|
13
|
+
action: PermissionAction
|
|
14
|
+
): boolean | PermissionStack => {
|
|
15
|
+
const rolePermissions = ctx.permissions[ctx.user.role];
|
|
16
|
+
if (typeof rolePermissions === 'boolean' || rolePermissions === undefined) {
|
|
17
|
+
return !!rolePermissions;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const typePermissions = rolePermissions[type];
|
|
21
|
+
if (typeof typePermissions === 'boolean' || typePermissions === undefined) {
|
|
22
|
+
return !!typePermissions;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const actionPermission = typePermissions[action];
|
|
26
|
+
if (typeof actionPermission === 'boolean' || actionPermission === undefined) {
|
|
27
|
+
return !!actionPermission;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return actionPermission;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const applyPermissions = (
|
|
34
|
+
ctx: Pick<FullContext, 'models' | 'permissions' | 'user' | 'knex'>,
|
|
35
|
+
type: string,
|
|
36
|
+
tableAlias: string,
|
|
37
|
+
query: Knex.QueryBuilder,
|
|
38
|
+
action: PermissionAction,
|
|
39
|
+
verifiedPermissionStack?: PermissionStack
|
|
40
|
+
): boolean | PermissionStack => {
|
|
41
|
+
const permissionStack = getPermissionStack(ctx, type, action);
|
|
42
|
+
|
|
43
|
+
if (permissionStack === true) {
|
|
44
|
+
return permissionStack;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (permissionStack === false) {
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
49
|
+
query.where(false);
|
|
50
|
+
return permissionStack;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
verifiedPermissionStack?.every((prefixChain) =>
|
|
55
|
+
permissionStack.some(
|
|
56
|
+
(chain) =>
|
|
57
|
+
hash(prefixChain) === hash(chain.slice(0, -1)) &&
|
|
58
|
+
// TODO: this is stricter than it could be if we add these checks to the query
|
|
59
|
+
!('where' in get(chain, chain.length - 1)) &&
|
|
60
|
+
!('me' in get(chain, chain.length - 1))
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
) {
|
|
64
|
+
// The user has access to a parent entity with one or more from a set of rules, all of which are inherited by this entity
|
|
65
|
+
// No need for additional checks
|
|
66
|
+
return permissionStack;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
70
|
+
ors(
|
|
71
|
+
query,
|
|
72
|
+
permissionStack.map(
|
|
73
|
+
(links) => (query) =>
|
|
74
|
+
query
|
|
75
|
+
.whereNull(`${tableAlias}.id`)
|
|
76
|
+
.orWhereExists((subQuery) => permissionLinkQuery(ctx, subQuery, links, ctx.knex.raw(`"${tableAlias}".id`)))
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return permissionStack;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check whether entity as currently in db can be mutated (update or delete)
|
|
85
|
+
*/
|
|
86
|
+
export const getEntityToMutate = async (
|
|
87
|
+
ctx: Pick<FullContext, 'models' | 'permissions' | 'user' | 'knex'>,
|
|
88
|
+
model: Model,
|
|
89
|
+
where: Record<string, BasicValue>,
|
|
90
|
+
action: 'UPDATE' | 'DELETE' | 'RESTORE'
|
|
91
|
+
) => {
|
|
92
|
+
const query = ctx.knex(model.name).where(where).first();
|
|
93
|
+
let entity = await query.clone();
|
|
94
|
+
|
|
95
|
+
if (!entity) {
|
|
96
|
+
throw new NotFoundError('Entity to mutate');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
applyPermissions(ctx, model.name, model.name, query, action);
|
|
100
|
+
entity = await query;
|
|
101
|
+
if (!entity) {
|
|
102
|
+
throw new PermissionError(action, `this ${model.name}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return entity;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check whether given data can be written to db (insert or update)
|
|
110
|
+
*/
|
|
111
|
+
export const checkCanWrite = async (
|
|
112
|
+
ctx: Pick<FullContext, 'models' | 'permissions' | 'user' | 'knex'>,
|
|
113
|
+
model: Model,
|
|
114
|
+
data: Record<string, BasicValue>,
|
|
115
|
+
action: 'CREATE' | 'UPDATE'
|
|
116
|
+
) => {
|
|
117
|
+
const permissionStack = getPermissionStack(ctx, model.name, action);
|
|
118
|
+
|
|
119
|
+
if (permissionStack === true) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (permissionStack === false) {
|
|
123
|
+
throw new PermissionError(action, getModelPlural(model));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- using `select(1 as any)` to instantiate an "empty" query builder
|
|
127
|
+
const query = ctx.knex.select(1 as any).first();
|
|
128
|
+
let linked = false;
|
|
129
|
+
|
|
130
|
+
for (const field of model.fields
|
|
131
|
+
.filter(({ relation }) => relation)
|
|
132
|
+
.filter((field) => field.generated || (action === 'CREATE' ? field.creatable : field.updatable))) {
|
|
133
|
+
const foreignKey = field.foreignKey || `${field.name}Id`;
|
|
134
|
+
const foreignId = data[foreignKey] as string;
|
|
135
|
+
if (!foreignId) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fieldPermissions = field[action === 'CREATE' ? 'creatableBy' : 'updatableBy'];
|
|
140
|
+
if (fieldPermissions && !fieldPermissions.includes(ctx.user.role)) {
|
|
141
|
+
throw new PermissionError(action, `this ${model.name}'s ${field.name}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
linked = true;
|
|
145
|
+
|
|
146
|
+
const fieldPermissionStack = getPermissionStack(ctx, field.type, 'LINK');
|
|
147
|
+
|
|
148
|
+
if (fieldPermissionStack === true) {
|
|
149
|
+
// User can link any entity from this type, just check whether it exists
|
|
150
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
151
|
+
query.whereExists((subQuery) => subQuery.from(`${field.type} as a`).whereRaw(`a.id = ?`, foreignId));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (fieldPermissionStack === false || !fieldPermissionStack.length) {
|
|
156
|
+
throw new PermissionError(action, `this ${model.name}'s ${field.name}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
160
|
+
ors(
|
|
161
|
+
query,
|
|
162
|
+
fieldPermissionStack.map(
|
|
163
|
+
(links) => (query) => query.whereExists((subQuery) => permissionLinkQuery(ctx, subQuery, links, foreignId))
|
|
164
|
+
)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (linked) {
|
|
169
|
+
const canMutate = await query;
|
|
170
|
+
if (!canMutate) {
|
|
171
|
+
throw new PermissionError(action, `this ${model.name} because there are no entities you can link it to`);
|
|
172
|
+
}
|
|
173
|
+
} else if (action === 'CREATE') {
|
|
174
|
+
throw new PermissionError(action, `this ${model.name} because there are no entity types you can link it to`);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const permissionLinkQuery = (
|
|
179
|
+
ctx: Pick<FullContext, 'models' | 'user'>,
|
|
180
|
+
subQuery: Knex.QueryBuilder,
|
|
181
|
+
links: PermissionLink[],
|
|
182
|
+
id: Knex.RawBinding | Knex.ValueDict
|
|
183
|
+
) => {
|
|
184
|
+
const aliases = new AliasGenerator();
|
|
185
|
+
let alias = aliases.getShort();
|
|
186
|
+
const { type, me, where } = links[0]!;
|
|
187
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
188
|
+
subQuery.from(`${type} as ${alias}`);
|
|
189
|
+
if (me) {
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
191
|
+
subQuery.where({ [`${alias}.id`]: ctx.user.id });
|
|
192
|
+
}
|
|
193
|
+
if (where) {
|
|
194
|
+
applyWhere(summonByName(ctx.models, type), subQuery, alias, where, aliases);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const { type, foreignKey, reverse, where } of links) {
|
|
198
|
+
const model = summonByName(ctx.models, type);
|
|
199
|
+
const subAlias = aliases.getShort();
|
|
200
|
+
if (reverse) {
|
|
201
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
202
|
+
subQuery.leftJoin(`${type} as ${subAlias}`, `${alias}.${foreignKey || 'id'}`, `${subAlias}.id`);
|
|
203
|
+
} else {
|
|
204
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
205
|
+
subQuery.rightJoin(`${type} as ${subAlias}`, `${alias}.id`, `${subAlias}.${foreignKey || 'id'}`);
|
|
206
|
+
}
|
|
207
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
208
|
+
subQuery.where({ [`${subAlias}.deleted`]: false });
|
|
209
|
+
if (where) {
|
|
210
|
+
applyWhere(model, subQuery, subAlias, where, aliases);
|
|
211
|
+
}
|
|
212
|
+
alias = subAlias;
|
|
213
|
+
}
|
|
214
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
215
|
+
subQuery.whereRaw(`"${alias}".id = ?`, id);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const applyWhere = (model: Model, query: Knex.QueryBuilder, alias: string, where: any, aliases: AliasGenerator) => {
|
|
219
|
+
for (const [key, value] of Object.entries(where)) {
|
|
220
|
+
const relation = model.relationsByName[key];
|
|
221
|
+
|
|
222
|
+
if (relation) {
|
|
223
|
+
const subAlias = aliases.getShort();
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
225
|
+
query.leftJoin(
|
|
226
|
+
`${relation.model.name} as ${subAlias}`,
|
|
227
|
+
`${alias}.${relation.field.foreignKey || `${relation.field.name}Id`}`,
|
|
228
|
+
`${subAlias}.id`
|
|
229
|
+
);
|
|
230
|
+
applyWhere(relation.model, query, subAlias, value, aliases);
|
|
231
|
+
} else if (Array.isArray(value)) {
|
|
232
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
233
|
+
query.whereIn(`${alias}.${key}`, value);
|
|
234
|
+
} else {
|
|
235
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- we do not need to await knex here
|
|
236
|
+
query.where({ [`${alias}.${key}`]: value });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Models } from '../models';
|
|
2
|
+
import { summonByName } from '../utils';
|
|
3
|
+
|
|
4
|
+
export type PermissionAction = 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'RESTORE' | 'LINK';
|
|
5
|
+
|
|
6
|
+
const ACTIONS: PermissionAction[] = ['READ', 'CREATE', 'UPDATE', 'DELETE', 'RESTORE', 'LINK'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Initial representation (tree structure, as defined by user).
|
|
10
|
+
*/
|
|
11
|
+
export type PermissionsConfig = {
|
|
12
|
+
[role: string]:
|
|
13
|
+
| true
|
|
14
|
+
| {
|
|
15
|
+
[type: string]: PermissionsBlock;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type PermissionsBlock = {
|
|
20
|
+
[action in PermissionAction]?: true;
|
|
21
|
+
} & {
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
WHERE?: Record<string, any>;
|
|
24
|
+
RELATIONS?: {
|
|
25
|
+
[relation: string]: PermissionsBlock;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Final representation (lookup table (role, model, action) -> permission stack).
|
|
31
|
+
*/
|
|
32
|
+
export type Permissions = {
|
|
33
|
+
[role: string]: true | RolePermissions;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type RolePermissions = {
|
|
37
|
+
[type: string]: {
|
|
38
|
+
[action in PermissionAction]?: true | PermissionStack;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* For a given role, model and action,
|
|
44
|
+
* this represents the list of potential (join) paths
|
|
45
|
+
* that would grant permission to perform that action.
|
|
46
|
+
*/
|
|
47
|
+
export type PermissionStack = PermissionChain[];
|
|
48
|
+
|
|
49
|
+
export type PermissionChain = PermissionLink[];
|
|
50
|
+
|
|
51
|
+
export type PermissionLink = {
|
|
52
|
+
type: string;
|
|
53
|
+
foreignKey?: string;
|
|
54
|
+
reverse?: boolean;
|
|
55
|
+
me?: boolean;
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
where?: any;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const generatePermissions = (models: Models, config: PermissionsConfig) => {
|
|
61
|
+
const permissions: Permissions = {};
|
|
62
|
+
for (const [role, roleConfig] of Object.entries(config)) {
|
|
63
|
+
if (roleConfig === true) {
|
|
64
|
+
permissions[role] = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const rolePermissions: RolePermissions = {};
|
|
68
|
+
for (const [key, block] of Object.entries(roleConfig)) {
|
|
69
|
+
const type = key === 'me' ? 'User' : key;
|
|
70
|
+
if (key !== 'me' && !('WHERE' in block)) {
|
|
71
|
+
rolePermissions[type] = {};
|
|
72
|
+
for (const action of ACTIONS) {
|
|
73
|
+
if (action === 'READ' || action in block) {
|
|
74
|
+
rolePermissions[type]![action] = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
addPermissions(
|
|
79
|
+
models,
|
|
80
|
+
rolePermissions,
|
|
81
|
+
[
|
|
82
|
+
{
|
|
83
|
+
type,
|
|
84
|
+
...(key === 'me' && { me: true }),
|
|
85
|
+
...('WHERE' in block && { where: block.WHERE }),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
block
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
permissions[role] = rolePermissions;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return permissions;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const addPermissions = (models: Models, permissions: RolePermissions, links: PermissionLink[], block: PermissionsBlock) => {
|
|
98
|
+
const { type } = links[links.length - 1]!;
|
|
99
|
+
const model = summonByName(models, type);
|
|
100
|
+
|
|
101
|
+
for (const action of ACTIONS) {
|
|
102
|
+
if (action === 'READ' || action in block) {
|
|
103
|
+
if (!permissions[type]) {
|
|
104
|
+
permissions[type] = {};
|
|
105
|
+
}
|
|
106
|
+
if (!permissions[type]![action]) {
|
|
107
|
+
permissions[type]![action] = [];
|
|
108
|
+
}
|
|
109
|
+
if (permissions[type]![action] !== true) {
|
|
110
|
+
(permissions[type]![action] as PermissionStack).push(links);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (block.RELATIONS) {
|
|
116
|
+
for (const [relation, subBlock] of Object.entries(block.RELATIONS)) {
|
|
117
|
+
const field = model.fields.find((field) => field.relation && field.name === relation);
|
|
118
|
+
let link: PermissionLink;
|
|
119
|
+
if (field) {
|
|
120
|
+
link = {
|
|
121
|
+
type: field.type,
|
|
122
|
+
foreignKey: field.foreignKey || `${field.name}Id`,
|
|
123
|
+
reverse: true,
|
|
124
|
+
};
|
|
125
|
+
} else {
|
|
126
|
+
const field = model.reverseRelationsByName[relation]!;
|
|
127
|
+
|
|
128
|
+
if (!field) {
|
|
129
|
+
throw new Error(`Relation ${relation} in model ${model.name} does not exist.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
link = {
|
|
133
|
+
type: field.model.name,
|
|
134
|
+
foreignKey: field.foreignKey,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (subBlock.WHERE) {
|
|
138
|
+
link.where = subBlock.WHERE;
|
|
139
|
+
}
|
|
140
|
+
addPermissions(models, permissions, [...links, link], subBlock);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|