Skip to content

Commit edb7fda

Browse files
authored
feat(schema): add support for resource permissions (#514)
As part of the [Actor permissions project](https://www.notion.so/apify/Public-Actor-permissions-design-document-1d1f39950a228015a679f276104123cb), we want to allow Actors to request access to resources via schema. - The Actor input schema already supports annotating string fields with a `resourceType` to specify that the field should reference an Apify platform resource (practically, a storage, such as a dataset). - This PR extends the input schema so that the Actor can not only specify that a certain string is actually a storage ID, but also what kind of access to the storage the Actor will require (read, or also write). For that reason, the PR adds a new property `resourcePermissions`. - We will then use this information both to communicate this Actor requirement to the user, and to correctly configure the access when running the Actor. Example input schema configuration: ```json { "title": "Dataset", "type": "string", "description": "Select a dataset that you want to process", "resourceType": "dataset", "resourcePermissions": ["READ", "WRITE"], } ``` The `resourcePermissions` property follows the same permission format as we use elsewhere in Apify. The Actor developer will be able to specify: - Nothing → No access at all (works only for full permission Actors). - `["READ"]` → The Actor will be able to read the storage. - `["READ", "WRITE"]` → The Actor will be able to write the storage as well. We don't support just `["WRITE"]`, which is enforced via schema validation. Later we might choose to introduce "append only" storages. To address a potential security loophole, the validation also forbids the use of `default` and `prefill` if `resourcePermissions` is set. This is not a breaking change as existing schemas will work just fine until the developer decides to provide `resourcePermissions`. For additional context refer to the [design doc](https://www.notion.so/apify/Public-Actor-permissions-design-document-1d1f39950a228015a679f276104123cb).
1 parent feb1be3 commit edb7fda

File tree

3 files changed

+268
-2
lines changed

3 files changed

+268
-2
lines changed

packages/input_schema/src/schema.json

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,14 +308,41 @@
308308
"description": { "type": "string" },
309309
"editor": { "enum": ["resourcePicker", "hidden"] },
310310
"resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] },
311+
"resourcePermissions": {
312+
"type": "array",
313+
"items": {
314+
"type": "string",
315+
"enum": ["READ", "WRITE"]
316+
},
317+
"minItems": 1,
318+
"uniqueItems": true,
319+
"contains": {
320+
"const": "READ"
321+
}
322+
},
311323
"default": { "type": "string" },
312324
"prefill": { "type": "string" },
313325
"example": { "type": "string" },
314326
"nullable": { "type": "boolean" },
315327
"sectionCaption": { "type": "string" },
316328
"sectionDescription": { "type": "string" }
317329
},
318-
"required": ["type", "title", "description", "resourceType"]
330+
"required": ["type", "title", "description", "resourceType"],
331+
"allOf": [
332+
{
333+
"if": {
334+
"required": ["resourcePermissions"]
335+
},
336+
"then": {
337+
"not": {
338+
"anyOf": [
339+
{ "required": ["prefill"] },
340+
{ "required": ["default"] }
341+
]
342+
}
343+
}
344+
}
345+
]
319346
},
320347
"resourceArrayProperty": {
321348
"title": "Resource array property",
@@ -326,6 +353,18 @@
326353
"title": { "type": "string" },
327354
"description": { "type": "string" },
328355
"editor": { "enum": ["resourcePicker", "hidden"] },
356+
"resourcePermissions": {
357+
"type": "array",
358+
"items": {
359+
"type": "string",
360+
"enum": ["READ", "WRITE"]
361+
},
362+
"minItems": 1,
363+
"uniqueItems": true,
364+
"contains": {
365+
"const": "READ"
366+
}
367+
},
329368
"default": { "type": "array" },
330369
"prefill": { "type": "array" },
331370
"example": { "type": "array" },
@@ -337,7 +376,22 @@
337376
"sectionCaption": { "type": "string" },
338377
"sectionDescription": { "type": "string" }
339378
},
340-
"required": ["type", "title", "description", "resourceType"]
379+
"required": ["type", "title", "description", "resourceType"],
380+
"allOf": [
381+
{
382+
"if": {
383+
"required": ["resourcePermissions"]
384+
},
385+
"then": {
386+
"not": {
387+
"anyOf": [
388+
{ "required": ["prefill"] },
389+
{ "required": ["default"] }
390+
]
391+
}
392+
}
393+
}
394+
]
341395
},
342396
"anyProperty": {
343397
"title": "Any property",

packages/input_schema/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export type ArrayFieldDefinition = CommonFieldDefinition<unknown[]> & {
6262
export type CommonResourceFieldDefinition<T> = CommonFieldDefinition<T> & {
6363
editor?: 'resourcePicker' | 'hidden';
6464
resourceType: 'dataset' | 'keyValueStore' | 'requestQueue';
65+
resourcePermissions?: ('READ' | 'WRITE')[];
6566
}
6667

6768
export type ResourceFieldDefinition = CommonResourceFieldDefinition<string> & {

test/input_schema.test.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,42 @@ describe('input_schema.json', () => {
287287
});
288288

289289
describe('special cases for resourceProperty', () => {
290+
it('should accept valid resourceType', () => {
291+
const schema = {
292+
title: 'Test input schema',
293+
type: 'object',
294+
schemaVersion: 1,
295+
properties: {
296+
myField: {
297+
title: 'Field title',
298+
description: 'My test field',
299+
type: 'string',
300+
resourceType: 'keyValueStore',
301+
prefill: 'test',
302+
default: 'test',
303+
},
304+
},
305+
};
306+
expect(() => validateInputSchema(validator, schema)).not.toThrow();
307+
308+
const schema2 = {
309+
title: 'Test input schema',
310+
type: 'object',
311+
schemaVersion: 1,
312+
properties: {
313+
myFieldArray: {
314+
title: 'Field title',
315+
description: 'My test field',
316+
type: 'array',
317+
resourceType: 'keyValueStore',
318+
prefill: [],
319+
default: [],
320+
},
321+
},
322+
};
323+
expect(() => validateInputSchema(validator, schema2)).not.toThrow();
324+
});
325+
290326
it('should not accept invalid resourceType', () => {
291327
const schema = {
292328
title: 'Test input schema',
@@ -326,6 +362,181 @@ describe('input_schema.json', () => {
326362
'Input schema is not valid (Field schema.properties.myField.editor must be equal to one of the allowed values: "resourcePicker", "hidden")',
327363
);
328364
});
365+
366+
it('should accept valid resourcePermissions', () => {
367+
const schema = {
368+
title: 'Test input schema',
369+
type: 'object',
370+
schemaVersion: 1,
371+
properties: {
372+
myField: {
373+
title: 'Field title',
374+
description: 'My test field',
375+
type: 'string',
376+
resourceType: 'keyValueStore',
377+
resourcePermissions: ['READ'],
378+
},
379+
},
380+
};
381+
validateInputSchema(validator, schema);
382+
383+
const schema2 = {
384+
title: 'Test input schema',
385+
type: 'object',
386+
schemaVersion: 1,
387+
properties: {
388+
myFieldArray: {
389+
title: 'Field title',
390+
description: 'My test field',
391+
type: 'array',
392+
resourceType: 'keyValueStore',
393+
resourcePermissions: ['READ', 'WRITE'],
394+
},
395+
},
396+
};
397+
validateInputSchema(validator, schema2);
398+
});
399+
400+
it('should not accept invalid resourcePermissions values', () => {
401+
const schema = {
402+
title: 'Test input schema',
403+
type: 'object',
404+
schemaVersion: 1,
405+
properties: {
406+
myField: {
407+
title: 'Field title',
408+
description: 'My test field',
409+
type: 'string',
410+
resourceType: 'keyValueStore',
411+
resourcePermissions: ['INVALID'],
412+
},
413+
},
414+
};
415+
expect(() => validateInputSchema(validator, schema)).toThrow(
416+
'Input schema is not valid (Field schema.properties.myField.0 must be equal to one of the allowed values: "READ", "WRITE")',
417+
);
418+
419+
const schema2 = {
420+
title: 'Test input schema',
421+
type: 'object',
422+
schemaVersion: 1,
423+
properties: {
424+
myFieldArray: {
425+
title: 'Field title',
426+
description: 'My test field',
427+
type: 'array',
428+
resourceType: 'keyValueStore',
429+
resourcePermissions: ['INVALID'],
430+
},
431+
},
432+
};
433+
expect(() => validateInputSchema(validator, schema2)).toThrow(
434+
'Input schema is not valid (Field schema.properties.myFieldArray.0 must be equal to one of the allowed values: "READ", "WRITE")',
435+
);
436+
});
437+
438+
it('should not accept empty resourcePermissions array', () => {
439+
const schema = {
440+
title: 'Test input schema',
441+
type: 'object',
442+
schemaVersion: 1,
443+
properties: {
444+
myField: {
445+
title: 'Field title',
446+
description: 'My test field',
447+
type: 'string',
448+
resourceType: 'keyValueStore',
449+
resourcePermissions: [],
450+
},
451+
},
452+
};
453+
expect(() => validateInputSchema(validator, schema)).toThrow(
454+
'Input schema is not valid (Field schema.properties.myField.resourcePermissions must NOT have fewer than 1 items)',
455+
);
456+
});
457+
458+
it('should not accept resourcePermissions with prefill', () => {
459+
const schema = {
460+
title: 'Test input schema',
461+
type: 'object',
462+
schemaVersion: 1,
463+
properties: {
464+
myField: {
465+
title: 'Field title',
466+
description: 'My test field',
467+
type: 'string',
468+
resourceType: 'keyValueStore',
469+
resourcePermissions: ['READ'],
470+
prefill: 'some-value',
471+
},
472+
},
473+
};
474+
475+
expect(() => validateInputSchema(validator, schema)).toThrow(
476+
'Input schema is not valid (Field schema.properties.myField. must NOT be valid)',
477+
);
478+
479+
const schema2 = {
480+
title: 'Test input schema',
481+
type: 'object',
482+
schemaVersion: 1,
483+
properties: {
484+
myField: {
485+
title: 'Field title',
486+
description: 'My test field',
487+
type: 'array',
488+
resourceType: 'keyValueStore',
489+
resourcePermissions: ['READ'],
490+
prefill: [],
491+
},
492+
},
493+
};
494+
495+
expect(() => validateInputSchema(validator, schema2)).toThrow(
496+
'Input schema is not valid (Field schema.properties.myField. must NOT be valid)',
497+
);
498+
});
499+
500+
it('should not accept resourcePermissions with default', () => {
501+
const schema = {
502+
title: 'Test input schema',
503+
type: 'object',
504+
schemaVersion: 1,
505+
properties: {
506+
myField: {
507+
title: 'Field title',
508+
description: 'My test field',
509+
type: 'string',
510+
resourceType: 'keyValueStore',
511+
resourcePermissions: ['READ'],
512+
default: 'some-value',
513+
},
514+
},
515+
};
516+
expect(() => validateInputSchema(validator, schema)).toThrow(
517+
'Input schema is not valid (Field schema.properties.myField. must NOT be valid)',
518+
);
519+
});
520+
521+
it('should not accept resourcePermissions without READ', () => {
522+
const schema = {
523+
title: 'Test input schema',
524+
type: 'object',
525+
schemaVersion: 1,
526+
properties: {
527+
myField: {
528+
title: 'Field title',
529+
description: 'My test field',
530+
type: 'string',
531+
resourceType: 'keyValueStore',
532+
resourcePermissions: ['WRITE'],
533+
},
534+
},
535+
};
536+
expect(() => validateInputSchema(validator, schema)).toThrow(
537+
'Input schema is not valid (Field schema.properties.myField.resourcePermissions must contain at least 1 valid item(s))',
538+
);
539+
});
329540
});
330541
});
331542
});

0 commit comments

Comments
 (0)