Browse Source

Merge pull request #2 from Lezek123/cli-entities-fixes

CLI - entity commands permissions fixes and code cleanup
DzhideX 4 years ago
parent
commit
ddcb18de77

+ 62 - 30
cli/src/base/ContentDirectoryCommandBase.ts

@@ -12,6 +12,7 @@ import {
   EntityId,
   Actor,
   PropertyType,
+  Property,
 } from '@joystream/types/content-directory'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
@@ -248,7 +249,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
 
   async getAndParseKnownEntity<T>(id: string | number, className?: string): Promise<FlattenRelations<T>> {
     const entity = await this.getEntity(id, className)
-    return this.parseToKnownEntityJson<T>(entity)
+    return this.parseToEntityJson<T>(entity)
   }
 
   async entitiesByClassAndOwner(classNameOrId: number | string, ownerMemberId?: number): Promise<[EntityId, Entity][]> {
@@ -340,7 +341,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     }, {} as Record<string, ParsedPropertyValue>)
   }
 
-  async parseToKnownEntityJson<T>(entity: Entity): Promise<FlattenRelations<T>> {
+  async parseToEntityJson<T = unknown>(entity: Entity): Promise<FlattenRelations<T>> {
     const entityClass = (await this.classEntryByNameOrId(entity.class_id.toString()))[1]
     return (_.mapValues(this.parseEntityPropertyValues(entity, entityClass), (v) =>
       this.parseStoredPropertyInnerValue(v.value)
@@ -381,12 +382,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
   async getActor(context: typeof CONTEXTS[number], pickedClass: Class) {
     let actor: Actor
     if (context === 'Member') {
-      if (pickedClass.class_permissions.any_member.isFalse) {
-        this.error(`You're not allowed to createEntity of className: ${pickedClass.name.toString()}!`)
-      }
-
       const memberId = await this.getRequiredMemberId()
-
       actor = this.createType('Actor', { Member: memberId })
     } else if (context === 'Curator') {
       actor = await this.getCuratorContext([pickedClass.name.toString()])
@@ -399,45 +395,81 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     return actor
   }
 
-  getQuestionsFromClass = (
-    classData: Class,
-    defaults?: { [key: string]: { 'value': unknown; locked: boolean } }
-  ): DistinctQuestion[] => {
-    return classData.properties.reduce((previousValue, { name, property_type: propertyType, required }, index) => {
+  isActorEntityController(actor: Actor, entity: Entity, isMaintainer: boolean): boolean {
+    const entityController = entity.entity_permissions.controller
+    return (
+      (isMaintainer && entityController.isOfType('Maintainers')) ||
+      (entityController.isOfType('Member') &&
+        actor.isOfType('Member') &&
+        entityController.asType('Member').eq(actor.asType('Member'))) ||
+      (entityController.isOfType('Lead') && actor.isOfType('Lead'))
+    )
+  }
+
+  async isEntityPropertyEditableByActor(entity: Entity, classPropertyId: number, actor: Actor): Promise<boolean> {
+    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
+
+    const isActorMaintainer =
+      actor.isOfType('Curator') &&
+      entityClass.class_permissions.maintainers.toArray().some((groupId) => groupId.eq(actor.asType('Curator')[0]))
+
+    const isActorController = this.isActorEntityController(actor, entity, isActorMaintainer)
+
+    const {
+      is_locked_from_controller: isLockedFromController,
+      is_locked_from_maintainer: isLockedFromMaintainer,
+    } = entityClass.properties[classPropertyId].locking_policy
+
+    return (
+      (isActorController && !isLockedFromController.valueOf()) ||
+      (isActorMaintainer && !isLockedFromMaintainer.valueOf())
+    )
+  }
+
+  getQuestionsFromProperties(properties: Property[], defaults?: { [key: string]: unknown }): DistinctQuestion[] {
+    return properties.reduce((previousValue, { name, property_type: propertyType, required }) => {
       const propertySubtype = propertyType.subtype
       const questionType = propertySubtype === 'Bool' ? 'list' : 'input'
       const isSubtypeNumber = propertySubtype.toLowerCase().includes('int')
       const isSubtypeReference = propertyType.isOfType('Single') && propertyType.asType('Single').isOfType('Reference')
-      const isQuestionLocked = defaults?.[Object.keys(defaults)[index]].locked
 
-      if (isQuestionLocked) {
-        return previousValue
+      const validate = async (answer: string | number | null) => {
+        if (answer === null) {
+          return true // Can only happen through "filter" if property is not required
+        }
+
+        if ((isSubtypeNumber || isSubtypeReference) && parseInt(answer.toString()).toString() !== answer.toString()) {
+          return `Expected integer value!`
+        }
+
+        if (isSubtypeReference) {
+          try {
+            await this.getEntity(+answer, propertyType.asType('Single').asType('Reference')[0].toString())
+          } catch (e) {
+            return e.message || JSON.stringify(e)
+          }
+        }
+
+        return true
       }
 
       const optionalQuestionProperties = {
         ...{
-          filter: (answer: string) => {
+          filter: async (answer: string) => {
             if (required.isFalse && !answer) {
               return null
             }
 
-            if ((isSubtypeNumber || isSubtypeReference) && isFinite(+answer)) {
-              return +answer
+            // Only cast to number if valid
+            // Prevents inquirer bug not allowing to edit invalid values when casted to number
+            // See: https://github.com/SBoudrias/Inquirer.js/issues/866
+            if ((isSubtypeNumber || isSubtypeReference) && (await validate(answer)) === true) {
+              return parseInt(answer)
             }
 
             return answer
           },
-          validate: async (answer: string | null) => {
-            if (answer && isSubtypeReference && isFinite(+answer)) {
-              const { class_id: classId } = await this.getEntity(+answer)
-
-              if (classId.toString() !== propertyType.asType('Single').asType('Reference')[0].toString()) {
-                return 'This entity is not of the right class id!'
-              }
-            }
-
-            return true
-          },
+          validate,
         },
         ...(propertySubtype === 'Bool' && {
           choices: ['true', 'false'],
@@ -460,7 +492,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
           type: questionType,
           ...optionalQuestionProperties,
           ...(defaults && {
-            default: defaults[Object.keys(defaults)[index]].value,
+            default: propertySubtype === 'Bool' ? JSON.stringify(defaults[name.toString()]) : defaults[name.toString()],
           }),
         },
       ]

+ 1 - 1
cli/src/base/MediaCommandBase.ts

@@ -23,7 +23,7 @@ export default abstract class MediaCommandBase extends ContentDirectoryCommandBa
     })
     if (licenseType === 'known') {
       const [id, knownLicenseEntity] = await this.promptForEntityEntry('Choose License', 'KnownLicense', 'code')
-      const knownLicense = await this.parseToKnownEntityJson<KnownLicenseEntity>(knownLicenseEntity)
+      const knownLicense = await this.parseToEntityJson<KnownLicenseEntity>(knownLicenseEntity)
       licenseInput = { knownLicense: id.toNumber() }
       if (knownLicense.attributionRequired) {
         licenseInput.attribution = await this.simplePrompt({ message: 'Attribution' })

+ 11 - 2
cli/src/commands/content-directory/createEntity.ts

@@ -1,6 +1,7 @@
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import inquirer from 'inquirer'
 import { InputParser } from '@joystream/cd-schemas'
+import ExitCodes from '../../ExitCodes'
 
 export default class CreateEntityCommand extends ContentDirectoryCommandBase {
   static description =
@@ -20,7 +21,11 @@ export default class CreateEntityCommand extends ContentDirectoryCommandBase {
 
   async run() {
     const { className } = this.parse(CreateEntityCommand).args
-    const { context } = this.parse(CreateEntityCommand).flags
+    let { context } = this.parse(CreateEntityCommand).flags
+
+    if (!context) {
+      context = await this.promptForContext()
+    }
 
     const currentAccount = await this.getRequiredSelectedAccount()
     await this.requestAccountDecoding(currentAccount)
@@ -28,9 +33,13 @@ export default class CreateEntityCommand extends ContentDirectoryCommandBase {
 
     const actor = await this.getActor(context, entityClass)
 
+    if (actor.isOfType('Member') && entityClass.class_permissions.any_member.isFalse) {
+      this.error('Choosen actor has no access to create an entity of this type', { exit: ExitCodes.AccessDenied })
+    }
+
     const answers: {
       [key: string]: string | number | null
-    } = await inquirer.prompt(this.getQuestionsFromClass(entityClass))
+    } = await inquirer.prompt(this.getQuestionsFromProperties(entityClass.properties.toArray()))
 
     this.jsonPrettyPrint(JSON.stringify(answers))
     await this.requireConfirmation('Do you confirm the provided input?')

+ 3 - 15
cli/src/commands/content-directory/removeEntity.ts

@@ -1,6 +1,5 @@
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import { Actor } from '@joystream/types/content-directory'
-import { createType } from '@joystream/types'
 import ExitCodes from '../../ExitCodes'
 
 export default class RemoveEntityCommand extends ContentDirectoryCommandBase {
@@ -31,20 +30,9 @@ export default class RemoveEntityCommand extends ContentDirectoryCommandBase {
     }
 
     const account = await this.getRequiredSelectedAccount()
-    let actor: Actor
-    if (context === 'Curator') {
-      actor = await this.getCuratorContext([entityClass.name.toString()])
-    } else if (context === 'Member') {
-      const memberId = await this.getRequiredMemberId()
-      if (
-        !entity.entity_permissions.controller.isOfType('Member') ||
-        entity.entity_permissions.controller.asType('Member').toNumber() !== memberId
-      ) {
-        this.error('You are not the entity controller!', { exit: ExitCodes.AccessDenied })
-      }
-      actor = createType('Actor', { Member: memberId })
-    } else {
-      actor = createType('Actor', { Lead: null })
+    const actor: Actor = await this.getActor(context, entityClass)
+    if (!actor.isOfType('Curator') && !this.isActorEntityController(actor, entity, false)) {
+      this.error('You are not the entity controller!', { exit: ExitCodes.AccessDenied })
     }
 
     await this.requireConfirmation(

+ 16 - 38
cli/src/commands/content-directory/updateEntityPropertyValues.ts

@@ -1,7 +1,7 @@
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { Actor, Class as ContentDirectoryClass } from '@joystream/types/content-directory'
 import inquirer from 'inquirer'
 import { InputParser } from '@joystream/cd-schemas'
+import ExitCodes from '../../ExitCodes'
 
 export default class UpdateEntityPropertyValues extends ContentDirectoryCommandBase {
   static description =
@@ -19,57 +19,35 @@ export default class UpdateEntityPropertyValues extends ContentDirectoryCommandB
     context: ContentDirectoryCommandBase.contextFlag,
   }
 
-  async parseDefaults(
-    defaults: { [key: string]: { 'value': unknown } },
-    actor: Actor,
-    pickedClass: ContentDirectoryClass
-  ) {
-    let parsedDefaults: { [key: string]: { 'value': unknown; locked: boolean } } = {}
-
-    const context = actor.type === 'Curator' ? 'Maintainer' : 'Controller'
-    let propertyLockedFromUser: boolean[] = []
-
-    if (context === 'Maintainer') {
-      propertyLockedFromUser = pickedClass.properties.map(
-        (property) => property.locking_policy.is_locked_from_maintainer.isTrue
-      )
-    } else {
-      propertyLockedFromUser = pickedClass.properties.map(
-        (property) => property.locking_policy.is_locked_from_controller.isTrue
-      )
-    }
-
-    Object.keys(defaults).forEach((key, index) => {
-      parsedDefaults = {
-        ...parsedDefaults,
-        [key]: {
-          ...defaults[key],
-          locked: propertyLockedFromUser[index],
-        },
-      }
-    })
-
-    return parsedDefaults
-  }
-
   async run() {
     const { id } = this.parse(UpdateEntityPropertyValues).args
-    const { context } = this.parse(UpdateEntityPropertyValues).flags
+    let { context } = this.parse(UpdateEntityPropertyValues).flags
+
+    if (!context) {
+      context = await this.promptForContext()
+    }
 
     const currentAccount = await this.getRequiredSelectedAccount()
     await this.requestAccountDecoding(currentAccount)
 
     const entity = await this.getEntity(id)
     const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
-    const defaults = this.parseEntityPropertyValues(entity, entityClass)
+    const defaults = await this.parseToEntityJson(entity)
 
     const actor = await this.getActor(context, entityClass)
 
-    const parsedDefaults = await this.parseDefaults(defaults, actor, entityClass)
+    const isPropertEditableByIndex = await Promise.all(
+      entityClass.properties.map((p, i) => this.isEntityPropertyEditableByActor(entity, i, actor))
+    )
+    const filteredProperties = entityClass.properties.filter((p, i) => isPropertEditableByIndex[i])
+
+    if (!filteredProperties.length) {
+      this.error('No entity properties are editable by choosen actor', { exit: ExitCodes.AccessDenied })
+    }
 
     const answers: {
       [key: string]: string | number | null
-    } = await inquirer.prompt(this.getQuestionsFromClass(entityClass, parsedDefaults))
+    } = await inquirer.prompt(this.getQuestionsFromProperties(filteredProperties, defaults))
 
     this.jsonPrettyPrint(JSON.stringify(answers))
     await this.requireConfirmation('Do you confirm the provided input?')

+ 1 - 1
cli/src/commands/media/featuredVideos.ts

@@ -11,7 +11,7 @@ export default class FeaturedVideosCommand extends ContentDirectoryCommandBase {
     const featured = await Promise.all(
       featuredEntries
         .filter(([, entity]) => entity.supported_schemas.toArray().length) // Ignore FeaturedVideo entities without schema
-        .map(([, entity]) => this.parseToKnownEntityJson<FeaturedVideoEntity>(entity))
+        .map(([, entity]) => this.parseToEntityJson<FeaturedVideoEntity>(entity))
     )
 
     const videoIds: number[] = featured.map(({ video: videoId }) => videoId)

+ 1 - 1
cli/src/commands/media/removeChannel.ts

@@ -33,7 +33,7 @@ export default class RemoveChannelCommand extends ContentDirectoryCommandBase {
       channelId = id.toNumber()
       channelEntity = channel
     }
-    const channel = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
+    const channel = await this.parseToEntityJson<ChannelEntity>(channelEntity)
 
     await this.requireConfirmation(`Are you sure you want to remove "${channel.handle}" channel?`)
 

+ 1 - 1
cli/src/commands/media/removeVideo.ts

@@ -34,7 +34,7 @@ export default class RemoveVideoCommand extends ContentDirectoryCommandBase {
       videoEntity = video
     }
 
-    const video = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const video = await this.parseToEntityJson<VideoEntity>(videoEntity)
 
     await this.requireConfirmation(`Are you sure you want to remove the "${video.title}" video?`)
 

+ 1 - 1
cli/src/commands/media/updateChannel.ts

@@ -57,7 +57,7 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
       channelEntity = channel
     }
 
-    const currentValues = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
+    const currentValues = await this.parseToEntityJson<ChannelEntity>(channelEntity)
     this.jsonPrettyPrint(JSON.stringify(currentValues))
 
     const channelJsonSchema = (ChannelEntitySchema as unknown) as JSONSchema

+ 1 - 1
cli/src/commands/media/updateVideo.ts

@@ -55,7 +55,7 @@ export default class UpdateVideoCommand extends MediaCommandBase {
       videoEntity = video
     }
 
-    const currentValues = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const currentValues = await this.parseToEntityJson<VideoEntity>(videoEntity)
     const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
 
     const {

+ 1 - 1
cli/src/commands/media/updateVideoLicense.ts

@@ -37,7 +37,7 @@ export default class UpdateVideoLicenseCommand extends MediaCommandBase {
       videoEntity = video
     }
 
-    const video = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const video = await this.parseToEntityJson<VideoEntity>(videoEntity)
     const currentLicense = await this.getAndParseKnownEntity<LicenseEntity>(video.license)
 
     this.log('Current license:', currentLicense)