diff --git a/modules/storage/src/Storage.ts b/modules/storage/src/Storage.ts index 433e5ecf5..947ca4612 100644 --- a/modules/storage/src/Storage.ts +++ b/modules/storage/src/Storage.ts @@ -39,8 +39,8 @@ import { ManagedModule, } from '@conduitplatform/module-tools'; import { StorageParamAdapter } from './adapter/StorageParamAdapter.js'; -import { FileResource } from './authz/index.js'; import { AdminFileHandlers } from './admin/adminFile.js'; +import * as resources from './authz/index.js'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); @@ -95,11 +95,20 @@ export default class Storage extends ManagedModule { config.aws.accountId = await getAwsAccountId(config); } } + if ( + !isNil( + (config as Config & { allowContainerCreation?: boolean }).allowContainerCreation, + ) + ) { + delete (config as Config & { allowContainerCreation?: boolean }) + .allowContainerCreation; + } return config; } async onConfig() { - if (!ConfigController.getInstance().config.active) { + const config = ConfigController.getInstance().config; + if (!config.active) { this.updateHealth(HealthCheckStatus.NOT_SERVING); } else { await this.grpcSdk.monitorModule( @@ -110,11 +119,20 @@ export default class Storage extends ManagedModule { }, false, ); - const { provider, local, google, azure, aws, aliyun, authorization } = - ConfigController.getInstance().config; + const { provider, local, google, azure, aws, aliyun, authorization } = config; if (authorization.enabled) { - this.grpcSdk.onceModuleUp('authorization', () => { - this.grpcSdk.authorization!.defineResource(FileResource); + this.grpcSdk.onceModuleUp('authorization', async () => { + for (const resource of Object.values(resources)) { + await this.grpcSdk.authorization!.defineResource(resource); + } + }); + } + const defaultContainer = await models._StorageContainer.getInstance().findOne({ + name: config.defaultContainer, + }); + if (!defaultContainer) { + await models._StorageContainer.getInstance().create({ + name: config.defaultContainer, }); } this.storageProvider = createStorageProvider(provider, { diff --git a/modules/storage/src/admin/adminFile.ts b/modules/storage/src/admin/adminFile.ts index 628b72869..e87f03085 100644 --- a/modules/storage/src/admin/adminFile.ts +++ b/modules/storage/src/admin/adminFile.ts @@ -15,6 +15,7 @@ import { _updateFile, _updateFileUploadUrl, deepPathHandler, + findOrCreateFolders, normalizeFolderPath, storeNewFile, validateName, @@ -48,19 +49,27 @@ export class AdminFileHandlers { if (isNil(file)) { throw new GrpcError(status.NOT_FOUND, 'File does not exist'); } - return file; } async createFile(call: ParsedRouterRequest): Promise { - const { name, alias, data, container, mimeType, isPublic } = call.request.params; - const folder = normalizeFolderPath(call.request.params.folder); + const { name, alias, data, container, mimeType, isPublic, scope } = + call.request.params; const config = ConfigController.getInstance().config; + + const folder = normalizeFolderPath(call.request.params.folder); const usedContainer = isNil(container) ? config.defaultContainer : await this.findOrCreateContainer(container, isPublic); if (folder !== '/') { - await this.findOrCreateFolders(folder, usedContainer, isPublic); + await findOrCreateFolders( + this.grpcSdk, + this.storageProvider, + folder, + usedContainer, + isPublic, + scope, + ); } const validatedName = await validateName(name, folder, usedContainer); if (!isString(data)) { @@ -68,15 +77,20 @@ export class AdminFileHandlers { } try { - return await storeNewFile(this.storageProvider, { - name: validatedName, - alias, - data, - container: usedContainer, - folder, - isPublic, - mimeType, - }); + return await storeNewFile( + this.grpcSdk, + this.storageProvider, + { + name: validatedName, + alias, + data, + container: usedContainer, + folder, + isPublic, + mimeType, + }, + scope, + ); } catch (e) { throw new GrpcError( status.INTERNAL, @@ -86,27 +100,48 @@ export class AdminFileHandlers { } async createFileUploadUrl(call: ParsedRouterRequest): Promise { - const { name, alias, container, size = 0, mimeType, isPublic } = call.request.params; + const { + name, + alias, + container, + size = 0, + mimeType, + isPublic, + scope, + } = call.request.params; + const folder = normalizeFolderPath(call.request.params.folder); const config = ConfigController.getInstance().config; const usedContainer = isNil(container) ? config.defaultContainer : await this.findOrCreateContainer(container, isPublic); if (folder !== '/') { - await this.findOrCreateFolders(folder, usedContainer, isPublic); + await findOrCreateFolders( + this.grpcSdk, + this.storageProvider, + folder, + usedContainer, + isPublic, + scope, + ); } const validatedName = await validateName(name, folder, usedContainer); try { - return await _createFileUploadUrl(this.storageProvider, { - container: usedContainer, - folder, - isPublic, - name: validatedName, - alias, - size, - mimeType, - }); + return await _createFileUploadUrl( + this.grpcSdk, + this.storageProvider, + { + container: usedContainer, + folder, + isPublic, + name: validatedName, + alias, + size, + mimeType, + }, + scope, + ); } catch (e) { throw new GrpcError( status.INTERNAL, @@ -116,7 +151,7 @@ export class AdminFileHandlers { } async updateFileUploadUrl(call: ParsedRouterRequest): Promise { - const { id, alias, mimeType, size } = call.request.params; + const { id, alias, mimeType, size, scope } = call.request.params; const found = await File.getInstance().findOne({ _id: id }); if (isNil(found)) { throw new GrpcError(status.NOT_FOUND, 'File does not exist'); @@ -126,14 +161,20 @@ export class AdminFileHandlers { found, ); try { - return await _updateFileUploadUrl(this.storageProvider, found, { - name, - alias, - folder, - container, - mimeType: mimeType ?? found.mimeType, - size, - }); + return await _updateFileUploadUrl( + this.grpcSdk, + this.storageProvider, + found, + { + name, + alias, + folder, + container, + mimeType: mimeType ?? found.mimeType, + size, + }, + scope, + ); } catch (e) { throw new GrpcError( status.INTERNAL, @@ -143,7 +184,7 @@ export class AdminFileHandlers { } async updateFile(call: ParsedRouterRequest): Promise { - const { id, alias, data, mimeType } = call.request.params; + const { id, alias, data, mimeType, scope } = call.request.params; const found = await File.getInstance().findOne({ _id: id }); if (isNil(found)) { throw new GrpcError(status.NOT_FOUND, 'File does not exist'); @@ -153,14 +194,20 @@ export class AdminFileHandlers { found, ); try { - return await _updateFile(this.storageProvider, found, { - name, - alias, - folder, - container, - data: Buffer.from(data, 'base64'), - mimeType: mimeType ?? found.mimeType, - }); + return await _updateFile( + this.grpcSdk, + this.storageProvider, + found, + { + name, + alias, + folder, + container, + data: Buffer.from(data, 'base64'), + mimeType: mimeType ?? found.mimeType, + }, + scope, + ); } catch (e) { throw new GrpcError( status.INTERNAL, @@ -170,11 +217,12 @@ export class AdminFileHandlers { } async deleteFile(call: ParsedRouterRequest): Promise { - if (!isString(call.request.params.id)) { + const { id } = call.request.urlParams; + if (!isString(id)) { throw new GrpcError(status.INVALID_ARGUMENT, 'The provided id is invalid'); } try { - const found = await File.getInstance().findOne({ _id: call.request.params.id }); + const found = await File.getInstance().findOne({ _id: id }); if (isNil(found)) { throw new GrpcError(status.NOT_FOUND, 'File does not exist'); } @@ -184,9 +232,19 @@ export class AdminFileHandlers { if (!success) { throw new GrpcError(status.INTERNAL, 'File could not be deleted'); } - await File.getInstance().deleteOne({ _id: call.request.params.id }); + await File.getInstance().deleteOne({ _id: id }); ConduitGrpcSdk.Metrics?.decrement('files_total'); ConduitGrpcSdk.Metrics?.decrement('storage_size_bytes_total', found.size); + + if (ConfigController.getInstance().config.authorization.enabled) { + await this.grpcSdk.authorization + ?.deleteAllRelations({ resource: 'File:' + id }) + .catch((e: Error) => { + if (!e.message.includes('No relations found')) { + throw e; + } + }); + } return { success: true }; } catch (e) { throw new GrpcError( @@ -257,18 +315,10 @@ export class AdminFileHandlers { container: string, isPublic?: boolean, ): Promise { - const config = ConfigController.getInstance().config; - // the container is sent from the client const found = await _StorageContainer.getInstance().findOne({ name: container, }); if (!found) { - if (!config.allowContainerCreation) { - throw new GrpcError( - status.PERMISSION_DENIED, - 'Container creation is not allowed!', - ); - } const exists = await this.storageProvider.containerExists(container); if (!exists) { await this.storageProvider.createContainer(container); @@ -281,36 +331,6 @@ export class AdminFileHandlers { return container; } - async findOrCreateFolders( - folderPath: string, - container: string, - isPublic?: boolean, - lastExistsHandler?: () => void, - ): Promise<_StorageFolder[]> { - const createdFolders: _StorageFolder[] = []; - let folder: _StorageFolder | null = null; - await deepPathHandler(folderPath, async (folderPath, isLast) => { - folder = await _StorageFolder - .getInstance() - .findOne({ name: folderPath, container }); - if (isNil(folder)) { - folder = await _StorageFolder.getInstance().create({ - name: folderPath, - container, - isPublic, - }); - createdFolders.push(folder); - const exists = await this.storage.container(container).folderExists(folderPath); - if (!exists) { - await this.storage.container(container).createFolder(folderPath); - } - } else if (isLast) { - lastExistsHandler?.(); - } - }); - return createdFolders; - } - private async validateFilenameAndContainer(call: ParsedRouterRequest, file: File) { const { name, folder, container } = call.request.params; const newName = name ?? file.name; @@ -320,7 +340,13 @@ export class AdminFileHandlers { } const newFolder = isNil(folder) ? file.folder : normalizeFolderPath(folder); if (newFolder !== file.folder && newFolder !== '/') { - await this.findOrCreateFolders(newFolder, newContainer); + await findOrCreateFolders( + this.grpcSdk, + this.storageProvider, + newFolder, + newContainer, + file.isPublic, + ); } const isDataUpdate = newName === file.name && diff --git a/modules/storage/src/admin/index.ts b/modules/storage/src/admin/index.ts index 1f70ef419..cf5c2ffdd 100644 --- a/modules/storage/src/admin/index.ts +++ b/modules/storage/src/admin/index.ts @@ -12,13 +12,14 @@ import { ConduitBoolean, ConduitNumber, ConduitString, + ConfigController, GrpcServer, RoutingManager, } from '@conduitplatform/module-tools'; import { status } from '@grpc/grpc-js'; import { isEmpty, isNil } from 'lodash-es'; import { _StorageContainer, _StorageFolder, File } from '../models/index.js'; -import { normalizeFolderPath } from '../utils/index.js'; +import { findOrCreateFolders, normalizeFolderPath } from '../utils/index.js'; import { AdminFileHandlers } from './adminFile.js'; export class AdminRoutes { @@ -96,26 +97,33 @@ export class AdminRoutes { } async createFolder(call: ParsedRouterRequest): Promise { - const { container, isPublic } = call.request.params; + const { container, isPublic, scope } = call.request.params; const name = normalizeFolderPath(call.request.params.name); if (name === '/') { throw new GrpcError(status.INVALID_ARGUMENT, 'Folder name may not be empty'); } - const containerDocument = await _StorageContainer + const foundFolder = await _StorageFolder.getInstance().findOne({ + name, + container, + }); + if (foundFolder) { + throw new GrpcError(status.ALREADY_EXISTS, 'Folder already exists'); + } + const containerDoc = await _StorageContainer .getInstance() .findOne({ name: container }); - if (isNil(containerDocument)) { - await this._createContainer(container, isPublic).catch((e: Error) => { + if (isNil(containerDoc)) { + await this._createContainer(container, isPublic, scope).catch((e: Error) => { throw new GrpcError(status.INTERNAL, e.message); }); } - const createdFolders = await this.fileHandlers.findOrCreateFolders( + const createdFolders = await findOrCreateFolders( + this.grpcSdk, + this.fileHandlers.storage, name, container, isPublic, - () => { - throw new GrpcError(status.ALREADY_EXISTS, 'Folder already exists'); - }, + scope, ); return createdFolders[createdFolders.length - 1]; } @@ -132,12 +140,28 @@ export class AdminRoutes { await this.fileHandlers.storage .container(folder.container) .deleteFolder(folder.name); - await _StorageFolder.getInstance().deleteOne({ - name: folder.name, + await _StorageFolder.getInstance().deleteMany({ + name: { $regex: `^${folder.name}` }, container: folder.container, }); await File.getInstance().deleteMany({ - folder: folder.name, + folder: { $regex: `^${folder.name}` }, + container: folder.container, + }); + if (ConfigController.getInstance().config.authorization.enabled) { + await this.grpcSdk.authorization + ?.deleteAllRelations({ subject: 'Folder:' + folder._id }) + .catch((e: Error) => { + if (e.message !== 'No relations found') throw e; + }); + await this.grpcSdk.authorization + ?.deleteAllRelations({ resource: 'Folder:' + folder._id }) + .catch((e: Error) => { + if (e.message !== 'No relations found') throw e; + }); + } + await _StorageFolder.getInstance().deleteOne({ + name: folder.name, container: folder.container, }); } @@ -157,29 +181,63 @@ export class AdminRoutes { } async createContainer(call: ParsedRouterRequest): Promise { - const { name, isPublic } = call.request.params; - return await this._createContainer(name, isPublic); + const { name, isPublic, scope } = call.request.params; + return await this._createContainer(name, isPublic, scope); } async deleteContainer(call: ParsedRouterRequest): Promise { const { id } = call.request.params; try { - const container = await _StorageContainer.getInstance().findOne({ - _id: id, - }); + const container = await _StorageContainer.getInstance().findOne({ _id: id }); if (isNil(container)) { throw new GrpcError(status.NOT_FOUND, 'Container does not exist'); } else { await this.fileHandlers.storage.deleteContainer(container.name); - await _StorageContainer.getInstance().deleteOne({ - _id: id, - }); - await File.getInstance().deleteMany({ - container: container.name, - }); - await _StorageFolder.getInstance().deleteMany({ - container: container.name, - }); + await _StorageContainer.getInstance().deleteOne({ _id: id }); + + if (!ConfigController.getInstance().config.authorization.enabled) { + await File.getInstance().deleteMany({ container: container.name }); + await _StorageFolder.getInstance().deleteMany({ container: container.name }); + } else { + // Delete all relations & indexes associated with container + const files = await File.getInstance().findMany( + { container: container.name }, + '_id', + ); + for (const file of files) { + await this.grpcSdk.authorization + ?.deleteAllRelations({ + resource: 'File:' + file._id, + }) + .catch((e: Error) => { + if (!e.message.includes('No relations found')) throw e; + }); + } + await File.getInstance().deleteMany({ container: container.name }); + + const folders = await _StorageFolder + .getInstance() + .findMany({ container: container.name }, '_id'); + for (const folder of folders) { + await this.grpcSdk.authorization + ?.deleteAllRelations({ resource: 'Folder:' + folder._id }) + .catch((e: Error) => { + if (!e.message.includes('No relations found')) throw e; + }); + await this.grpcSdk.authorization + ?.deleteAllRelations({ subject: 'Folder:' + folder._id }) + .catch((e: Error) => { + if (!e.message.includes('No relations found')) throw e; + }); + } + await _StorageFolder.getInstance().deleteMany({ container: container.name }); + + await this.grpcSdk.authorization + ?.deleteAllRelations({ subject: 'Container:' + id }) + .catch((e: Error) => { + if (!e.message.includes('No relations found')) throw e; + }); + } } return container; } catch (e) { @@ -192,6 +250,7 @@ export class AdminRoutes { private registerAdminRoutes() { this.routingManager.clear(); + const authzEnabled = ConfigController.getInstance().config.authorization.enabled; this.routingManager.route( { path: '/files/:id', @@ -238,6 +297,9 @@ export class AdminRoutes { mimeType: ConduitString.Optional, isPublic: ConduitBoolean.Optional, }, + queryParams: { + ...(authzEnabled && { scope: { type: TYPE.String, required: false } }), + }, }, new ConduitRouteReturnDefinition('CreateFile', File.name), this.fileHandlers.createFile.bind(this.fileHandlers), @@ -253,6 +315,9 @@ export class AdminRoutes { container: { type: TYPE.String, required: false }, isPublic: TYPE.Boolean, }, + queryParams: { + ...(authzEnabled && { scope: { type: TYPE.String, required: false } }), + }, action: ConduitRouteActions.POST, path: '/files/upload', description: `Creates a new file and provides a URL to upload it to.`, @@ -279,6 +344,9 @@ export class AdminRoutes { data: ConduitString.Required, mimeType: ConduitString.Optional, }, + queryParams: { + ...(authzEnabled && { scope: { type: TYPE.String, required: false } }), + }, }, new ConduitRouteReturnDefinition('PatchFile', File.name), this.fileHandlers.updateFile.bind(this.fileHandlers), @@ -296,6 +364,9 @@ export class AdminRoutes { mimeType: ConduitString.Optional, size: ConduitNumber.Optional, }, + queryParams: { + ...(authzEnabled && { scope: { type: TYPE.String, required: false } }), + }, action: ConduitRouteActions.PATCH, path: '/files/upload/:id', description: `Updates a file and provides a URL to upload its data to.`, @@ -382,6 +453,9 @@ export class AdminRoutes { container: ConduitString.Required, isPublic: ConduitBoolean.Optional, }, + queryParams: { + ...(authzEnabled && { scope: { type: TYPE.String, required: false } }), + }, }, new ConduitRouteReturnDefinition('CreateFolder', _StorageFolder.name), this.createFolder.bind(this), @@ -424,6 +498,9 @@ export class AdminRoutes { name: ConduitString.Required, isPublic: ConduitBoolean.Optional, }, + queryParams: { + ...(authzEnabled && { scope: { type: TYPE.String, required: false } }), + }, }, new ConduitRouteReturnDefinition(_StorageContainer.name), this.createContainer.bind(this), @@ -443,21 +520,29 @@ export class AdminRoutes { this.routingManager.registerRoutes(); } - private async _createContainer(name: string, isPublic: boolean | undefined) { + private async _createContainer( + name: string, + isPublic: boolean | undefined, + scope?: string, + ) { try { let container = await _StorageContainer.getInstance().findOne({ name, }); if (isNil(container)) { const exists = await this.fileHandlers.storage.containerExists(name); - if (!exists) { await this.fileHandlers.storage.createContainer(name); } - container = await _StorageContainer.getInstance().create({ - name, - isPublic, - }); + container = await _StorageContainer.getInstance().create({ name, isPublic }); + const authzEnabled = ConfigController.getInstance().config.authorization.enabled; + if (authzEnabled && scope) { + await this.grpcSdk.authorization?.createRelation({ + subject: scope, + relation: 'owner', + resource: 'Container:' + container._id, + }); + } } else { throw new GrpcError(status.ALREADY_EXISTS, 'Container already exists'); } diff --git a/modules/storage/src/authz/index.ts b/modules/storage/src/authz/index.ts index ab3a35dc8..129ecf9eb 100644 --- a/modules/storage/src/authz/index.ts +++ b/modules/storage/src/authz/index.ts @@ -1,5 +1,49 @@ import { ConduitAuthorizedResource } from '@conduitplatform/grpc-sdk'; +export const ContainerResource = new ConduitAuthorizedResource( + 'Container', + { + owner: ['*'], + reader: ['*'], + editor: ['*'], + }, + { + read: [ + 'owner', + 'reader', + 'editor', + 'reader->read', + 'editor->edit', + 'owner->read', + 'owner->edit', + ], + edit: ['owner', 'editor', 'editor->edit', 'owner->edit'], + delete: ['owner', 'owner->edit'], + }, +); + +export const FolderResource = new ConduitAuthorizedResource( + 'Folder', + { + owner: ['*'], + reader: ['*'], + editor: ['*'], + }, + { + read: [ + 'owner', + 'reader', + 'editor', + 'reader->read', + 'editor->edit', + 'owner->read', + 'owner->edit', + ], + edit: ['owner', 'editor', 'editor->edit', 'owner->edit'], + delete: ['owner', 'owner->edit'], + }, +); + export const FileResource = new ConduitAuthorizedResource( 'File', { diff --git a/modules/storage/src/config/config.ts b/modules/storage/src/config/config.ts index 222a806e3..ec3f58546 100644 --- a/modules/storage/src/config/config.ts +++ b/modules/storage/src/config/config.ts @@ -17,10 +17,6 @@ export default { format: 'String', default: 'conduit', }, - allowContainerCreation: { - format: 'Boolean', - default: true, - }, google: { serviceAccountKeyPath: { format: 'String', diff --git a/modules/storage/src/handlers/file.ts b/modules/storage/src/handlers/file.ts index 62ed02ab6..afc4fdea5 100644 --- a/modules/storage/src/handlers/file.ts +++ b/modules/storage/src/handlers/file.ts @@ -2,8 +2,8 @@ import { ConduitGrpcSdk, DatabaseProvider, GrpcError, - Indexable, ParsedRouterRequest, + Query, UnparsedRouterResponse, } from '@conduitplatform/grpc-sdk'; import { ConfigController } from '@conduitplatform/module-tools'; @@ -15,7 +15,8 @@ import { _createFileUploadUrl, _updateFile, _updateFileUploadUrl, - deepPathHandler, + findOrCreateFolders, + getNestedPaths, normalizeFolderPath, storeNewFile, validateName, @@ -46,18 +47,24 @@ export class FileHandlers { async fileAccessCheck( action: 'read' | 'create' | 'edit' | 'delete', - request: Indexable, + userId?: string, + scope?: string, file?: File, + container?: string, ) { - if (!request.context.user) { + if (!userId) { throw new GrpcError(status.PERMISSION_DENIED, 'File access is not public'); } - if (ConfigController.getInstance().config.authorization.enabled) { - if (action === 'create' && request.queryParams.scope) { + if (!ConfigController.getInstance().config.authorization.enabled) { + return; + } + + if (action === 'create') { + if (scope) { const allowed = await this.grpcSdk.authorization?.can({ - subject: `User:${request.context.user._id}`, - actions: ['read'], - resource: request.params.scope, + subject: 'User:' + userId, + actions: ['edit'], + resource: scope, }); if (!allowed || !allowed.allow) { throw new GrpcError( @@ -66,39 +73,83 @@ export class FileHandlers { ); } } - if (['read', 'edit', 'delete'].includes(action)) { + const defaultContainer = ConfigController.getInstance().config.defaultContainer; + if (container !== defaultContainer) { + const containerDoc = await _StorageContainer + .getInstance() + .findOne({ name: container }); const allowed = await this.grpcSdk.authorization?.can({ - subject: `User:${request.context.user._id}`, - actions: [action], - resource: `File:${file!._id}`, + subject: scope ?? 'User:' + userId, + actions: ['edit'], + resource: 'Container:' + containerDoc?._id, }); if (!allowed || !allowed.allow) { - throw new GrpcError(status.PERMISSION_DENIED, 'You do not have access to file'); + throw new GrpcError( + status.PERMISSION_DENIED, + 'You are not allowed to create files in this container', + ); } } + } else { + const allowed = await this.grpcSdk.authorization?.can({ + subject: 'User:' + userId, + actions: [action], + resource: `File:${file!._id}`, + }); + if (!allowed || !allowed.allow) { + throw new GrpcError( + status.PERMISSION_DENIED, + `You are not allowed to ${action} this file`, + ); + } } } - async fileAccessAdd(file: File, request: Indexable) { - if (ConfigController.getInstance().config.authorization.enabled) { - if (request.queryParams.scope) { - const allowed = await this.grpcSdk.authorization?.can({ - subject: `User:${request.context.user._id}`, - actions: ['read'], - resource: request.params.scope, + /* Checks if user can create-update file in provided folder */ + async folderAccessEdit(container: string, folder: string, scope: string) { + if (!ConfigController.getInstance().config.authorization.enabled) { + return; + } + const folderDoc = await _StorageFolder.getInstance().findOne({ + name: folder, + container, + }); + if (folderDoc) { + const allowed = await this.grpcSdk.authorization?.can({ + subject: scope, + actions: ['edit'], + resource: 'Folder:' + folderDoc._id, + }); + if (!allowed || !allowed.allow) { + throw new GrpcError( + status.PERMISSION_DENIED, + 'You are not allowed to edit files in folder ' + folderDoc.name, + ); + } + return; + } else { + // Check previous folders + const nestedPaths = getNestedPaths(folder); + for (let i = nestedPaths.length - 2; i >= 0; i--) { + const prevFolder = await _StorageFolder.getInstance().findOne({ + name: nestedPaths[i], + container, }); - if (!allowed || !allowed.allow) { - throw new GrpcError( - status.PERMISSION_DENIED, - 'You are not allowed to create files in this scope', - ); + if (prevFolder) { + const allowed = await this.grpcSdk.authorization?.can({ + subject: scope, + actions: ['edit'], + resource: 'Folder:' + prevFolder._id, + }); + if (!allowed || !allowed.allow) { + throw new GrpcError( + status.PERMISSION_DENIED, + 'You are not allowed to edit files in folder' + prevFolder.name, + ); + } + return; } } - await this.grpcSdk.authorization?.createRelation({ - subject: request.params.scope ?? `User:${request.context.user._id}`, - relation: 'owner', - resource: `File:${file._id}`, - }); } } @@ -109,35 +160,56 @@ export class FileHandlers { } if (!file.isPublic) { - await this.fileAccessCheck('read', call.request, file); + await this.fileAccessCheck( + 'read', + call.request.context.user._id, + call.request.context.scope, + file, + ); } return file; } async createFile(call: ParsedRouterRequest): Promise { - const { name, alias, data, container, mimeType, isPublic } = call.request.params; - await this.fileAccessCheck('create', call.request); - const folder = normalizeFolderPath(call.request.params.folder); + const { name, alias, data, container, mimeType, isPublic, scope } = + call.request.params; + const { user } = call.request.context; + const config = ConfigController.getInstance().config; const usedContainer = isNil(container) ? config.defaultContainer - : await this.findOrCreateContainer(container, isPublic); + : await this.findContainer(container); + await this.fileAccessCheck('create', user._id, scope, undefined, usedContainer); + + const folder = normalizeFolderPath(call.request.params.folder ?? 'cnd_' + user._id); if (folder !== '/') { - await this.findOrCreateFolders(folder, usedContainer, isPublic); + await this.folderAccessEdit(usedContainer, folder, scope ?? 'User:' + user._id); + await findOrCreateFolders( + this.grpcSdk, + this.storageProvider, + folder, + usedContainer, + isPublic, + scope ?? 'User:' + user._id, + ); } const validatedName = await validateName(name, folder, usedContainer); + try { - const file = await storeNewFile(this.storageProvider, { - name: validatedName, - alias, - data, - container: usedContainer, - folder, - isPublic, - mimeType, - }); - await this.fileAccessAdd(file, call.request); - return file; + return await storeNewFile( + this.grpcSdk, + this.storageProvider, + { + name: validatedName, + alias, + data, + container: usedContainer, + folder, + isPublic, + mimeType, + }, + scope ?? 'User:' + user._id, + ); } catch (e) { throw new GrpcError( status.INTERNAL, @@ -147,28 +219,52 @@ export class FileHandlers { } async createFileUploadUrl(call: ParsedRouterRequest): Promise { - const { name, alias, container, size = 0, mimeType, isPublic } = call.request.params; - await this.fileAccessCheck('create', call.request); - const folder = normalizeFolderPath(call.request.params.folder); + const { + name, + alias, + container, + size = 0, + mimeType, + isPublic, + scope, + } = call.request.params; + const { user } = call.request.context; + const config = ConfigController.getInstance().config; const usedContainer = isNil(container) ? config.defaultContainer - : await this.findOrCreateContainer(container, isPublic); + : await this.findContainer(container); + await this.fileAccessCheck('create', user._id, scope, undefined, usedContainer); + + const folder = normalizeFolderPath(call.request.params.folder ?? 'cnd_' + user._id); if (folder !== '/') { - await this.findOrCreateFolders(folder, usedContainer, isPublic); + await this.folderAccessEdit(usedContainer, folder, scope ?? 'User:' + user._id); + await findOrCreateFolders( + this.grpcSdk, + this.storageProvider, + folder, + usedContainer, + isPublic, + scope ?? 'User:' + user._id, + ); } const validatedName = await validateName(name, folder, usedContainer); + try { - const { file, url } = await _createFileUploadUrl(this.storageProvider, { - container: usedContainer, - folder, - isPublic, - name: validatedName, - alias, - size, - mimeType, - }); - await this.fileAccessAdd(file, call.request); + const { file, url } = await _createFileUploadUrl( + this.grpcSdk, + this.storageProvider, + { + container: usedContainer, + folder, + isPublic, + name: validatedName, + alias, + size, + mimeType, + }, + scope ?? 'User:' + user._id, + ); return { file, url }; } catch (e) { throw new GrpcError( @@ -179,25 +275,33 @@ export class FileHandlers { } async updateFileUploadUrl(call: ParsedRouterRequest): Promise { - const { id, alias, mimeType, size } = call.request.params; + const { id, alias, mimeType, size, scope } = call.request.params; + const { user } = call.request.context; + const found = await File.getInstance().findOne({ _id: id }); if (isNil(found)) { throw new GrpcError(status.NOT_FOUND, 'File does not exist'); } - await this.fileAccessCheck('edit', call.request, found); + await this.fileAccessCheck('edit', user._id, scope, found); const { name, folder, container } = await this.validateFilenameAndContainer( call, found, ); try { - return await _updateFileUploadUrl(this.storageProvider, found, { - name, - alias, - folder, - container, - mimeType: mimeType ?? found.mimeType, - size, - }); + return await _updateFileUploadUrl( + this.grpcSdk, + this.storageProvider, + found, + { + name, + alias, + folder, + container, + mimeType: mimeType ?? found.mimeType, + size, + }, + scope ?? 'User:' + user._id, + ); } catch (e) { throw new GrpcError( status.INTERNAL, @@ -207,25 +311,33 @@ export class FileHandlers { } async updateFile(call: ParsedRouterRequest): Promise { - const { id, alias, data, mimeType } = call.request.params; + const { id, alias, data, mimeType, scope } = call.request.params; + const { user } = call.request.context; + const found = await File.getInstance().findOne({ _id: id }); if (isNil(found)) { throw new GrpcError(status.NOT_FOUND, 'File does not exist'); } - await this.fileAccessCheck('edit', call.request, found); + await this.fileAccessCheck('edit', user._id, scope, found); const { name, folder, container } = await this.validateFilenameAndContainer( call, found, ); try { - return await _updateFile(this.storageProvider, found, { - name, - alias, - folder, - container, - data: Buffer.from(data, 'base64'), - mimeType: mimeType ?? found.mimeType, - }); + return await _updateFile( + this.grpcSdk, + this.storageProvider, + found, + { + name, + alias, + folder, + container, + data: Buffer.from(data, 'base64'), + mimeType: mimeType ?? found.mimeType, + }, + scope ?? 'User:' + user._id, + ); } catch (e) { throw new GrpcError( status.INTERNAL, @@ -235,24 +347,38 @@ export class FileHandlers { } async deleteFile(call: ParsedRouterRequest): Promise { - if (!isString(call.request.params.id)) { + const { id } = call.request.urlParams; + const { scope } = call.request.queryParams; + const { user } = call.request.context; + if (!isString(id)) { throw new GrpcError(status.INVALID_ARGUMENT, 'The provided id is invalid'); } try { - const found = await File.getInstance().findOne({ _id: call.request.params.id }); + const found = await File.getInstance().findOne({ _id: id }); if (isNil(found)) { throw new GrpcError(status.NOT_FOUND, 'File does not exist'); } - await this.fileAccessCheck('delete', call.request, found); + await this.fileAccessCheck('delete', user._id, scope, found); + const success = await this.storageProvider .container(found.container) .delete((found.folder === '/' ? '' : found.folder) + found.name); if (!success) { throw new GrpcError(status.INTERNAL, 'File could not be deleted'); } - await File.getInstance().deleteOne({ _id: call.request.params.id }); + await File.getInstance().deleteOne({ _id: id }); ConduitGrpcSdk.Metrics?.decrement('files_total'); ConduitGrpcSdk.Metrics?.decrement('storage_size_bytes_total', found.size); + + if (ConfigController.getInstance().config.authorization.enabled) { + await this.grpcSdk.authorization + ?.deleteAllRelations({ resource: 'File:' + id }) + .catch((e: Error) => { + if (!e.message.includes('No relations found')) { + throw e; + } + }); + } return { success: true }; } catch (e) { throw new GrpcError( @@ -274,7 +400,12 @@ export class FileHandlers { } return { redirect: found.url }; } - await this.fileAccessCheck('read', call.request, found); + await this.fileAccessCheck( + 'read', + call.request.context.user._id, + call.request.queryParams.scope, + found, + ); const url = await this.storageProvider .container(found.container) .getSignedUrl((found.folder === '/' ? '' : found.folder) + found.name); @@ -300,7 +431,12 @@ export class FileHandlers { if (isNil(file)) { throw new GrpcError(status.NOT_FOUND, 'File does not exist'); } - await this.fileAccessCheck('read', call.request, file); + await this.fileAccessCheck( + 'read', + call.request.context.user._id, + call.request.queryParams.scope, + file, + ); let data: Buffer; const result = await this.storageProvider .container(file.container) @@ -321,74 +457,56 @@ export class FileHandlers { } } - private async findOrCreateContainer( - container: string, - isPublic?: boolean, - ): Promise { - const config = ConfigController.getInstance().config; - // the container is sent from the client + private async findContainer(container: string): Promise { const found = await _StorageContainer.getInstance().findOne({ name: container, }); if (!found) { - if (!config.allowContainerCreation) { - throw new GrpcError( - status.PERMISSION_DENIED, - 'Container creation is not allowed!', - ); - } - const exists = await this.storageProvider.containerExists(container); - if (!exists) { - await this.storageProvider.createContainer(container); - } - await _StorageContainer.getInstance().create({ - name: container, - isPublic, - }); + throw new GrpcError(status.NOT_FOUND, 'Container does not exist'); } return container; } - async findOrCreateFolders( - folderPath: string, - container: string, - isPublic?: boolean, - lastExistsHandler?: () => void, - ): Promise<_StorageFolder[]> { - const createdFolders: _StorageFolder[] = []; - let folder: _StorageFolder | null = null; - await deepPathHandler(folderPath, async (folderPath, isLast) => { - folder = await _StorageFolder - .getInstance() - .findOne({ name: folderPath, container }); - if (isNil(folder)) { - folder = await _StorageFolder.getInstance().create({ - name: folderPath, - container, - isPublic, - }); - createdFolders.push(folder); - const exists = await this.storage.container(container).folderExists(folderPath); - if (!exists) { - await this.storage.container(container).createFolder(folderPath); - } - } else if (isLast) { - lastExistsHandler?.(); - } - }); - return createdFolders; - } - private async validateFilenameAndContainer(call: ParsedRouterRequest, file: File) { - const { name, folder, container } = call.request.params; + const { name, folder, container, scope } = call.request.params; const newName = name ?? file.name; const newContainer = container ?? file.container; if (newContainer !== file.container) { - await this.findOrCreateContainer(newContainer); + const foundContainer = await _StorageContainer.getInstance().findOne({ + name: newContainer, + }); + if (!foundContainer) { + throw new GrpcError(status.NOT_FOUND, 'Container does not exist'); + } + if (ConfigController.getInstance().config.authorization.enabled) { + const allowed = await this.grpcSdk.authorization?.can({ + subject: scope ?? 'User:' + call.request.context.user._id, + actions: ['edit'], + resource: 'Container:' + foundContainer._id, + }); + if (!allowed || !allowed.allow) { + throw new GrpcError( + status.PERMISSION_DENIED, + 'You are not allowed to edit files in this container', + ); + } + } } const newFolder = isNil(folder) ? file.folder : normalizeFolderPath(folder); if (newFolder !== file.folder && newFolder !== '/') { - await this.findOrCreateFolders(newFolder, newContainer); + await this.folderAccessEdit( + newContainer, + newFolder, + scope ?? 'User:' + call.request.context.user._id, + ); + await findOrCreateFolders( + this.grpcSdk, + this.storageProvider, + newFolder, + newContainer, + file.isPublic, + scope ?? 'User:' + call.request.context.user._id, + ); } const isDataUpdate = newName === file.name && diff --git a/modules/storage/src/providers/local/index.ts b/modules/storage/src/providers/local/index.ts index ecc5b1da9..0c3b24e8d 100644 --- a/modules/storage/src/providers/local/index.ts +++ b/modules/storage/src/providers/local/index.ts @@ -42,7 +42,7 @@ export class LocalStorage implements IStorageProvider { } deleteContainer(name: string): Promise { - return this.deleteFolder(name); + return this.container(name).deleteFolder(name); } deleteFolder(name: string): Promise { diff --git a/modules/storage/src/routes/index.ts b/modules/storage/src/routes/index.ts index d48958b96..eda1ab67f 100644 --- a/modules/storage/src/routes/index.ts +++ b/modules/storage/src/routes/index.ts @@ -6,6 +6,7 @@ import { TYPE, } from '@conduitplatform/grpc-sdk'; import { + ConduitBoolean, ConduitNumber, ConduitString, ConfigController, diff --git a/modules/storage/src/utils/index.ts b/modules/storage/src/utils/index.ts index edae3ad7a..41d154dd5 100644 --- a/modules/storage/src/utils/index.ts +++ b/modules/storage/src/utils/index.ts @@ -2,7 +2,7 @@ import { GetUserCommand, IAMClient } from '@aws-sdk/client-iam'; import { IFileParams, IStorageProvider, StorageConfig } from '../interfaces/index.js'; import { isNil } from 'lodash-es'; import path from 'path'; -import { File } from '../models/index.js'; +import { _StorageContainer, _StorageFolder, File } from '../models/index.js'; import { ConduitGrpcSdk, GrpcError } from '@conduitplatform/grpc-sdk'; import { randomUUID } from 'node:crypto'; import { ConfigController } from '@conduitplatform/module-tools'; @@ -43,7 +43,7 @@ export function normalizeFolderPath(folderPath?: string) { return `${path.normalize(folderPath.trim()).replace(/^\/|\/$/g, '')}/`; } -function getNestedPaths(inputPath: string): string[] { +export function getNestedPaths(inputPath: string): string[] { const paths: string[] = []; const strippedPath = !inputPath.trim() ? '' @@ -69,11 +69,173 @@ export async function deepPathHandler( } } +export async function createFileRelations( + grpcSdk: ConduitGrpcSdk, + file: File, + scope?: string, +) { + if (file.folder === '/') { + const containerDoc = await _StorageContainer + .getInstance() + .findOne({ name: file.container }); + await grpcSdk.authorization?.createRelation({ + subject: 'Container:' + containerDoc!._id, + relation: 'owner', + resource: `File:${file._id}`, + }); + if (scope) { + await grpcSdk.authorization?.createRelation({ + subject: scope, + relation: 'owner', + resource: `File:${file._id}`, + }); + } + } else { + const folderDoc = await _StorageFolder + .getInstance() + .findOne({ name: file.folder, container: file.container }); + await grpcSdk.authorization?.createRelation({ + subject: 'Folder:' + folderDoc!._id, + relation: 'owner', + resource: `File:${file._id}`, + }); + } +} + +export async function updateFileRelations( + grpcSdk: ConduitGrpcSdk, + file: File, + updatedFile: File, + scope?: string, +) { + if (updatedFile.container !== file.container) { + const prevContainer = await _StorageContainer + .getInstance() + .findOne({ name: file.container }); + await grpcSdk.authorization + ?.deleteRelation({ + subject: 'Container:' + prevContainer!._id, + relation: 'owner', + resource: `File:${updatedFile._id}`, + }) + .catch((e: Error) => { + if (!e.message.includes('Relation does not exist')) { + throw e; + } + }); + } + if (updatedFile.folder === file.folder) { + return; + } + + if (updatedFile.folder === '/') { + const newContainer = await _StorageContainer + .getInstance() + .findOne({ name: updatedFile.container }); + await grpcSdk.authorization?.createRelation({ + subject: 'Container:' + newContainer!._id, + relation: 'owner', + resource: `File:${updatedFile._id}`, + }); + if (scope) { + await grpcSdk.authorization?.createRelation({ + subject: scope!, + relation: 'owner', + resource: `File:${updatedFile._id}`, + }); + } + } else { + const folderDoc = await _StorageFolder + .getInstance() + .findOne({ name: updatedFile.folder, container: updatedFile.container }); + await grpcSdk.authorization?.createRelation({ + subject: 'Folder:' + folderDoc!._id, + relation: 'owner', + resource: `File:${updatedFile._id}`, + }); + } + + if (file.folder !== '/') { + const prevFolder = await _StorageFolder + .getInstance() + .findOne({ name: file.folder, container: file.container }); + await grpcSdk.authorization?.deleteRelation({ + subject: 'Folder:' + prevFolder!._id, + relation: 'owner', + resource: `File:${updatedFile._id}`, + }); + } +} + +/* First folder in nested path will be owned by container + First folder in nested path, if not pre-existing, will be owned by provided scope + The other subfolders will be owned by their previous folder */ +export async function findOrCreateFolders( + grpcSdk: ConduitGrpcSdk, + storageProvider: IStorageProvider, + folderPath: string, + container: string, + isPublic?: boolean, + scope?: string, +): Promise<_StorageFolder[]> { + const authzEnabled = ConfigController.getInstance().config.authorization.enabled; + const containerDoc = await _StorageContainer.getInstance().findOne({ name: container }); + if (!containerDoc) { + throw new GrpcError(status.NOT_FOUND, 'Container does not exist'); + } + const createdFolders: _StorageFolder[] = []; + let folder: _StorageFolder | null = null; + const nestedPaths = getNestedPaths(folderPath); + + for (let i = 0; i < nestedPaths.length; i++) { + const folderPath = nestedPaths[i]; + const isFirst = i === 0; + folder = await _StorageFolder.getInstance().findOne({ name: folderPath, container }); + + if (!folder) { + folder = await _StorageFolder + .getInstance() + .create({ name: folderPath, container, isPublic }); + createdFolders.push(folder); + const exists = await storageProvider.container(container).folderExists(folderPath); + if (!exists) { + await storageProvider.container(container).createFolder(folderPath); + } + if (!authzEnabled) { + continue; + } + // Create folder owner relations + const folderOwners: string[] = []; + if (isFirst) { + folderOwners.push('Container:' + containerDoc._id, scope!); + } else { + const prevFolder = await _StorageFolder.getInstance().findOne({ + name: nestedPaths[i - 1], + container, + }); + folderOwners.push('Folder:' + prevFolder!._id); + } + for (const owner of folderOwners) { + await grpcSdk.authorization?.createRelation({ + subject: owner, + relation: 'owner', + resource: `Folder:${folder._id}`, + }); + } + } + } + return createdFolders; +} + export async function storeNewFile( + grpcSdk: ConduitGrpcSdk, storageProvider: IStorageProvider, params: IFileParams, + scope?: string, ): Promise { const { name, alias, data, container, folder, mimeType, isPublic } = params; + const authzEnabled = ConfigController.getInstance().config.authorization.enabled; + const buffer = Buffer.from(data as string, 'base64'); const size = buffer.byteLength; const fileName = (folder === '/' ? '' : folder) + name; @@ -81,9 +243,10 @@ export async function storeNewFile( const publicUrl = isPublic ? await storageProvider.container(container).getPublicUrl(fileName) : null; + ConduitGrpcSdk.Metrics?.increment('files_total'); ConduitGrpcSdk.Metrics?.increment('storage_size_bytes_total', size); - return await File.getInstance().create({ + const file = await File.getInstance().create({ name, alias, mimeType, @@ -93,13 +256,21 @@ export async function storeNewFile( isPublic, url: publicUrl, }); + if (authzEnabled) { + await createFileRelations(grpcSdk, file, scope); + } + return file; } export async function _createFileUploadUrl( + grpcSdk: ConduitGrpcSdk, storageProvider: IStorageProvider, params: IFileParams, + scope?: string, ): Promise<{ file: File; url: string }> { const { name, alias, container, folder, mimeType, isPublic, size } = params; + const authzEnabled = ConfigController.getInstance().config.authorization.enabled; + const fileName = (folder === '/' ? '' : folder) + name; await storageProvider .container(container) @@ -107,6 +278,7 @@ export async function _createFileUploadUrl( const publicUrl = isPublic ? await storageProvider.container(container).getPublicUrl(fileName) : null; + ConduitGrpcSdk.Metrics?.increment('files_total'); ConduitGrpcSdk.Metrics?.increment('storage_size_bytes_total', size); const file = await File.getInstance().create({ @@ -119,6 +291,9 @@ export async function _createFileUploadUrl( isPublic, url: publicUrl, }); + if (authzEnabled) { + await createFileRelations(grpcSdk, file, scope); + } const url = (await storageProvider .container(container) .getUploadUrl(fileName)) as string; @@ -129,13 +304,17 @@ export async function _createFileUploadUrl( } export async function _updateFile( + grpcSdk: ConduitGrpcSdk, storageProvider: IStorageProvider, file: File, params: IFileParams, + scope?: string, ): Promise { const { name, alias, data, folder, container, mimeType } = params; const onlyDataUpdate = name === file.name && folder === file.folder && container === file.container; + const authzEnabled = ConfigController.getInstance().config.authorization.enabled; + await storageProvider .container(container) .store((folder === '/' ? '' : folder) + name, data, file.isPublic); @@ -157,19 +336,26 @@ export async function _updateFile( url, mimeType, })) as File; + if (authzEnabled) { + await updateFileRelations(grpcSdk, file, updatedFile, scope); + } updateFileMetrics(file.size, (data as Buffer).byteLength); return updatedFile; } export async function _updateFileUploadUrl( + grpcSdk: ConduitGrpcSdk, storageProvider: IStorageProvider, file: File, params: IFileParams, + scope?: string, ): Promise<{ file: File; url: string }> { const { name, alias, folder, container, mimeType, size } = params; - let updatedFile; const onlyDataUpdate = name === file.name && folder === file.folder && container === file.container; + const authzEnabled = ConfigController.getInstance().config.authorization.enabled; + let updatedFile; + if (onlyDataUpdate) { updatedFile = await File.getInstance().findByIdAndUpdate(file._id, { mimeType, @@ -192,6 +378,7 @@ export async function _updateFileUploadUrl( .container(container) .getPublicUrl((folder === '/' ? '' : folder) + name) : null; + updatedFile = await File.getInstance().findByIdAndUpdate(file._id, { name, alias, @@ -201,6 +388,9 @@ export async function _updateFileUploadUrl( mimeType, ...{ size: size ?? file.size }, }); + if (authzEnabled) { + await updateFileRelations(grpcSdk, file, updatedFile!, scope); + } } if (!isNil(size)) updateFileMetrics(file.size, size!); const uploadUrl = (await storageProvider