cofoundry-cms / cofoundry

Cofoundry is an extensible and flexible .NET Core CMS & application framework focusing on code first development
https://www.cofoundry.org
MIT License
835 stars 146 forks source link

UpdateDocumentAssetCommand: throwing permission error when executing with elevated permissions/system user execution context as BackgroundTask #538

Closed otavioferraz closed 1 year ago

otavioferraz commented 1 year ago

Hi there!

I'm trying to implement an immediate background task (through Hangfire) that is called by a PageAdded/PagePublished message handler and generates a Word or Pdf document of a page when it is published. The documents are stored in the document assets repository via execution of AddDocumentAssetCommand or UpdateDocumentAssetCommand (when a document already exists for that page/version in the repository and the page is re-published - in order to maintain only one document per page/version).

The code is working fine with AddDocumentAssetCommand, but is throwing a PermissionValidationFailedException with UpdateDocumentAssetCommand - ImageAssetDeletePermission required. I've tried to execute the code with Elevated Permissions and by creating a System User Execution Context and both fails.

These are the details of the exception:

Cofoundry.Domain.PermissionValidationFailedException Permission Validation Check Failed. Permission Type: Cofoundry.Domain.ImageAssetDeletePermission. UserId:

Cofoundry.Domain.PermissionValidationFailedException: Permission Validation Check Failed. Permission Type: Cofoundry.Domain.ImageAssetDeletePermission. UserId: at Cofoundry.Domain.Internal.PermissionValidationService.EnforcePermission(IPermissionApplication permission, IUserContext userContext) at Cofoundry.Domain.Internal.PermissionValidationService.EnforcePermission(IEnumerable1 permissions, IUserContext userContext) at Cofoundry.Domain.Internal.ExecutePermissionValidationService.Validate[TCommand](TCommand command, ICommandHandler1 commandHandler, IExecutionContext executionContext) at Cofoundry.Domain.CQS.Internal.CommandExecutor.ExecuteCommandAsync[TCommand](TCommand command, IExecutionContext executionContext) at Cofoundry.Domain.CQS.Internal.CommandExecutor.ExecuteCommandAsync[TCommand](TCommand command, IExecutionContext executionContext) at Cofoundry.Domain.CQS.Internal.CommandExecutor.ExecuteAsync(ICommand command, IExecutionContext executionContext) at Cofoundry.Domain.Internal.UpdateDocumentAssetCommandHandler.ExecuteAsync(UpdateDocumentAssetCommand command, IExecutionContext executionContext) at Cofoundry.Domain.CQS.Internal.CommandExecutor.ExecuteCommandAsync[TCommand](TCommand command, IExecutionContext executionContext) at Cofoundry.Domain.CQS.Internal.CommandExecutor.ExecuteCommandAsync[TCommand](TCommand command, IExecutionContext executionContext) at Cofoundry.Domain.CQS.Internal.CommandExecutor.ExecuteAsync(ICommand command, IExecutionContext executionContext) at OtavioFerraz.WebApp.Cofoundry.MessageHandlers.ResumePage.BackgroundTasks.ResumeDocumentGenerateBackgroundTask.AddOrUpdateDocumentAsync(String title, StreamFileSource file) in /Users/otavioferraz/Projects/OtavioFerraz/OtavioFerraz.WebApp/Cofoundry/MessageHandlers/ResumePage/BackgroundTasks/ResumeDocumentGenerateBackgroundTask.cs:line 131 at OtavioFerraz.WebApp.Cofoundry.MessageHandlers.ResumePage.BackgroundTasks.ResumeDocumentGenerateBackgroundTask.GenerateDocAsync(Int32 pageId) in /Users/otavioferraz/Projects/OtavioFerraz/OtavioFerraz.WebApp/Cofoundry/MessageHandlers/ResumePage/BackgroundTasks/ResumeDocumentGenerateBackgroundTask.cs:line 55 at System.Runtime.CompilerServices.TaskAwaiter.GetResult()

This is the code for the BackgroundTask:

public async Task GenerateDocAsync(int pageId) { var resumePage = await _domainRepository.ExecuteQueryAsync(new GetResumePageDetailsByIdQuery(pageId)); EntityNotFoundException.ThrowIfNull(resumePage, pageId);

        var jsonData = JObject.FromObject(resumePage.Content);

        var adobeClient = new AdobePdfServicesClient(_configuration);

        var resumeDocStream = await adobeClient.MergeDocumentAsync(_resumeDocumentSettings.TemplateUrl, jsonData, OutputFormat.DOCX);

        var resumeDocId = await AddOrUpdateDocumentAsync(
            $"CV Otávio Ferraz {resumePage.Locale.ToUpper()} DOC-P{resumePage.PageId}-V{resumePage.Version}",
            new StreamFileSource("tempfile.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", () => resumeDocStream));

        var resumeDocument = await _advancedContentRepository
            .DocumentAssets()
            .GetById(resumeDocId)
            .AsRenderDetails()
            .ExecuteAsync();

        var mailTemplate = new ResumeDocumentMailTemplate();
        mailTemplate.Document = resumeDocument;
        mailTemplate.Page = resumePage.FullUrlPath;

        await _mailService.SendAsync(_resumeDocumentSettings.NotificationToAddress, mailTemplate);
    }

    public async Task GeneratePdfAsync(int pageId)
    {
        var resumePage = await _domainRepository.ExecuteQueryAsync(new GetResumePageDetailsByIdQuery(pageId));
        EntityNotFoundException.ThrowIfNull(resumePage, pageId);

        var jsonData = JObject.FromObject(resumePage.Content);

        var adobeClient = new AdobePdfServicesClient(_configuration);

        var resumePdfStream = await adobeClient.MergeDocumentAsync(_resumeDocumentSettings.TemplateUrl, jsonData, OutputFormat.PDF);

        var resumePdfId = await AddOrUpdateDocumentAsync(
            $"CV Otávio Ferraz {resumePage.Locale.ToUpper()} PDF-P{resumePage.PageId}-V{resumePage.Version}",
            new StreamFileSource("tempfile.pdf", "application/pdf", () => resumePdfStream));

        var resumeDocument = await _advancedContentRepository
            .DocumentAssets()
            .GetById(resumePdfId)
            .AsRenderDetails()
            .ExecuteAsync();

        var mailTemplate = new ResumeDocumentMailTemplate();
        mailTemplate.Document = resumeDocument;
        mailTemplate.Page = resumePage.FullUrlPath;

        await _mailService.SendAsync(_resumeDocumentSettings.NotificationToAddress, mailTemplate);
    }

    private async Task<int> AddOrUpdateDocumentAsync(string title, StreamFileSource file)
    {
        var currentDocumentId = await _domainRepository.ExecuteQueryAsync(new DoesDocumentExistsQuery(title));

        if (currentDocumentId == 0)
        {
            var newDocumentId = await _advancedContentRepository
                .WithElevatedPermissions()
                .DocumentAssets()
                .AddAsync(new AddDocumentAssetCommand()
                {
                    File = file,
                    Title = title
                });

            return newDocumentId;
        }
        else
        {
            //await _advancedContentRepository
            //    .WithElevatedPermissions()
            //    .DocumentAssets()
            //    .UpdateAsync(new UpdateDocumentAssetCommand()
            //    {
            //        DocumentAssetId = currentDocumentId,
            //        File = file,
            //        Title = title
            //    });

            var executionContext = await _executionContextFactory.CreateSystemUserExecutionContextAsync();

            await _commandExecutor.ExecuteAsync(new UpdateDocumentAssetCommand()
            {
                DocumentAssetId = currentDocumentId,
                File = file,
                Title = title
            }, executionContext);

            return currentDocumentId;
        }
    }

I assume, for some reason, the elevated permissions or system user execution context are not being carried over to the QueueAssetFileDeletionCommand, which requires both ImageAssetDeletePermission and DocumentAssetDeletePermission. Or should it be implemented a different way?

Thanks in advance!

otavioferraz commented 1 year ago

After a quick further inspection into the current implementation of the UpdateDocumentAssetCommandHandler, it seems that the deleteOldFileCommand execution is being done without passing through the execution context it was injected by the command handler (Line 71).

An easy fix would be replace the line 71 by the one below:

await _commandExecutor.ExecuteAsync(deleteOldFileCommand, executionContext);

Can this fix be implemented on the next minor release?

HeyJoel commented 1 year ago

Hi @otavioferraz, yes that looks like the issue. I'll see if I can find time to put in a release with the fix soon, but until then you can easily integrate the fix yourself by copying the UpdateDocumentAssetCommandHandler code into your solution, applying the fix and then registering it with the DI container as an override:

public class ExampleOverrideRegistration : IDependencyRegistration
{
    public void Register(IContainerRegister container)
    {
        container.Register<ICommandHandler<UpdateDocumentAssetCommand>, UpdateDocumentAssetCommandHandlerFix>(RegistrationOptions.Override());
    }
}

Full code below:

namespace ExampleFix;

using Cofoundry.Core.Data;
using Cofoundry.Core.DependencyInjection;
using Cofoundry.Core.MessageAggregator;
using Cofoundry.Core.Validation;
using Cofoundry.Domain.CQS;
using Cofoundry.Domain.Data;
using Microsoft.EntityFrameworkCore;

public class UpdateDocumentAssetCommandHandlerFix
    : ICommandHandler<UpdateDocumentAssetCommand>
    , IPermissionRestrictedCommandHandler<UpdateDocumentAssetCommand>
{
    private readonly CofoundryDbContext _dbContext;
    private readonly EntityAuditHelper _entityAuditHelper;
    private readonly EntityTagHelper _entityTagHelper;
    private readonly DocumentAssetCommandHelper _documentAssetCommandHelper;
    private readonly ITransactionScopeManager _transactionScopeFactory;
    private readonly IMessageAggregator _messageAggregator;
    private readonly ICommandExecutor _commandExecutor;

    public UpdateDocumentAssetCommandHandlerFix(
        CofoundryDbContext dbContext,
        EntityAuditHelper entityAuditHelper,
        EntityTagHelper entityTagHelper,
        DocumentAssetCommandHelper documentAssetCommandHelper,
        ITransactionScopeManager transactionScopeFactory,
        IMessageAggregator messageAggregator,
        ICommandExecutor commandExecutor
        )
    {
        _dbContext = dbContext;
        _entityAuditHelper = entityAuditHelper;
        _entityTagHelper = entityTagHelper;
        _documentAssetCommandHelper = documentAssetCommandHelper;
        _transactionScopeFactory = transactionScopeFactory;
        _messageAggregator = messageAggregator;
        _commandExecutor = commandExecutor;
    }

    public async Task ExecuteAsync(UpdateDocumentAssetCommand command, IExecutionContext executionContext)
    {
        bool hasNewFile = command.File != null;

        var documentAsset = await _dbContext
            .DocumentAssets
            .Include(a => a.DocumentAssetTags)
            .ThenInclude(a => a.Tag)
            .FilterById(command.DocumentAssetId)
            .SingleOrDefaultAsync();

        documentAsset.Title = command.Title;
        documentAsset.Description = command.Description ?? string.Empty;
        documentAsset.FileName = FilePathHelper.CleanFileName(command.Title);

        if (string.IsNullOrWhiteSpace(documentAsset.FileName))
        {
            throw ValidationErrorException.CreateWithProperties("Document title is empty or does not contain any safe file path characters.", nameof(command.Title));
        }

        _entityTagHelper.UpdateTags(documentAsset.DocumentAssetTags, command.Tags, executionContext);
        _entityAuditHelper.SetUpdated(documentAsset, executionContext);

        using (var scope = _transactionScopeFactory.Create(_dbContext))
        {
            if (hasNewFile)
            {
                var deleteOldFileCommand = new QueueAssetFileDeletionCommand()
                {
                    EntityDefinitionCode = DocumentAssetEntityDefinition.DefinitionCode,
                    FileNameOnDisk = documentAsset.FileNameOnDisk,
                    FileExtension = documentAsset.FileExtension
                };

                await _commandExecutor.ExecuteAsync(deleteOldFileCommand, executionContext);
                await _documentAssetCommandHelper.SaveFile(command.File, documentAsset);
                documentAsset.FileUpdateDate = executionContext.ExecutionDate;
            }

            await _dbContext.SaveChangesAsync();

            scope.QueueCompletionTask(() => OnTransactionComplete(documentAsset, hasNewFile));

            await scope.CompleteAsync();
        }
    }

    private Task OnTransactionComplete(DocumentAsset documentAsset, bool hasNewFile)
    {
        return _messageAggregator.PublishAsync(new DocumentAssetUpdatedMessage()
        {
            DocumentAssetId = documentAsset.DocumentAssetId,
            HasFileChanged = hasNewFile
        });
    }

    public IEnumerable<IPermissionApplication> GetPermissions(UpdateDocumentAssetCommand command)
    {
        yield return new DocumentAssetUpdatePermission();
    }
}

public class ExampleOverrideRegistration : IDependencyRegistration
{
    public void Register(IContainerRegister container)
    {
        container.Register<ICommandHandler<UpdateDocumentAssetCommand>, UpdateDocumentAssetCommandHandlerFix>(RegistrationOptions.Override());
    }
}
HeyJoel commented 1 year ago

Fixed, will be release in 0.11.4

HeyJoel commented 1 year ago

0.11.4 has now been released with the fix.