From 4df2e32dcebeaf15a9bfd8f3064e4419c4b1ea60 Mon Sep 17 00:00:00 2001 From: Stephanie Buadu <47737608+acn-sbuad@users.noreply.github.com> Date: Fri, 9 Jun 2023 13:14:26 +0200 Subject: [PATCH] Latest features in storage (#46) * code builds * fixed data repository update logic * removed obsolete using * Removed try/catch from controller * added missing service * bug fix * downgraded package * ignore props causing trouble * Switch for properties in Datarepository * fixed repository logic * merged main * back to dummy implementation in repository --------- Co-authored-by: tba76 Co-authored-by: Bakken --- src/Configuration/Storage/GeneralSettings.cs | 51 +++ src/Constants/Authorization/AuthzConstants.cs | 5 + src/Controllers/Storage/DataController.cs | 201 +++++---- src/Controllers/Storage/DataLockController.cs | 85 ++-- src/Controllers/Storage/SignController.cs | 52 +++ ...ContentDispositionHeaderValueExtensions.cs | 26 ++ src/Extensions/Storage/StringExtensions.cs | 39 ++ src/Helpers/Storage/MessageBoxInstance.cs | 41 +- src/LocalTest.csproj | 4 +- src/Models/ServiceError.cs | 36 ++ src/Models/Storage/FileScanRequest.cs | 44 ++ src/Models/Storage/RepositoryException.cs | 36 ++ .../Implementation/ApplicationService.cs | 42 ++ .../Implementation/AuthorizationService.cs | 423 ++++++++++++++++++ .../Implementation/ClaimsPrincipalProvider.cs | 32 ++ .../Storage/Implementation/DataRepository.cs | 110 ++++- .../Storage/Implementation/DataService.cs | 72 +++ .../Implementation/InstanceEventService.cs | 78 ++++ .../Storage/Implementation/InstanceService.cs | 106 +++++ .../Implementation/StorageAccessHandler.cs | 192 ++++++++ .../Storage/Interface/IApplicationService.cs | 19 + .../Storage/Interface/IAuthorization.cs | 52 +++ .../Interface/IClaimsPrincipalProvider.cs | 18 + .../Storage/Interface/IDataRepository.cs | 34 +- .../Storage/Interface/IDataService.cs | 45 ++ .../Interface/IInstanceEventService.cs | 28 ++ .../Storage/Interface/IInstanceService.cs | 22 + src/Startup.cs | 28 +- 28 files changed, 1773 insertions(+), 148 deletions(-) create mode 100644 src/Configuration/Storage/GeneralSettings.cs create mode 100644 src/Controllers/Storage/SignController.cs create mode 100644 src/Extensions/Storage/ContentDispositionHeaderValueExtensions.cs create mode 100644 src/Extensions/Storage/StringExtensions.cs create mode 100644 src/Models/ServiceError.cs create mode 100644 src/Models/Storage/FileScanRequest.cs create mode 100644 src/Models/Storage/RepositoryException.cs create mode 100644 src/Services/Storage/Implementation/ApplicationService.cs create mode 100644 src/Services/Storage/Implementation/AuthorizationService.cs create mode 100644 src/Services/Storage/Implementation/ClaimsPrincipalProvider.cs create mode 100644 src/Services/Storage/Implementation/DataService.cs create mode 100644 src/Services/Storage/Implementation/InstanceEventService.cs create mode 100644 src/Services/Storage/Implementation/InstanceService.cs create mode 100644 src/Services/Storage/Implementation/StorageAccessHandler.cs create mode 100644 src/Services/Storage/Interface/IApplicationService.cs create mode 100644 src/Services/Storage/Interface/IAuthorization.cs create mode 100644 src/Services/Storage/Interface/IClaimsPrincipalProvider.cs create mode 100644 src/Services/Storage/Interface/IDataService.cs create mode 100644 src/Services/Storage/Interface/IInstanceEventService.cs create mode 100644 src/Services/Storage/Interface/IInstanceService.cs diff --git a/src/Configuration/Storage/GeneralSettings.cs b/src/Configuration/Storage/GeneralSettings.cs new file mode 100644 index 00000000..799ae824 --- /dev/null +++ b/src/Configuration/Storage/GeneralSettings.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Altinn.Platform.Storage.Configuration +{ + /// + /// Configuration object used to hold general settings for the storage application. + /// + public class GeneralSettings + { + /// + /// Open Id Connect Well known endpoint. Related to JSON WEB token validation. + /// + public string OpenIdWellKnownEndpoint { get; set; } + + /// + /// Hostname + /// + public string Hostname { get; set; } + + /// + /// Name of the cookie for runtime + /// + public string RuntimeCookieName { get; set; } + + /// + /// Gets or sets the URI for the SBL Bridge Authorization API. + /// + public Uri BridgeApiAuthorizationEndpoint { get; set; } + + /// + /// Gets or sets the scopes for Instance Read. + /// + public List InstanceReadScope { get; set; } + + /// + /// Gets or sets the cache lifetime for text resources. + /// + public int TextResourceCacheLifeTimeInSeconds { get; set; } + + /// + /// Gets or sets the cache lifetime for application title dictionary. + /// + public int AppTitleCacheLifeTimeInSeconds { get; set; } + + /// + /// Gets or sets the cache lifetime for application metadata document. + /// + public int AppMetadataCacheLifeTimeInSeconds { get; set; } + } +} diff --git a/src/Constants/Authorization/AuthzConstants.cs b/src/Constants/Authorization/AuthzConstants.cs index 594c860c..3d476a46 100644 --- a/src/Constants/Authorization/AuthzConstants.cs +++ b/src/Constants/Authorization/AuthzConstants.cs @@ -25,6 +25,11 @@ public static class AuthzConstants /// public const string POLICY_INSTANCE_COMPLETE = "InstanceComplete"; + /// + /// Policy tag for authorizing client scope. + /// + public const string POLICY_INSTANCE_SIGN = "InstanceSign"; + /// /// Policy tag for authorizing client scope. /// diff --git a/src/Controllers/Storage/DataController.cs b/src/Controllers/Storage/DataController.cs index 9f57c291..fca13cd1 100644 --- a/src/Controllers/Storage/DataController.cs +++ b/src/Controllers/Storage/DataController.cs @@ -1,20 +1,12 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using System.Web; - +using Altinn.Platform.Storage.Configuration; +using Altinn.Platform.Storage.Extensions; using Altinn.Platform.Storage.Helpers; using Altinn.Platform.Storage.Interface.Enums; using Altinn.Platform.Storage.Interface.Models; using Altinn.Platform.Storage.Repository; - -using LocalTest.Configuration; -using LocalTest.Helpers; +using Altinn.Platform.Storage.Services; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; @@ -22,6 +14,8 @@ using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; +using System.Web; + namespace Altinn.Platform.Storage.Controllers { /// @@ -33,13 +27,13 @@ public class DataController : ControllerBase { private const long RequestSizeLimit = 2000 * 1024 * 1024; - private static readonly FormOptions _defaultFormOptions = new FormOptions(); + private static readonly FormOptions _defaultFormOptions = new(); private readonly IDataRepository _dataRepository; private readonly IInstanceRepository _instanceRepository; private readonly IApplicationRepository _applicationRepository; - private readonly IInstanceEventRepository _instanceEventRepository; - + private readonly IDataService _dataService; + private readonly IInstanceEventService _instanceEventService; private readonly string _storageBaseAndHost; /// @@ -48,19 +42,22 @@ public class DataController : ControllerBase /// the data repository handler /// the instance repository /// the application repository - /// the instance event repository + /// A data service with data element related business logic. + /// An instance event service with event related business logic. /// the general settings. public DataController( IDataRepository dataRepository, IInstanceRepository instanceRepository, IApplicationRepository applicationRepository, - IInstanceEventRepository instanceEventRepository, + IDataService dataService, + IInstanceEventService instanceEventService, IOptions generalSettings) { _dataRepository = dataRepository; _instanceRepository = instanceRepository; _applicationRepository = applicationRepository; - _instanceEventRepository = instanceEventRepository; + _dataService = dataService; + _instanceEventService = instanceEventService; _storageBaseAndHost = $"{generalSettings.Value.Hostname}/storage/api/v1/"; } @@ -167,8 +164,7 @@ public async Task Get(int instanceOwnerPartyId, Guid instanceGuid, if (!dataElement.IsRead && !appOwnerRequestingElement) { - dataElement.IsRead = true; - await _dataRepository.Update(dataElement); + await _dataRepository.Update(instanceGuid, dataGuid, new Dictionary() { { "/isRead", true } }); } string storageFileName = DataElementHelper.DataFileName(instance.AppId, instanceGuid.ToString(), dataGuid.ToString()); @@ -220,7 +216,7 @@ public async Task> GetMany(int instanceOwnerPartyI dataElements : dataElements.Where(de => de.DeleteStatus == null || !de.DeleteStatus.IsHardDeleted).ToList(); - DataElementList dataElementList = new DataElementList { DataElements = filteredList }; + DataElementList dataElementList = new() { DataElements = filteredList }; return Ok(dataElementList); } @@ -265,7 +261,9 @@ public async Task> CreateAndUploadData( return applicationError; } - if (!appInfo.DataTypes.Exists(e => e.Id == dataType)) + DataType dataTypeDefinition = appInfo.DataTypes.FirstOrDefault(e => e.Id == dataType); + + if (dataTypeDefinition is null) { return BadRequest("Requested element type is not declared in application metadata"); } @@ -274,13 +272,16 @@ public async Task> CreateAndUploadData( Stream theStream = streamAndDataElement.Stream; DataElement newData = streamAndDataElement.DataElement; + newData.FileScanResult = dataTypeDefinition.EnableFileScan ? FileScanResult.Pending : FileScanResult.NotApplicable; + if (theStream == null) { return BadRequest("No data attachments found"); } newData.Filename = HttpUtility.UrlDecode(newData.Filename); - newData.Size = await _dataRepository.WriteDataToStorage(instance.Org, theStream, newData.BlobStoragePath); + (long length, DateTimeOffset blobTimestamp) = await _dataRepository.WriteDataToStorage(instance.Org, theStream, newData.BlobStoragePath); + newData.Size = length; if (User.GetOrg() == instance.Org) { @@ -290,13 +291,15 @@ public async Task> CreateAndUploadData( DataElement dataElement = await _dataRepository.Create(newData); dataElement.SetPlatformSelfLinks(_storageBaseAndHost, instanceOwnerPartyId); - await DispatchEvent(InstanceEventType.Created.ToString(), instance, dataElement); + await _dataService.StartFileScan(instance, dataTypeDefinition, dataElement, blobTimestamp, CancellationToken.None); + + await _instanceEventService.DispatchEvent(InstanceEventType.Created, instance, dataElement); return Created(dataElement.SelfLinks.Platform, dataElement); } /// - /// Replaces an existing data element whit the attached file. The StreamContent.Headers.ContentDisposition.FileName property shall be used to set the filename on client side + /// Replaces an existing data element with the attached file. The StreamContent.Headers.ContentDisposition.FileName property shall be used to set the filename on client side /// /// The party id of the instance owner. /// The id of the instance that the data element is associated with. @@ -331,12 +334,25 @@ public async Task> OverwriteData( return instanceError; } + (Application appInfo, ActionResult applicationError) = await GetApplicationAsync(instance.AppId, instance.Org); + if (appInfo == null) + { + return applicationError; + } + (DataElement dataElement, ActionResult dataElementError) = await GetDataElementAsync(instanceGuid, dataGuid); if (dataElement == null) { return dataElementError; } + DataType dataTypeDefinition = appInfo.DataTypes.FirstOrDefault(e => e.Id == dataElement.DataType); + + if (dataTypeDefinition is null) + { + return BadRequest("Requested element type is not declared in application metadata"); + } + if (dataElement.Locked) { return Conflict($"Data element {dataGuid} is locked and cannot be updated"); @@ -347,46 +363,58 @@ public async Task> OverwriteData( instanceGuid.ToString(), dataGuid.ToString()); - if (string.Equals(dataElement.BlobStoragePath, blobStoragePathName)) + if (!string.Equals(dataElement.BlobStoragePath, blobStoragePathName)) { - var streamAndDataElement = await ReadRequestAndCreateDataElementAsync(Request, dataElement.DataType, refs, generatedFromIds, instance); - Stream theStream = streamAndDataElement.Stream; - DataElement updatedData = streamAndDataElement.DataElement; + return StatusCode(500, "Storage url does not match with instance metadata"); + } - if (theStream == null) - { - return BadRequest("No data found in request body"); - } + var streamAndDataElement = await ReadRequestAndCreateDataElementAsync(Request, dataElement.DataType, refs, generatedFromIds, instance); + Stream theStream = streamAndDataElement.Stream; + DataElement updatedData = streamAndDataElement.DataElement; - DateTime changedTime = DateTime.UtcNow; + if (theStream == null) + { + return BadRequest("No data found in request body"); + } - dataElement.ContentType = updatedData.ContentType; - dataElement.Filename = HttpUtility.UrlDecode(updatedData.Filename); - dataElement.LastChangedBy = User.GetUserOrOrgId(); - dataElement.LastChanged = changedTime; - dataElement.Refs = updatedData.Refs; + DateTime changedTime = DateTime.UtcNow; - dataElement.Size = await _dataRepository.WriteDataToStorage(instance.Org, theStream, blobStoragePathName); + (long blobSize, DateTimeOffset blobTimestamp) = await _dataRepository.WriteDataToStorage(instance.Org, theStream, blobStoragePathName); - if (User.GetOrg() == instance.Org) - { - dataElement.IsRead = false; - } + var updatedProperties = new Dictionary() + { + { "/contentType", updatedData.ContentType }, + { "/filename", HttpUtility.UrlDecode(updatedData.Filename) }, + { "/lastChangedBy", User.GetUserOrOrgId() }, + { "/lastChanged", changedTime }, + { "/refs", updatedData.Refs }, + { "/references", updatedData.References }, + { "/size", blobSize } + }; - if (dataElement.Size > 0) - { - DataElement updatedElement = await _dataRepository.Update(dataElement); - updatedElement.SetPlatformSelfLinks(_storageBaseAndHost, instanceOwnerPartyId); + if (User.GetOrg() == instance.Org) + { + updatedProperties.Add("/isRead", false); + } - await DispatchEvent(InstanceEventType.Saved.ToString(), instance, updatedElement); + if (blobSize > 0) + { + FileScanResult scanResult = dataTypeDefinition.EnableFileScan ? FileScanResult.Pending : FileScanResult.NotApplicable; - return Ok(updatedElement); - } + updatedProperties.Add("/fileScanResult", scanResult); + + DataElement updatedElement = await _dataRepository.Update(instanceGuid, dataGuid, updatedProperties); + + updatedElement.SetPlatformSelfLinks(_storageBaseAndHost, instanceOwnerPartyId); - return UnprocessableEntity("Could not process attached file"); + await _dataService.StartFileScan(instance, dataTypeDefinition, dataElement, blobTimestamp, CancellationToken.None); + + await _instanceEventService.DispatchEvent(InstanceEventType.Saved, instance, updatedElement); + + return Ok(updatedElement); } - return StatusCode(500, "Storage url does not match with instance metadata"); + return UnprocessableEntity("Could not process attached file"); } /// @@ -414,11 +442,45 @@ public async Task> Update( return BadRequest("Mismatch between path and dataElement content"); } - DataElement updatedDataElement = await _dataRepository.Update(dataElement); + Dictionary propertyList = new() + { + { "/locked", dataElement.Locked }, + { "/refs", dataElement.Refs }, + { "/references", dataElement.References }, + { "/tags", dataElement.Tags }, + { "/deleteStatus", dataElement.DeleteStatus }, + { "/lastChanged", dataElement.LastChanged }, + { "/lastChangedBy", dataElement.LastChangedBy } + }; + + DataElement updatedDataElement = await _dataRepository.Update(instanceGuid, dataGuid, propertyList); return Ok(updatedDataElement); } + /// + /// Sets the file scan status for an existing data element. + /// + /// The id of the instance that the data element is associated with. + /// The id of the data element to update. + /// The file scan results for this data element. + /// The updated data element. + [Authorize(Policy = "PlatformAccess")] + [HttpPut("dataelements/{dataGuid}/filescanstatus")] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Produces("application/json")] + public async Task SetFileScanStatus( + Guid instanceGuid, + Guid dataGuid, + [FromBody] FileScanStatus fileScanStatus) + { + await _dataRepository.Update(instanceGuid, dataGuid, new Dictionary() { { "/fileScanResult", fileScanStatus.FileScanResult } }); + + return Ok(); + } + /// /// Creates a data element by reading the first multipart element or body of the request. /// @@ -437,7 +499,7 @@ public async Task> Update( MediaTypeHeaderValue mediaType = MediaTypeHeaderValue.Parse(request.ContentType); string boundary = MultipartRequestHelper.GetBoundary(mediaType, _defaultFormOptions.MultipartBoundaryLengthLimit); - MultipartReader reader = new MultipartReader(boundary, request.Body); + MultipartReader reader = new(boundary, request.Body); MultipartSection section = await reader.ReadNextSectionAsync(); theStream = section.Body; @@ -511,41 +573,20 @@ public async Task> Update( return (dataElement, null); } - private async Task DispatchEvent(string eventType, Instance instance, DataElement dataElement) - { - InstanceEvent instanceEvent = new InstanceEvent - { - EventType = eventType, - InstanceId = instance.Id, - DataId = dataElement.Id, - InstanceOwnerPartyId = instance.InstanceOwner.PartyId, - User = new PlatformUser - { - UserId = User.GetUserIdAsInt(), - AuthenticationLevel = User.GetAuthenticationLevel(), - OrgId = User.GetOrg(), - }, - ProcessInfo = instance.Process, - Created = DateTime.UtcNow, - }; - - await _instanceEventRepository.InsertInstanceEvent(instanceEvent); - } - private async Task> InitiateDelayedDelete(Instance instance, DataElement dataElement) { DateTime deletedTime = DateTime.UtcNow; - dataElement.DeleteStatus = new() + DeleteStatus deleteStatus = new() { IsHardDeleted = true, HardDeleted = deletedTime }; - await _dataRepository.Update(dataElement); + var updatedDateElement = await _dataRepository.Update(Guid.Parse(dataElement.InstanceGuid), Guid.Parse(dataElement.Id), new Dictionary() { { "/deleteStatus", deleteStatus } }); - await DispatchEvent(InstanceEventType.Deleted.ToString(), instance, dataElement); - return Ok(dataElement); + await _instanceEventService.DispatchEvent(InstanceEventType.Deleted, instance, dataElement); + return Ok(updatedDateElement); } private async Task> DeleteImmediately(Instance instance, DataElement dataElement) @@ -556,7 +597,7 @@ private async Task> DeleteImmediately(Instance instanc await _dataRepository.Delete(dataElement); - await DispatchEvent(InstanceEventType.Deleted.ToString(), instance, dataElement); + await _instanceEventService.DispatchEvent(InstanceEventType.Deleted, instance, dataElement); return Ok(dataElement); } diff --git a/src/Controllers/Storage/DataLockController.cs b/src/Controllers/Storage/DataLockController.cs index 7a3ab62e..ba1f052d 100644 --- a/src/Controllers/Storage/DataLockController.cs +++ b/src/Controllers/Storage/DataLockController.cs @@ -1,7 +1,9 @@ -using Altinn.Common.PEP.Interfaces; +#nullable enable +using Altinn.Platform.Storage.Authorization; using Altinn.Platform.Storage.Helpers; using Altinn.Platform.Storage.Interface.Models; using Altinn.Platform.Storage.Repository; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,24 +18,22 @@ public class DataLockController : ControllerBase { private readonly IInstanceRepository _instanceRepository; private readonly IDataRepository _dataRepository; - private readonly AuthorizationHelper _authorizationHelper; + private readonly IAuthorization _authorizationService; /// /// Initializes a new instance of the class /// /// the instance repository /// the data repository handler - /// - /// + /// the authorization service. public DataLockController( IInstanceRepository instanceRepository, IDataRepository dataRepository, - IPDP pdp, - ILogger authzLogger) + IAuthorization authorizationService) { _instanceRepository = instanceRepository; _dataRepository = dataRepository; - _authorizationHelper = new AuthorizationHelper(pdp, authzLogger); + _authorizationService = authorizationService; } /// @@ -42,24 +42,43 @@ public DataLockController( /// The party id of the instance owner. /// The id of the instance that the data element is associated with. /// The id of the data element to delete. - /// + /// DataElement that was locked [Authorize(Policy = AuthzConstants.POLICY_INSTANCE_WRITE)] [HttpPut] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [Produces("application/json")] public async Task> Lock(int instanceOwnerPartyId, Guid instanceGuid, Guid dataGuid) { - (DataElement dataElement, ActionResult errorMessage) = await GetDataElementAsync(instanceGuid, dataGuid); - if (dataElement == null) + (Instance? instance, ActionResult? instanceError) = await GetInstanceAsync(instanceGuid, instanceOwnerPartyId); + if (instance == null) + { + return instanceError!; + } + + DataElement? dataElement = instance.Data.Find(d => d.Id == dataGuid.ToString()); + + if (dataElement?.Locked is true) { - return errorMessage; + return Ok(dataElement); } - dataElement.Locked = true; - DataElement updatedDataElement = await _dataRepository.Update(dataElement); - return Ok(updatedDataElement); + Dictionary propertyList = new() + { + { "/locked", true } + }; + + try + { + DataElement updatedDataElement = await _dataRepository.Update(instanceGuid, dataGuid, propertyList); + return Created(updatedDataElement.Id, updatedDataElement); + } + catch (RepositoryException e) + { + return e.StatusCodeSuggestion != null ? StatusCode((int)e.StatusCodeSuggestion) : StatusCode(500); + } } /// @@ -68,7 +87,7 @@ public async Task> Lock(int instanceOwnerPartyId, Guid /// The party id of the instance owner. /// The id of the instance that the data element is associated with. /// The id of the data element to delete. - /// + /// DataElement that was unlocked [Authorize] [HttpDelete] [ProducesResponseType(StatusCodes.Status200OK)] @@ -78,30 +97,34 @@ public async Task> Lock(int instanceOwnerPartyId, Guid [Produces("application/json")] public async Task> Unlock(int instanceOwnerPartyId, Guid instanceGuid, Guid dataGuid) { - (Instance instance, _) = await GetInstanceAsync(instanceGuid, instanceOwnerPartyId); + (Instance? instance, _) = await GetInstanceAsync(instanceGuid, instanceOwnerPartyId); if (instance == null) { return Forbid(); } - bool authorized = await _authorizationHelper.AuthorizeAnyOfInstanceActions(HttpContext.User, instance, new List() { "write", "unlock", "reject" }); + bool authorized = await _authorizationService.AuthorizeAnyOfInstanceActions(instance, new List() { "write", "unlock", "reject" }); if (!authorized) { return Forbid(); } - (DataElement dataElement, ActionResult errorMessage) = await GetDataElementAsync(instanceGuid, dataGuid); - if (dataElement == null) + Dictionary propertyList = new() { - return errorMessage; + { "/locked", false } + }; + try + { + DataElement updatedDataElement = await _dataRepository.Update(instanceGuid, dataGuid, propertyList); + return Ok(updatedDataElement); + } + catch (RepositoryException e) + { + return e.StatusCodeSuggestion != null ? StatusCode((int)e.StatusCodeSuggestion) : StatusCode(500); } - dataElement.Locked = false; - - DataElement updatedDataElement = await _dataRepository.Update(dataElement); - return Ok(updatedDataElement); } - private async Task<(Instance Instance, ActionResult ErrorMessage)> GetInstanceAsync(Guid instanceGuid, int instanceOwnerPartyId) + private async Task<(Instance? Instance, ActionResult? ErrorMessage)> GetInstanceAsync(Guid instanceGuid, int instanceOwnerPartyId) { Instance instance = await _instanceRepository.GetOne(instanceOwnerPartyId, instanceGuid); @@ -112,16 +135,4 @@ public async Task> Unlock(int instanceOwnerPartyId, Gu return (instance, null); } - - private async Task<(DataElement DataElement, ActionResult ErrorMessage)> GetDataElementAsync(Guid instanceGuid, Guid dataGuid) - { - DataElement dataElement = await _dataRepository.Read(instanceGuid, dataGuid); - - if (dataElement == null) - { - return (null, NotFound($"Unable to find any data element with id: {dataGuid}.")); - } - - return (dataElement, null); - } } diff --git a/src/Controllers/Storage/SignController.cs b/src/Controllers/Storage/SignController.cs new file mode 100644 index 00000000..7f65ffd9 --- /dev/null +++ b/src/Controllers/Storage/SignController.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Altinn.Platform.Storage.Helpers; +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Altinn.Platform.Storage.Controllers +{ + /// + /// Handles operations for signing all or a subset of dataelements for an instance + /// + [Route("storage/api/v1/instances")] + [ApiController] + public class SignController : ControllerBase + { + private readonly IInstanceService _instanceService; + + /// + /// Initializes a new instance of the class + /// + /// A instance service with instance related business logic. + public SignController(IInstanceService instanceService) + { + _instanceService = instanceService; + } + + /// + /// Create signature document from listed data elements + /// + /// The party id of the instance owner. + /// The guid of the instance + /// Signrequest containing data element ids and sign status + [Authorize(Policy = AuthzConstants.POLICY_INSTANCE_SIGN)] + [HttpPost("{instanceOwnerPartyId:int}/{instanceGuid:guid}/sign")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Produces("application/json")] + public async Task Sign([FromRoute] int instanceOwnerPartyId, [FromRoute] Guid instanceGuid, [FromBody] SignRequest signRequest) + { + if (string.IsNullOrEmpty(signRequest.Signee.UserId)) + { + return Problem("The 'UserId' parameter must be defined for signee.", null, 400); + } + + await _instanceService.CreateSignDocument(instanceOwnerPartyId, instanceGuid, signRequest, User.GetUserIdAsInt().Value); + return StatusCode(201, "SignDocument is created"); + } + } +} \ No newline at end of file diff --git a/src/Extensions/Storage/ContentDispositionHeaderValueExtensions.cs b/src/Extensions/Storage/ContentDispositionHeaderValueExtensions.cs new file mode 100644 index 00000000..8772eb3d --- /dev/null +++ b/src/Extensions/Storage/ContentDispositionHeaderValueExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Altinn.Platform.Storage.Extensions +{ + /// + /// Extensions to simplify the use of . + /// + public static class ContentDispositionHeaderValueExtensions + { + /// + /// Obtain the filename value from FileNameStar or FileName if the FileNameStar property is empty. + /// Then remove any quotes and clean the filename with the AsFileName method. + /// + /// The ContentDispositionHeaderValue object to get a filename from. + /// A filename cleaned of any impurities. + public static string GetFilename(this ContentDispositionHeaderValue contentDisposition) + { + StringSegment filename = contentDisposition.FileNameStar.HasValue + ? contentDisposition.FileNameStar + : contentDisposition.FileName; + + return HeaderUtilities.RemoveQuotes(filename).Value.AsFileName(false); + } + } +} diff --git a/src/Extensions/Storage/StringExtensions.cs b/src/Extensions/Storage/StringExtensions.cs new file mode 100644 index 00000000..318aadb6 --- /dev/null +++ b/src/Extensions/Storage/StringExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Linq; + +namespace Altinn.Platform.Storage.Extensions +{ + /// + /// Extensions to facilitate sanitization of string values + /// + public static class StringExtensions + { + /// + /// Sanitize the input as a file name. + /// + /// The input variable to be sanitized. + /// Throw exception instead of replacing invalid characters with '_'. + /// A filename cleaned of any impurities. + public static string AsFileName(this string input, bool throwExceptionOnInvalidCharacters = true) + { + if (string.IsNullOrWhiteSpace(input)) + { + return input; + } + + char[] illegalFileNameCharacters = Path.GetInvalidFileNameChars(); + if (throwExceptionOnInvalidCharacters) + { + if (illegalFileNameCharacters.Any(ic => input.Any(i => ic == i))) + { + throw new ArgumentOutOfRangeException(nameof(input)); + } + + return input; + } + + return illegalFileNameCharacters.Aggregate(input, (current, c) => current.Replace(c, '_')); + } + } +} diff --git a/src/Helpers/Storage/MessageBoxInstance.cs b/src/Helpers/Storage/MessageBoxInstance.cs index 752a06bd..8d7d63e2 100644 --- a/src/Helpers/Storage/MessageBoxInstance.cs +++ b/src/Helpers/Storage/MessageBoxInstance.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Runtime.Serialization; using Altinn.Platform.Storage.Interface.Models; @@ -65,6 +66,11 @@ public class MessageBoxInstance /// public ReadStatus ReadStatus { get; set; } + /// + /// The substatus of the instance. + /// + public Substatus Substatus { get; set; } + /// /// Boolean indicating if user is allowed to delete instance. /// @@ -81,7 +87,12 @@ public class MessageBoxInstance public bool AuthorizedForWrite { get; set; } /// - /// DateTime the instance was archived + /// Gets or sets a value indicating whether user is authorized to sign the data elements on instance. + /// + public bool AuthorizedForSign { get; set; } + + /// + /// DateTime the instance was archived /// public DateTime? ArchivedDateTime { get; set; } @@ -89,6 +100,18 @@ public class MessageBoxInstance /// DateTime the instance was deleted /// public DateTime? DeletedDateTime { get; set; } + + /// + /// Presentation text is a dynamically created text that have been retrieved from data elements + /// and stored on the instance. The text can be used to make it easy to separate instaces from + /// the same app when displayed by the portal message box. + /// + public string PresentationText { get; set; } + + /// + /// Dictionary holding metadata about the instance. + /// + public Dictionary DataValues { get; set; } } /// @@ -108,4 +131,20 @@ public enum DeleteStatusType : int [EnumMember] SoftDeleted = 1 } + + /// + /// Status containing label and description. + /// + public class Substatus + { + /// + /// A text key pointing to a short description of the substatus. + /// + public string Label { get; set; } + + /// + /// A text key pointing to a longer description of the substatus. + /// + public string Description { get; set; } + } } diff --git a/src/LocalTest.csproj b/src/LocalTest.csproj index 1c2bfe32..661e60a5 100644 --- a/src/LocalTest.csproj +++ b/src/LocalTest.csproj @@ -8,9 +8,9 @@ - + - + diff --git a/src/Models/ServiceError.cs b/src/Models/ServiceError.cs new file mode 100644 index 00000000..c8f715b4 --- /dev/null +++ b/src/Models/ServiceError.cs @@ -0,0 +1,36 @@ +namespace Altinn.Platform.Storage.Models +{ + /// + /// A class representing a service error object used to transfere error information from service to controller. + /// + public class ServiceError + { + /// + /// The error code + /// + /// An error code translates directly into an HTTP status code + public int ErrorCode { get; private set; } + + /// + /// The error message + /// + public string ErrorMessage { get; private set; } + + /// + /// Create a new instance of a service error + /// + public ServiceError(int errorCode, string errorMessage) + { + ErrorCode = errorCode; + ErrorMessage = errorMessage; + } + + /// + /// Create a new instance of a service error + /// + public ServiceError(int errorCode) + { + ErrorCode = errorCode; + } + } +} diff --git a/src/Models/Storage/FileScanRequest.cs b/src/Models/Storage/FileScanRequest.cs new file mode 100644 index 00000000..9bdfb9f8 --- /dev/null +++ b/src/Models/Storage/FileScanRequest.cs @@ -0,0 +1,44 @@ +using System; + +namespace Altinn.Platform.Storage.Models +{ + /// + /// This class represents a request to perform a file scan. Instances is sent to a queue + /// handled by the FileScan system. + /// + public class FileScanRequest + { + /// + /// Gets or sets the unique id of the data element. + /// + public string DataElementId { get; set; } + + /// + /// Gets or sets the unique id of the parent instance of the data element. + /// + /// + /// The instance id contains both the instance owner party id and the unique instance guid. + /// + public string InstanceId { get; set; } + + /// + /// Gets or sets the name of the data element (file) + /// + public string Filename { get; set; } + + /// + /// Gets or sets the time when blob was saved. + /// + public DateTimeOffset Timestamp { get; set; } + + /// + /// Gets or sets the path to blob storage. Might be nullified in export. + /// + public string BlobStoragePath { get; set; } + + /// + /// Gets or sets the application owner identifier + /// + public string Org { get; set; } + } +} diff --git a/src/Models/Storage/RepositoryException.cs b/src/Models/Storage/RepositoryException.cs new file mode 100644 index 00000000..ddda4211 --- /dev/null +++ b/src/Models/Storage/RepositoryException.cs @@ -0,0 +1,36 @@ +using System; +using System.Net; + +namespace Altinn.Platform.Storage.Repository; + +/// +/// Exception thrown by the repository layer +/// +public class RepositoryException: Exception +{ + /// + /// Suggested status code to return to the client + /// + public virtual HttpStatusCode? StatusCodeSuggestion { get; } + + /// + /// Create RepositoryException with message and optional suggested status code + /// + /// Exception message + /// Optional suggested to return to the client + public RepositoryException(string message, HttpStatusCode? statusCodeSuggestion = null) : base(message) + { + StatusCodeSuggestion = statusCodeSuggestion; + } + + /// + /// Create RepositoryException with message, inner exception and optional suggested status code + /// + /// Exception message + /// Inner exception + /// Optional suggested to return to the client + public RepositoryException(string message, Exception innerException, HttpStatusCode? statusCodeSuggestion = null) : base(message, innerException) + { + StatusCodeSuggestion = statusCodeSuggestion; + } +} diff --git a/src/Services/Storage/Implementation/ApplicationService.cs b/src/Services/Storage/Implementation/ApplicationService.cs new file mode 100644 index 00000000..6af6b32b --- /dev/null +++ b/src/Services/Storage/Implementation/ApplicationService.cs @@ -0,0 +1,42 @@ +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Models; +using Altinn.Platform.Storage.Repository; + +namespace Altinn.Platform.Storage.Services +{ + /// + /// Service class with business logic related to applications. + /// + public class ApplicationService : IApplicationService + { + private readonly IApplicationRepository _applicationRepository; + + /// + /// Initializes a new instance of the class. + /// + public ApplicationService(IApplicationRepository applicationRepository) + { + _applicationRepository = applicationRepository; + } + + /// + public async Task<(bool IsValid, ServiceError ServiceError)> ValidateDataTypeForApp(string org, string appId, string dataType) + { + Application application = await _applicationRepository.FindOne(appId, org); + + if (application == null) + { + return (false, new ServiceError(404, $"Cannot find application {appId} in storage")); + } + + DataType dataTypeDefinition = application.DataTypes.FirstOrDefault(e => e.Id == dataType); + + if (dataTypeDefinition is null) + { + return (false, new ServiceError(405, $"DataType {dataType} is not declared in application metadata for app {appId}")); + } + + return (true, null); + } + } +} \ No newline at end of file diff --git a/src/Services/Storage/Implementation/AuthorizationService.cs b/src/Services/Storage/Implementation/AuthorizationService.cs new file mode 100644 index 00000000..d672d4bc --- /dev/null +++ b/src/Services/Storage/Implementation/AuthorizationService.cs @@ -0,0 +1,423 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using Altinn.Authorization.ABAC.Xacml.JsonProfile; +using Altinn.Common.PEP.Constants; +using Altinn.Common.PEP.Helpers; +using Altinn.Common.PEP.Interfaces; +using Altinn.Platform.Storage.Helpers; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.Logging; + +namespace Altinn.Platform.Storage.Authorization +{ + /// + /// Implementation of the Storage Authorization service + /// + public class AuthorizationService : IAuthorization + { + private readonly IPDP _pdp; + private readonly IClaimsPrincipalProvider _claimsPrincipalProvider; + private readonly ILogger _logger; + + private const string XacmlResourceTaskId = "urn:altinn:task"; + private const string XacmlResourceEndId = "urn:altinn:end-event"; + private const string XacmlResourceActionId = "urn:oasis:names:tc:xacml:1.0:action:action-id"; + private const string DefaultIssuer = "Altinn"; + private const string DefaultType = "string"; + private const string SubjectId = "s"; + private const string ActionId = "a"; + private const string ResourceId = "r"; + + /// + /// Initializes a new instance of the class. + /// + /// Policy decision point + /// A service providing access to the current . + /// The logger + public AuthorizationService(IPDP pdp, IClaimsPrincipalProvider claimsPrincipalProvider, ILogger logger) + { + _pdp = pdp; + _claimsPrincipalProvider = claimsPrincipalProvider; + _logger = logger; + } + + /// > + public async Task> AuthorizeMesseageBoxInstances(List instances, bool includeInstantiate) + { + if (instances.Count <= 0) + { + return new List(); + } + + List authorizedInstanceList = new(); + List actionTypes = new() { "read", "write", "delete" }; + + if (includeInstantiate) + { + actionTypes.Add("instantiate"); + } + + if (instances.Exists(i => "Signing".Equals(i.Process?.CurrentTask?.AltinnTaskType.ToString(), StringComparison.InvariantCultureIgnoreCase))) + { + actionTypes.Add("sign"); + } + + ClaimsPrincipal user = _claimsPrincipalProvider.GetUser(); + XacmlJsonRequestRoot xacmlJsonRequest = CreateMultiDecisionRequest(user, instances, actionTypes); + + _logger.LogInformation("// AuthorizationHelper // AuthorizeMsgBoxInstances // xacmlJsonRequest: {request}", JsonSerializer.Serialize(xacmlJsonRequest)); + XacmlJsonResponse response = await _pdp.GetDecisionForRequest(xacmlJsonRequest); + _logger.LogInformation("// AuthorizationHelper // AuthorizeMsgBoxInstances // xacmlJsonResponse: {response}", JsonSerializer.Serialize(response)); + foreach (XacmlJsonResult result in response.Response.Where(result => DecisionHelper.ValidateDecisionResult(result, user))) + { + string instanceId = string.Empty; + string actiontype = string.Empty; + + // Loop through all attributes in Category from the response + foreach (var attributes in result.Category.Select(c => c.Attribute)) + { + foreach (var attribute in attributes) + { + if (attribute.AttributeId.Equals(XacmlResourceActionId)) + { + actiontype = attribute.Value; + } + + if (attribute.AttributeId.Equals(AltinnXacmlUrns.InstanceId)) + { + instanceId = attribute.Value; + } + } + } + + Instance authorizedInstance = instances.First(i => i.Id == instanceId); + + MessageBoxInstance authorizedMessageBoxInstance = + authorizedInstanceList.FirstOrDefault(i => i.Id.Equals(authorizedInstance.Id.Split("/")[1])); + if (authorizedMessageBoxInstance is null) + { + authorizedMessageBoxInstance = InstanceHelper.ConvertToMessageBoxInstance(authorizedInstance); + authorizedInstanceList.Add(authorizedMessageBoxInstance); + } + + switch (actiontype) + { + case "write": + authorizedMessageBoxInstance.AuthorizedForWrite = true; + break; + case "delete": + authorizedMessageBoxInstance.AllowDelete = true; + break; + case "instantiate": + authorizedMessageBoxInstance.AllowNewCopy = true; + break; + case "sign": + authorizedMessageBoxInstance.AuthorizedForSign = true; + break; + case "read": + break; + } + } + + return authorizedInstanceList; + } + + /// > + public async Task AuthorizeInstanceAction(Instance instance, string action, string task = null) + { + string org = instance.Org; + string app = instance.AppId.Split('/')[1]; + int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId); + XacmlJsonRequestRoot request; + + ClaimsPrincipal user = _claimsPrincipalProvider.GetUser(); + if (instance.Id == null) + { + request = DecisionHelper.CreateDecisionRequest(org, app, user, action, instanceOwnerPartyId, null); + } + else + { + Guid instanceGuid = Guid.Parse(instance.Id.Split('/')[1]); + request = DecisionHelper.CreateDecisionRequest(org, app, user, action, instanceOwnerPartyId, instanceGuid, task); + } + + XacmlJsonResponse response = await _pdp.GetDecisionForRequest(request); + + if (response?.Response == null) + { + _logger.LogInformation("// Authorization Helper // Authorize instance action failed for request: {request}.", JsonSerializer.Serialize(request)); + return false; + } + + bool authorized = DecisionHelper.ValidatePdpDecision(response.Response, user); + return authorized; + } + + /// > + public async Task AuthorizeAnyOfInstanceActions(Instance instance, List actions) + { + if (actions.Count == 0) + { + return false; + } + + ClaimsPrincipal user = _claimsPrincipalProvider.GetUser(); + XacmlJsonRequestRoot request = CreateMultiDecisionRequest(user, new List() { instance }, actions); + + _logger.LogDebug("// Authorization Helper // AuthorizeAnyOfInstanceActions // request: {Request}", JsonSerializer.Serialize(request)); + XacmlJsonResponse response = await _pdp.GetDecisionForRequest(request); + + _logger.LogDebug("// Authorization Helper // AuthorizeAnyOfInstanceActions // response: {Response}", JsonSerializer.Serialize(response)); + if (response?.Response != null) + { + return response.Response.Any(result => DecisionHelper.ValidateDecisionResult(result, user)); + } + + _logger.LogInformation("// Authorization Helper // Authorize instance action failed for request: {request}.", JsonSerializer.Serialize(request)); + return false; + } + + /// > + public async Task> AuthorizeInstances(List instances) + { + if (instances.Count <= 0) + { + return instances; + } + + List authorizedInstanceList = new(); + List actionTypes = new() { "read" }; + + ClaimsPrincipal user = _claimsPrincipalProvider.GetUser(); + XacmlJsonRequestRoot xacmlJsonRequest = CreateMultiDecisionRequest(user, instances, actionTypes); + XacmlJsonResponse response = await _pdp.GetDecisionForRequest(xacmlJsonRequest); + + foreach (XacmlJsonResult result in response.Response.Where(result => DecisionHelper.ValidateDecisionResult(result, user))) + { + string instanceId = string.Empty; + + // Loop through all attributes in Category from the response + foreach (var attributes in result.Category.Select(category => category.Attribute)) + { + foreach (var attribute in attributes.Where(a => a.AttributeId.Equals(AltinnXacmlUrns.InstanceId))) + { + instanceId = attribute.Value; + } + } + + Instance instance = instances.FirstOrDefault(i => i.Id == instanceId); + authorizedInstanceList.Add(instance); + } + + return authorizedInstanceList; + } + + /// > + public bool UserHasRequiredScope(List requiredScope) + { + ClaimsPrincipal user = _claimsPrincipalProvider.GetUser(); + string contextScope = user.Identities? + .FirstOrDefault(i => i.AuthenticationType != null && i.AuthenticationType.Equals("AuthenticationTypes.Federation")) + ?.Claims + .Where(c => c.Type.Equals("urn:altinn:scope")) + ?.Select(c => c.Value).FirstOrDefault(); + + contextScope ??= user.Claims.Where(c => c.Type.Equals("scope")).Select(c => c.Value).FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(contextScope)) + { + return requiredScope.Any(scope => contextScope.Contains(scope, StringComparison.InvariantCultureIgnoreCase)); + } + + return false; + } + + /// > + public async Task GetDecisionForRequest(XacmlJsonRequestRoot xacmlJsonRequest) + { + return await _pdp.GetDecisionForRequest(xacmlJsonRequest); + } + + /// + /// Creates multi decision request. + /// + public static XacmlJsonRequestRoot CreateMultiDecisionRequest(ClaimsPrincipal user, List instances, List actionTypes) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + XacmlJsonRequest request = new() + { + AccessSubject = new List() + }; + + request.AccessSubject.Add(CreateMultipleSubjectCategory(user.Claims)); + request.Action = CreateMultipleActionCategory(actionTypes); + request.Resource = CreateMultipleResourceCategory(instances); + request.MultiRequests = CreateMultiRequestsCategory(request.AccessSubject, request.Action, request.Resource); + + XacmlJsonRequestRoot jsonRequest = new() { Request = request }; + + return jsonRequest; + } + + /// + /// Replaces Resource attributes with data from instance. Add all relevant values so PDP have it all + /// + /// The JSON Request + /// The instance + public static void EnrichXacmlJsonRequest(XacmlJsonRequestRoot jsonRequest, Instance instance) + { + XacmlJsonCategory resourceCategory = new() { Attribute = new List() }; + + var instanceProps = GetInstanceProperties(instance); + + if (instanceProps.Task != null) + { + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(XacmlResourceTaskId, instanceProps.Task, DefaultType, DefaultIssuer)); + } + else if (instance.Process?.EndEvent != null) + { + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(XacmlResourceEndId, instance.Process.EndEvent, DefaultType, DefaultIssuer)); + } + + if (!string.IsNullOrWhiteSpace(instanceProps.InstanceId)) + { + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.InstanceId, instanceProps.InstanceId, DefaultType, DefaultIssuer, true)); + } + + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.PartyId, instanceProps.InstanceOwnerPartyId, DefaultType, DefaultIssuer)); + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.OrgId, instanceProps.Org, DefaultType, DefaultIssuer)); + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.AppId, instanceProps.App, DefaultType, DefaultIssuer)); + + // Replaces the current Resource attributes + jsonRequest.Request.Resource = new List + { + resourceCategory + }; + } + + private static (string InstanceId, string InstanceGuid, string Task, string InstanceOwnerPartyId, string Org, string App) GetInstanceProperties(Instance instance) + { + string instanceId = instance.Id.Contains('/') ? instance.Id : null; + string instanceGuid = instance.Id.Contains('/') ? instance.Id.Split("/")[1] : instance.Id; + string task = instance.Process?.CurrentTask?.ElementId; + string instanceOwnerPartyId = instance.InstanceOwner.PartyId; + string org = instance.Org; + string app = instance.AppId.Split("/")[1]; + + return (instanceId, instanceGuid, task, instanceOwnerPartyId, org, app); + } + + private static XacmlJsonCategory CreateMultipleSubjectCategory(IEnumerable claims) + { + XacmlJsonCategory subjectAttributes = DecisionHelper.CreateSubjectCategory(claims); + subjectAttributes.Id = SubjectId + "1"; + + return subjectAttributes; + } + + private static List CreateMultipleActionCategory(List actionTypes) + { + List actionCategories = new(); + int counter = 1; + + foreach (string actionType in actionTypes) + { + XacmlJsonCategory actionCategory; + actionCategory = DecisionHelper.CreateActionCategory(actionType, true); + actionCategory.Id = ActionId + counter.ToString(); + actionCategories.Add(actionCategory); + counter++; + } + + return actionCategories; + } + + private static List CreateMultipleResourceCategory(List instances) + { + List resourcesCategories = new(); + int counter = 1; + + foreach (Instance instance in instances) + { + XacmlJsonCategory resourceCategory = new() { Attribute = new List() }; + + var instanceProps = GetInstanceProperties(instance); + + if (instanceProps.Task != null) + { + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(XacmlResourceTaskId, instanceProps.Task, DefaultType, DefaultIssuer)); + } + else if (instance.Process?.EndEvent != null) + { + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(XacmlResourceEndId, instance.Process.EndEvent, DefaultType, DefaultIssuer)); + } + + if (!string.IsNullOrWhiteSpace(instanceProps.InstanceId)) + { + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.InstanceId, instanceProps.InstanceId, DefaultType, DefaultIssuer, true)); + } + else if (!string.IsNullOrEmpty(instanceProps.InstanceGuid)) + { + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.InstanceId, instanceProps.InstanceOwnerPartyId + "/" + instanceProps.InstanceGuid, DefaultType, DefaultIssuer, true)); + } + + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.PartyId, instanceProps.InstanceOwnerPartyId, DefaultType, DefaultIssuer)); + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.OrgId, instanceProps.Org, DefaultType, DefaultIssuer)); + resourceCategory.Attribute.Add(DecisionHelper.CreateXacmlJsonAttribute(AltinnXacmlUrns.AppId, instanceProps.App, DefaultType, DefaultIssuer)); + resourceCategory.Id = ResourceId + counter.ToString(); + resourcesCategories.Add(resourceCategory); + counter++; + } + + return resourcesCategories; + } + + private static XacmlJsonMultiRequests CreateMultiRequestsCategory(List subjects, List actions, List resources) + { + List subjectIds = subjects.Select(s => s.Id).ToList(); + List actionIds = actions.Select(a => a.Id).ToList(); + List resourceIds = resources.Select(r => r.Id).ToList(); + + XacmlJsonMultiRequests multiRequests = new() + { + RequestReference = CreateRequestReference(subjectIds, actionIds, resourceIds) + }; + + return multiRequests; + } + + private static List CreateRequestReference(List subjectIds, List actionIds, List resourceIds) + { + List references = new(); + + foreach (string resourceId in resourceIds) + { + foreach (string actionId in actionIds) + { + foreach (string subjectId in subjectIds) + { + XacmlJsonRequestReference reference = new(); + List referenceId = new() + { + subjectId, + actionId, + resourceId + }; + reference.ReferenceId = referenceId; + references.Add(reference); + } + } + } + + return references; + } + } +} diff --git a/src/Services/Storage/Implementation/ClaimsPrincipalProvider.cs b/src/Services/Storage/Implementation/ClaimsPrincipalProvider.cs new file mode 100644 index 00000000..ceae4bad --- /dev/null +++ b/src/Services/Storage/Implementation/ClaimsPrincipalProvider.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; + +using Microsoft.AspNetCore.Http; + +namespace Altinn.Platform.Storage.Authorization +{ + /// + /// Represents an implementation of using the HttpContext to obtain + /// the current claims principal needed for the application to make calls to other services. + /// + [ExcludeFromCodeCoverage] + public class ClaimsPrincipalProvider : IClaimsPrincipalProvider + { + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// The http context accessor + public ClaimsPrincipalProvider(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + /// + public ClaimsPrincipal GetUser() + { + return _httpContextAccessor.HttpContext.User; + } + } +} diff --git a/src/Services/Storage/Implementation/DataRepository.cs b/src/Services/Storage/Implementation/DataRepository.cs index 460b37fd..7bfeb121 100644 --- a/src/Services/Storage/Implementation/DataRepository.cs +++ b/src/Services/Storage/Implementation/DataRepository.cs @@ -1,9 +1,8 @@ -using System; -using System.Collections.Generic; -using System.IO; using System.Text; -using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Nodes; +using Altinn.Platform.Storage.Interface.Enums; using Altinn.Platform.Storage.Interface.Models; using Altinn.Platform.Storage.Repository; @@ -94,7 +93,93 @@ public async Task Update(DataElement dataElement) return dataElement; } - public async Task WriteDataToStorage(string org, Stream stream, string blobStoragePath) + public async Task Update(Guid instanceGuid, Guid dataElementId, Dictionary propertylist) + { + string path = GetDataPath($"{instanceGuid}", $"{dataElementId}"); + + if (File.Exists(path)) + { + string content = await ReadFileAsString(path); + DataElement dataElement = JsonConvert.DeserializeObject(content); + + foreach (KeyValuePair property in propertylist) + { + string propName = property.Key.Trim('/'); + switch (propName) + { + case "contentType": + { + dataElement.ContentType = (string)property.Value; + break; + } + case "deleteStatus": + { + dataElement.DeleteStatus = (DeleteStatus)property.Value; + break; + } + case "filename": + { + dataElement.Filename = (string)property.Value; + break; + } + case "fileScanResult": + { + dataElement.FileScanResult = (FileScanResult)property.Value; + break; + } + case "isRead": + { + dataElement.IsRead = (bool)property.Value; + break; + } + case "lastChangedBy": + { + dataElement.LastChangedBy = (string)property.Value; + break; + } + case "lastChanged": + { + dataElement.LastChanged = (DateTime)property.Value; + break; + } + case "locked": + { + dataElement.Locked = (bool)property.Value; + break; + } + case "refs": + { + dataElement.Refs = (List)property.Value; + break; + } + case "references": + { + dataElement.References = (List)property.Value; + break; + } + case "size": + { + dataElement.Size = (long)property.Value; + break; + } + case "tags": + { + dataElement.Tags = (List)property.Value; + break; + } + default: + break; + } + } + await Update(dataElement); + + return dataElement; + } + + throw new RepositoryException("Error occured"); + } + + public async Task<(long ContentLength, DateTimeOffset LastModified)> WriteDataToStorage(string org, Stream stream, string blobStoragePath) { string filePath = GetFilePath(blobStoragePath); if (!Directory.Exists(Path.GetDirectoryName(filePath))) @@ -117,7 +202,7 @@ private string GetDataPath(string instanceId, string dataId) private string GetDataForInstanceFolder(string instanceId) { - return Path.Combine(GetDataCollectionFolder() + instanceId.Replace("/", "_") + "/"); + return Path.Combine(GetDataCollectionFolder() + instanceId.Replace("/", "_") + "/"); } private string GetDataCollectionFolder() @@ -165,7 +250,7 @@ private static async Task WriteToFile(string path, string content) await WriteToFile(path, stream); } - private static async Task WriteToFile(string path, Stream stream) + private static async Task<(long ContentLength, DateTimeOffset LastModified)> WriteToFile(string path, Stream stream) { if (stream is not MemoryStream memStream) { @@ -195,7 +280,7 @@ private static async Task WriteToFile(string path, Stream stream) } } - private static async Task WriteToFileInternal(string path, MemoryStream stream) + private static async Task<(long ContentLength, DateTimeOffset LastModified)> WriteToFileInternal(string path, MemoryStream stream) { long fileSize; await using (FileStream streamToWriteTo = File.Open(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) @@ -205,7 +290,12 @@ private static async Task WriteToFileInternal(string path, MemoryStream st fileSize = streamToWriteTo.Length; } - return fileSize; + return (fileSize, DateTime.UtcNow); + } + + public Task>> ReadAllForMultiple(List instanceGuids) + { + throw new NotImplementedException(); } } -} +} \ No newline at end of file diff --git a/src/Services/Storage/Implementation/DataService.cs b/src/Services/Storage/Implementation/DataService.cs new file mode 100644 index 00000000..f5e96638 --- /dev/null +++ b/src/Services/Storage/Implementation/DataService.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Altinn.Platform.Storage.Clients; +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Models; +using Altinn.Platform.Storage.Repository; + +namespace Altinn.Platform.Storage.Services +{ + /// + /// Service class with business logic related to data blobs and their metadata documents. + /// + public class DataService : IDataService + { + private readonly IDataRepository _dataRepository; + + /// + /// Initializes a new instance of the class. + /// + public DataService(IDataRepository dataRepository) + { + _dataRepository = dataRepository; + } + + /// + public Task StartFileScan(Instance instance, DataType dataType, DataElement dataElement, DateTimeOffset blobTimestamp, CancellationToken ct) + { + return Task.CompletedTask; + } + + /// + public async Task<(string FileHash, ServiceError ServiceError)> GenerateSha256Hash(string org, Guid instanceGuid, Guid dataElementId) + { + DataElement dataElement = await _dataRepository.Read(instanceGuid, dataElementId); + if (dataElement == null) + { + return (null, new ServiceError(404, $"DataElement not found, dataElementId: {dataElementId}")); + } + + Stream filestream = await _dataRepository.ReadDataFromStorage(org, dataElement.BlobStoragePath); + if (filestream == null || !filestream.CanRead) + { + return (null, new ServiceError(404, $"Failed reading file, dataElementId: {dataElementId}")); + } + + return (CalculateSha256Hash(filestream), null); + } + + /// + public async Task UploadDataAndCreateDataElement(string org, Stream stream, DataElement dataElement) + { + (long length, DateTimeOffset blobTimestamp) = await _dataRepository.WriteDataToStorage(org, stream, dataElement.BlobStoragePath); + dataElement.Size = length; + + await _dataRepository.Create(dataElement); + } + + private string CalculateSha256Hash(Stream fileStream) + { + using (SHA256 sha256 = SHA256.Create()) + { + byte[] hashBytes = sha256.ComputeHash(fileStream); + return Convert.ToBase64String(hashBytes); + } + } + } +} diff --git a/src/Services/Storage/Implementation/InstanceEventService.cs b/src/Services/Storage/Implementation/InstanceEventService.cs new file mode 100644 index 00000000..438dab17 --- /dev/null +++ b/src/Services/Storage/Implementation/InstanceEventService.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; + +using Altinn.Platform.Storage.Helpers; +using Altinn.Platform.Storage.Interface.Enums; +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Repository; + +using Microsoft.AspNetCore.Http; + +namespace Altinn.Platform.Storage.Services +{ + /// + /// Service class with business logic related to instanced events. + /// + public class InstanceEventService : IInstanceEventService + { + private readonly IInstanceEventRepository _repository; + private readonly IHttpContextAccessor _contextAccessor; + + /// + /// Initializes a new instance of the class. + /// + public InstanceEventService(IInstanceEventRepository repository, IHttpContextAccessor contextAccessor) + { + _repository = repository; + _contextAccessor = contextAccessor; + } + + /// + public async Task DispatchEvent(InstanceEventType eventType, Instance instance) + { + var user = _contextAccessor.HttpContext.User; + + InstanceEvent instanceEvent = new() + { + EventType = eventType.ToString(), + InstanceId = instance.Id, + InstanceOwnerPartyId = instance.InstanceOwner.PartyId, + User = new PlatformUser + { + UserId = user.GetUserIdAsInt(), + AuthenticationLevel = user.GetAuthenticationLevel(), + OrgId = user.GetOrg(), + }, + + ProcessInfo = instance.Process, + Created = DateTime.UtcNow, + }; + + await _repository.InsertInstanceEvent(instanceEvent); + } + + /// + public async Task DispatchEvent(InstanceEventType eventType, Instance instance, DataElement dataElement) + { + var user = _contextAccessor.HttpContext.User; + + InstanceEvent instanceEvent = new() + { + EventType = eventType.ToString(), + InstanceId = instance.Id, + DataId = dataElement.Id, + InstanceOwnerPartyId = instance.InstanceOwner.PartyId, + User = new PlatformUser + { + UserId = user.GetUserIdAsInt(), + AuthenticationLevel = user.GetAuthenticationLevel(), + OrgId = user.GetOrg(), + }, + ProcessInfo = instance.Process, + Created = DateTime.UtcNow, + }; + + await _repository.InsertInstanceEvent(instanceEvent); + } + } +} diff --git a/src/Services/Storage/Implementation/InstanceService.cs b/src/Services/Storage/Implementation/InstanceService.cs new file mode 100644 index 00000000..81741b8f --- /dev/null +++ b/src/Services/Storage/Implementation/InstanceService.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Altinn.Platform.Storage.Helpers; +using Altinn.Platform.Storage.Interface.Enums; +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Models; +using Altinn.Platform.Storage.Repository; +using Newtonsoft.Json; + +namespace Altinn.Platform.Storage.Services +{ + /// + /// Service class with business logic related to instances. + /// + public class InstanceService : IInstanceService + { + private readonly IInstanceRepository _instanceRepository; + private readonly IDataService _dataService; + private readonly IApplicationService _applicationService; + private readonly IInstanceEventService _instanceEventService; + + /// + /// Initializes a new instance of the class. + /// + public InstanceService(IInstanceRepository instanceRepository, IDataService dataService, IApplicationService applicationService, IInstanceEventService instanceEventService) + { + _instanceRepository = instanceRepository; + _dataService = dataService; + _applicationService = applicationService; + _instanceEventService = instanceEventService; + } + + /// + public async Task<(bool Created, ServiceError ServiceError)> CreateSignDocument(int instanceOwnerPartyId, Guid instanceGuid, SignRequest signRequest, int userId) + { + Instance instance = await _instanceRepository.GetOne(instanceOwnerPartyId, instanceGuid); + if (instance == null) + { + return (false, new ServiceError(404, "Instance not found")); + } + + (bool validDataType, ServiceError serviceError) = await _applicationService.ValidateDataTypeForApp(instance.Org, instance.AppId, signRequest.SignatureDocumentDataType); + if (!validDataType) + { + return (false, serviceError); + } + + SignDocument signDocument = GetSignDocument(instanceGuid, signRequest); + + foreach (SignRequest.DataElementSignature dataElementSignature in signRequest.DataElementSignatures) + { + (string base64Sha256Hash, serviceError) = await _dataService.GenerateSha256Hash(instance.Org, instanceGuid, Guid.Parse(dataElementSignature.DataElementId)); + if (string.IsNullOrEmpty(base64Sha256Hash)) + { + return (false, serviceError); + } + + signDocument.DataElementSignatures.Add(new SignDocument.DataElementSignature + { + DataElementId = dataElementSignature.DataElementId, + Sha256Hash = base64Sha256Hash, + Signed = dataElementSignature.Signed + }); + } + + DataElement dataElement = DataElementHelper.CreateDataElement( + signRequest.SignatureDocumentDataType, + null, + instance, + signDocument.SignedTime, + "application/json", + $"{signRequest.SignatureDocumentDataType}.json", + 0, + userId.ToString(), + null); + + signDocument.Id = dataElement.Id; + + using (MemoryStream fileStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(signDocument, Formatting.Indented)))) + { + await _dataService.UploadDataAndCreateDataElement(instance.Org, fileStream, dataElement); + } + + await _instanceEventService.DispatchEvent(InstanceEventType.Signed, instance); + return (true, null); + } + + private SignDocument GetSignDocument(Guid instanceGuid, SignRequest signRequest) + { + SignDocument signDocument = new SignDocument + { + InstanceGuid = instanceGuid.ToString(), + SignedTime = DateTime.UtcNow, + SigneeInfo = new Signee + { + UserId = signRequest.Signee.UserId, + PersonNumber = signRequest.Signee.PersonNumber, + OrganisationNumber = signRequest.Signee.OrganisationNumber + } + }; + + return signDocument; + } + } +} \ No newline at end of file diff --git a/src/Services/Storage/Implementation/StorageAccessHandler.cs b/src/Services/Storage/Implementation/StorageAccessHandler.cs new file mode 100644 index 00000000..b3b47cdd --- /dev/null +++ b/src/Services/Storage/Implementation/StorageAccessHandler.cs @@ -0,0 +1,192 @@ +using System; +using System.Text; +using System.Threading.Tasks; + +using Altinn.Authorization.ABAC.Xacml.JsonProfile; +using Altinn.Common.PEP.Authorization; +using Altinn.Common.PEP.Configuration; +using Altinn.Common.PEP.Constants; +using Altinn.Common.PEP.Helpers; +using Altinn.Common.PEP.Interfaces; +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Repository; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Newtonsoft.Json; + +namespace Altinn.Platform.Storage.Authorization +{ + /// + /// AuthorizationHandler that is created for handling access to storage and supporting caching of decisions from PDP + /// Authorizes based om AppAccessRequirement and app id from route + /// for details about authorization + /// in asp.net core + /// + public class StorageAccessHandler : AuthorizationHandler + { + private readonly IInstanceRepository _instanceRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IPDP _pdp; + private readonly ILogger _logger; + private readonly IMemoryCache _memoryCache; + private readonly PepSettings _pepSettings; + + /// + /// Initializes a new instance of the class. + /// + /// The http context accessor + /// The pdp + /// The settings for pep + /// The logger. + /// The instance repository + /// The memory cache + public StorageAccessHandler( + IHttpContextAccessor httpContextAccessor, + IPDP pdp, + IOptions pepSettings, + ILogger logger, + IInstanceRepository instanceRepository, + IMemoryCache memoryCache) + { + _httpContextAccessor = httpContextAccessor; + _pdp = pdp; + _logger = logger; + _pepSettings = pepSettings.Value; + _instanceRepository = instanceRepository; + _memoryCache = memoryCache; + } + + /// + /// This method authorize access bases on context and requirement + /// Is triggered by annotation on MVC action and setup in startup. + /// + /// The context + /// The requirement + /// A Task + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, AppAccessRequirement requirement) + { + XacmlJsonRequestRoot request = DecisionHelper.CreateDecisionRequest(context, requirement, _httpContextAccessor.HttpContext.GetRouteData()); + + _logger.LogInformation("// Storage PEP // AppAccessHandler // Request sent: {request}", JsonConvert.SerializeObject(request)); + + XacmlJsonResponse response; + + // Get The instance to enrich the request + Instance instance = await GetInstance(request); + if (instance != null) + { + AuthorizationService.EnrichXacmlJsonRequest(request, instance); + response = await GetDecisionForRequest(request); + } + else + { + response = await _pdp.GetDecisionForRequest(request); + } + + if (response?.Response == null) + { + throw new Exception("Response is null from PDP"); + } + + if (!DecisionHelper.ValidatePdpDecision(response.Response, context.User)) + { + context.Fail(); + } + + context.Succeed(requirement); + await Task.CompletedTask; + } + + private async Task GetDecisionForRequest(XacmlJsonRequestRoot request) + { + string cacheKey = GetCacheKeyForDecisionRequest(request); + + if (!_memoryCache.TryGetValue(cacheKey, out XacmlJsonResponse response)) + { + // Key not in cache, so get decisin from PDP. + response = await _pdp.GetDecisionForRequest(request); + + // Set the cache options + MemoryCacheEntryOptions cacheEntryOptions = new MemoryCacheEntryOptions() + .SetPriority(CacheItemPriority.High) + .SetAbsoluteExpiration(new TimeSpan(0, 0, 30)); + + _memoryCache.Set(cacheKey, response, cacheEntryOptions); + } + + return response; + } + + /// + /// Get the instance from database based on request + /// + /// The request + /// The instance identified by information in the request. + private async Task GetInstance(XacmlJsonRequestRoot request) + { + string instanceId = string.Empty; + foreach (XacmlJsonCategory category in request.Request.Resource) + { + foreach (var atr in category.Attribute) + { + if (atr.AttributeId.Equals(AltinnXacmlUrns.InstanceId)) + { + instanceId = atr.Value; + break; + } + } + } + + if (string.IsNullOrEmpty(instanceId)) + { + return null; + } + + Instance instance = await _instanceRepository.GetOne(Convert.ToInt32(instanceId.Split("/")[0]), Guid.Parse(instanceId.Split("/")[1])); + return instance; + } + + /// + /// This method creates a uniqe cache key based on all relevant attributes in a decision request + /// + /// The decision requonst + /// The cache key + private static string GetCacheKeyForDecisionRequest(XacmlJsonRequestRoot request) + { + StringBuilder resourceKey = new StringBuilder(); + foreach (XacmlJsonCategory category in request.Request.Resource) + { + foreach (XacmlJsonAttribute atr in category.Attribute) + { + resourceKey.Append(atr.AttributeId + ":" + atr.Value + ";"); + } + } + + StringBuilder subjectKey = new StringBuilder(); + foreach (XacmlJsonCategory category in request.Request.AccessSubject) + { + foreach (XacmlJsonAttribute atr in category.Attribute) + { + subjectKey.Append(atr.AttributeId + ":" + atr.Value + ";"); + } + } + + StringBuilder actionKey = new StringBuilder(); + foreach (XacmlJsonCategory category in request.Request.Action) + { + foreach (XacmlJsonAttribute atr in category.Attribute) + { + actionKey.Append(atr.AttributeId + ":" + atr.Value + ";"); + } + } + + return subjectKey.ToString() + actionKey.ToString() + resourceKey.ToString(); + } + } +} diff --git a/src/Services/Storage/Interface/IApplicationService.cs b/src/Services/Storage/Interface/IApplicationService.cs new file mode 100644 index 00000000..45aa8f35 --- /dev/null +++ b/src/Services/Storage/Interface/IApplicationService.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Altinn.Platform.Storage.Models; + +namespace Altinn.Platform.Storage.Services +{ + /// + /// This interface describes the required methods and features of a application service implementation. + /// + public interface IApplicationService + { + /// + /// Upload file and save dataElement + /// + /// The application owner id. + /// The id of the application. + /// The data type identifier for the data being uploaded. + Task<(bool IsValid, ServiceError ServiceError)> ValidateDataTypeForApp(string org, string appId, string dataType); + } +} \ No newline at end of file diff --git a/src/Services/Storage/Interface/IAuthorization.cs b/src/Services/Storage/Interface/IAuthorization.cs new file mode 100644 index 00000000..48956043 --- /dev/null +++ b/src/Services/Storage/Interface/IAuthorization.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; + +using Altinn.Authorization.ABAC.Xacml.JsonProfile; +using Altinn.Platform.Storage.Helpers; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.Platform.Storage.Authorization +{ + /// + /// Interface for the authorization service + /// + public interface IAuthorization + { + /// + /// Authorize instances, and returns a list of MesseageBoxInstances with information about read and write rights of each instance. + /// + public Task> AuthorizeMesseageBoxInstances(List instances, bool includeInstantiate); + + /// + /// Authorizes a given action on an instance. + /// + /// true if the user is authorized. + public Task AuthorizeInstanceAction(Instance instance, string action, string task = null); + + /// + /// Authorizes that the user has one or more of the actions on an instance. + /// + /// true if the user is authorized. + public Task AuthorizeAnyOfInstanceActions(Instance instance, List actions); + + /// + /// Authorize instances, and returns a list of instances that the user has the right to read. + /// + public Task> AuthorizeInstances(List instances); + + /// + /// Verifies a scope claim based on claimsprincipal. + /// + /// Requiered scope. + /// true if the given ClaimsPrincipal or on of its identities have contains the given scope. + public bool UserHasRequiredScope(List requiredScope); + + /// + /// Sends in a request and get response with result of the request + /// + /// The Xacml Json Request + /// The Xacml Json response contains the result of the request + public Task GetDecisionForRequest(XacmlJsonRequestRoot xacmlJsonRequest); + } +} diff --git a/src/Services/Storage/Interface/IClaimsPrincipalProvider.cs b/src/Services/Storage/Interface/IClaimsPrincipalProvider.cs new file mode 100644 index 00000000..d7db9ddf --- /dev/null +++ b/src/Services/Storage/Interface/IClaimsPrincipalProvider.cs @@ -0,0 +1,18 @@ +using System.Security.Claims; + +namespace Altinn.Platform.Storage.Authorization +{ + /// + /// Defines the methods required for an implementation of a user JSON Web Token provider. + /// The provider is used by client implementations that needs the user token in requests + /// against other systems. + /// + public interface IClaimsPrincipalProvider + { + /// + /// Defines a method that can return a claims principal for the current user. + /// + /// The Json Web Token for the current user. + public ClaimsPrincipal GetUser(); + } +} diff --git a/src/Services/Storage/Interface/IDataRepository.cs b/src/Services/Storage/Interface/IDataRepository.cs index fac5b53e..4b493f0a 100644 --- a/src/Services/Storage/Interface/IDataRepository.cs +++ b/src/Services/Storage/Interface/IDataRepository.cs @@ -1,14 +1,9 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; - using Altinn.Platform.Storage.Interface.Models; namespace Altinn.Platform.Storage.Repository { /// - /// Describes the implementation of a data element storage. + /// Describes the implementation of a data element storage. /// public interface IDataRepository { @@ -19,7 +14,7 @@ public interface IDataRepository /// Data to be written to blob storage. /// Path to save the stream to in blob storage. /// The size of the blob. - Task WriteDataToStorage(string org, Stream stream, string blobStoragePath); + Task<(long ContentLength, DateTimeOffset LastModified)> WriteDataToStorage(string org, Stream stream, string blobStoragePath); /// /// Reads a data file from blob storage @@ -44,6 +39,13 @@ public interface IDataRepository /// list of data elements Task> ReadAll(Guid instanceGuid); + /// + /// Gets all data elements for given instances + /// + /// the list of instance guids to return data elements for + /// list of data elements + Task>> ReadAllForMultiple(List instanceGuids); + /// /// Creates a dataElement into the repository /// @@ -56,21 +58,23 @@ public interface IDataRepository /// /// the instance guid as partitionKey /// The data element guid - /// + /// The identified data element. Task Read(Guid instanceGuid, Guid dataElementId); - /// - /// Updates a data element. - /// - /// The data element to update. Dataelement must have instanceGuid set! - /// The updated data element - Task Update(DataElement dataElement); - /// /// Deletes the data element metadata object permanently! /// /// the element to delete /// true if delete went well. Task Delete(DataElement dataElement); + + /// + /// Updates the data element with the properties provided in the dictionary + /// + /// The instance guid + /// The data element id + /// A dictionary contaning property id (key) and object (value) to be stored + /// Dictionary can containt at most 10 entries + Task Update(Guid instanceGuid, Guid dataElementId, Dictionary propertylist); } } diff --git a/src/Services/Storage/Interface/IDataService.cs b/src/Services/Storage/Interface/IDataService.cs new file mode 100644 index 00000000..b094d1b1 --- /dev/null +++ b/src/Services/Storage/Interface/IDataService.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Models; + +namespace Altinn.Platform.Storage.Services +{ + /// + /// This interface describes the required methods and features of a data service implementation. + /// + public interface IDataService + { + /// + /// Trigger malware scan of the blob associated with the given data element. + /// + /// The metadata document for the parent instance for the data element. + /// + /// The data type properties document for the data type of the blob to be scanned for malware. + /// + /// The data element metadata document. + /// Timestamp when blob upload completed. + /// A cancellation token should the request be cancelled. + /// A task representing the asynconous call to file scan service. + Task StartFileScan(Instance instance, DataType dataType, DataElement dataElement, DateTimeOffset blobTimestamp, CancellationToken ct); + + /// + /// Create SHA-256 hash of the blob associated with the given data element. + /// + /// The application owner id. + /// the instance guid. + /// The data element guid. + Task<(string FileHash, ServiceError ServiceError)> GenerateSha256Hash(string org, Guid instanceGuid, Guid dataElementId); + + /// + /// Upload file and save dataElement + /// + /// The application owner id. + /// Data to be written to blob storage. + /// The data element to insert. + Task UploadDataAndCreateDataElement(string org, Stream stream, DataElement dataElement); + } +} \ No newline at end of file diff --git a/src/Services/Storage/Interface/IInstanceEventService.cs b/src/Services/Storage/Interface/IInstanceEventService.cs new file mode 100644 index 00000000..e1878e88 --- /dev/null +++ b/src/Services/Storage/Interface/IInstanceEventService.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +using Altinn.Platform.Storage.Interface.Enums; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.Platform.Storage.Services +{ + /// + /// This interface describes the required methods and features of an instance event service implementation. + /// + public interface IInstanceEventService + { + /// + /// Dispatch an instance event to the repository + /// + /// The event type + /// The instance the event is related to + public Task DispatchEvent(InstanceEventType eventType, Instance instance); + + /// + /// Dispatch an instance event related to a data elementto the repository + /// + /// The event type + /// The instance the event is related to + /// The data element the event is related to + public Task DispatchEvent(InstanceEventType eventType, Instance instance, DataElement dataElement); + } +} diff --git a/src/Services/Storage/Interface/IInstanceService.cs b/src/Services/Storage/Interface/IInstanceService.cs new file mode 100644 index 00000000..9eabe03f --- /dev/null +++ b/src/Services/Storage/Interface/IInstanceService.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading.Tasks; +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Models; + +namespace Altinn.Platform.Storage.Services +{ + /// + /// This interface describes the required methods and features of a instance service implementation. + /// + public interface IInstanceService + { + /// + /// Create signature document for given dataelements, this includes creating md5 hash for all blobs listed. + /// + /// The instance owner partyId + /// The instance guid + /// Signrequest containing data element ids and sign status + /// User id for the authenticated user + Task<(bool Created, ServiceError ServiceError)> CreateSignDocument(int instanceOwnerPartyId, Guid instanceGuid, SignRequest signRequest, int userId); + } +} \ No newline at end of file diff --git a/src/Startup.cs b/src/Startup.cs index ef5c1203..dbd1ba94 100644 --- a/src/Startup.cs +++ b/src/Startup.cs @@ -15,12 +15,16 @@ using Altinn.Platform.Events.Services; using Altinn.Platform.Events.Services.Interfaces; using Altinn.Platform.Register.Core; +using Altinn.Platform.Storage.Authorization; using Altinn.Platform.Storage.Clients; using Altinn.Platform.Storage.Helpers; using Altinn.Platform.Storage.Repository; +using Altinn.Platform.Storage.Services; using Altinn.ResourceRegistry.Core; + using AltinnCore.Authentication.Constants; using AltinnCore.Authentication.JwtCookie; + using LocalTest.Clients.CdnAltinnOrgs; using LocalTest.Configuration; using LocalTest.Helpers; @@ -42,6 +46,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles.Infrastructure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; @@ -49,6 +54,7 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Logging; using Microsoft.IdentityModel.Tokens; + using ResourceRegistryTest.Mocks; namespace LocalTest @@ -101,9 +107,6 @@ public void ConfigureServices(IServiceCollection services) services.AddHttpClient(); services.AddHttpClient(); services.AddSingleton(); - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -115,6 +118,21 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + // Shared auth services + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Storage services + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); services.AddMemoryCache(); X509Certificate2 cert = new X509Certificate2("JWTValidationCert.cer"); @@ -149,12 +167,16 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy( AuthzConstants.POLICY_INSTANCE_COMPLETE, policy => policy.Requirements.Add(new AppAccessRequirement("complete"))); + options.AddPolicy(AuthzConstants.POLICY_INSTANCE_SIGN, + policy => policy.Requirements.Add(new AppAccessRequirement("sign"))); + options.AddPolicy( AuthzConstants.POLICY_SCOPE_APPDEPLOY, policy => policy.Requirements.Add(new ScopeAccessRequirement("altinn:appdeploy"))); options.AddPolicy( AuthzConstants.POLICY_SCOPE_INSTANCE_READ, policy => policy.Requirements.Add(new ScopeAccessRequirement("altinn:instances.read"))); + options.AddPolicy( "AuthorizationLevel2", policy => policy.RequireClaim(AltinnCoreClaimTypes.AuthenticationLevel, "2", "3", "4"));