From b8bcc7bc31ac21f448c559ebb148ce84342be33d Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:38:11 +0200 Subject: [PATCH 01/15] feat: Add PermissionsController for managing permission configurations and authorization checks --- .../Controllers/PermissionsController.cs | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 server/Server/Controllers/PermissionsController.cs diff --git a/server/Server/Controllers/PermissionsController.cs b/server/Server/Controllers/PermissionsController.cs new file mode 100644 index 0000000..f9548ce --- /dev/null +++ b/server/Server/Controllers/PermissionsController.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Identity; +using server.Models.Domain; +using server.Models.Domain.Enums; +using server.Models.DTOs.Authz; +using server.Services.Interfaces; + +namespace server.Controllers; + +[ApiController] +[Route("api/permissions")] +[Authorize] +public class PermissionsController : ControllerBase +{ + private readonly ILogger _logger; + private readonly UserManager _userManager; + private readonly IPermissionConfigurationService _permissionConfig; + private readonly IProjectMembershipService _membershipService; + + public PermissionsController( + ILogger logger, + UserManager userManager, + IPermissionConfigurationService permissionConfig, + IProjectMembershipService membershipService) + { + _logger = logger; + _userManager = userManager; + _permissionConfig = permissionConfig; + _membershipService = membershipService; + } + + /// + /// Reloads the permission configuration from the configuration source. + /// Restricted to administrators. + /// + /// Success message on completion. + /// Configuration reloaded successfully + /// If the user is not authenticated + /// If the user is not an admin + /// If an error occurs during reload + [HttpPost("reload")] + [Authorize(Policy = "RequireAdminRole")] + public IActionResult ReloadPermissionConfiguration() + { + try + { + _logger.LogInformation("Admin requested permission configuration reload"); + _permissionConfig.ReloadConfiguration(); + _logger.LogInformation("Permission configuration reload completed successfully"); + return Ok(new { message = "Permission configuration reloaded successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reload permission configuration"); + return StatusCode(StatusCodes.Status500InternalServerError, + "An error occurred while reloading the permission configuration"); + } + } + + [HttpPost("authorize")] + public async Task> AuthorizeAsync([FromBody] AuthorizationCheckDto request) + { + if (request is null || string.IsNullOrWhiteSpace(request.Permission)) + { + return BadRequest("Permission is required"); + } + + var user = await _userManager.GetUserAsync(User); + if (user is null) + { + return Unauthorized(); + } + + var allow = await EvaluateAsync(user, request.Permission, request.Context); + return Ok(new AuthorizationResponseDto { Allow = allow, PolicyVersion = null }); + } + + [HttpPost("authorize/batch")] + public async Task> AuthorizeBatchAsync([FromBody] AuthorizationBatchRequestDto request) + { + if (request is null || request.Checks is null) + { + return BadRequest("Checks are required"); + } + + var user = await _userManager.GetUserAsync(User); + if (user is null) + { + return Unauthorized(); + } + + var results = new List(request.Checks.Count); + foreach (var check in request.Checks) + { + if (check is null || string.IsNullOrWhiteSpace(check.Permission)) + { + results.Add(false); + continue; + } + var allow = await EvaluateAsync(user, check.Permission, check.Context); + results.Add(allow); + } + + return Ok(new AuthorizationBatchResponseDto { Results = results, PolicyVersion = null }); + } + + private async Task EvaluateAsync(ApplicationUser user, string permission, AuthorizationContextDto? context) + { + // Global permissions (no project context) + if (context?.ProjectId is null) + { + var roles = await _userManager.GetRolesAsync(user); + var perms = _permissionConfig.GetGlobalPermissionsForUser(roles); + var allow = perms.Contains(permission); + _logger.LogDebug("Authorize (global): user {UserId}, perm {Perm} => {Allow}", user.Id, permission, allow); + return allow; + } + + // Project-scoped permissions + var projectId = context.ProjectId.Value; + + // First, confirm membership + var isMember = await _membershipService.IsProjectMemberAsync(user.Id, projectId); + if (!isMember) + { + _logger.LogDebug("Authorize: user {UserId} not a member of project {ProjectId}", user.Id, projectId); + return false; + } + + var role = await _membershipService.GetUserRoleInProjectAsync(user.Id, projectId); + if (role is null) + { + _logger.LogDebug("Authorize: user {UserId} has no role in project {ProjectId}", user.Id, projectId); + return false; + } + + var allowRole = _permissionConfig.HasPermission(role.Value, permission); + _logger.LogDebug("Authorize: user {UserId} role {Role} in project {ProjectId}, perm {Perm} => {Allow}", + user.Id, role.Value, projectId, permission, allowRole); + return allowRole; + } +} From 90c425032336b810a69cb43f114d14c671156769 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:38:44 +0200 Subject: [PATCH 02/15] Refactor annotation and asset services to include project ID for scoped access - Updated AnnotationsController to accept projectId in relevant endpoints for task and asset annotations. - Modified AssetController to include projectId in asset retrieval, update, and deletion methods. - Enhanced AnnotationService and AssetService to validate project ownership when fetching, creating, updating, or deleting annotations and assets. - Introduced SecuritySettings for configurable presigned URL expiry in AssetService. - Removed ConfigurationController as it was deemed unnecessary. - Added AuthorizationDtos for handling authorization checks and contexts. - Updated interfaces for IAnnotationService and IAssetService to reflect changes in method signatures. - Improved logging for better traceability of project-related operations. --- .../Controllers/AnnotationsController.cs | 34 ++-- server/Server/Controllers/AssetsController.cs | 20 ++- .../Controllers/ConfigurationController.cs | 150 ------------------ .../Core/Workflow/Steps/AssetTransferStep.cs | 5 +- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Models/Configurations/SecuritySettings.cs | 19 +++ .../Models/DTOs/Authz/AuthorizationDtos.cs | 30 ++++ server/Server/Services/AnnotationService.cs | 74 ++++++++- server/Server/Services/AssetService.cs | 63 +++++++- .../Services/Interfaces/IAnnotationService.cs | 16 +- .../Services/Interfaces/IAssetService.cs | 26 +-- .../IPermissionConfigurationService.cs | 9 +- 12 files changed, 253 insertions(+), 201 deletions(-) delete mode 100644 server/Server/Controllers/ConfigurationController.cs create mode 100644 server/Server/Models/Configurations/SecuritySettings.cs create mode 100644 server/Server/Models/DTOs/Authz/AuthorizationDtos.cs diff --git a/server/Server/Controllers/AnnotationsController.cs b/server/Server/Controllers/AnnotationsController.cs index 3041a16..b223320 100644 --- a/server/Server/Controllers/AnnotationsController.cs +++ b/server/Server/Controllers/AnnotationsController.cs @@ -27,6 +27,7 @@ public AnnotationsController(IAnnotationService annotationService, ILogger /// Gets all annotations for a specific task with optional filtering, sorting, and pagination. /// + /// The project ID from the route used to scope access. /// The ID of the task to get annotations for. /// The field to filter on (e.g., "annotation_type", "is_prediction", "annotator_user_id"). /// The value to filter by. @@ -42,6 +43,7 @@ public AnnotationsController(IAnnotationService annotationService, ILogger), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task GetAnnotationsForTask( + int projectId, int taskId, [FromQuery] string? filterOn = null, [FromQuery] string? filterQuery = null, @@ -54,7 +56,7 @@ public async Task GetAnnotationsForTask( try { var annotations = await _annotationService.GetAnnotationsForTaskAsync( - taskId, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize); + projectId, taskId, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize); return Ok(annotations); } catch (Exception ex) @@ -70,6 +72,7 @@ public async Task GetAnnotationsForTask( /// /// Gets all annotations for a specific asset with optional filtering, sorting, and pagination. /// + /// The project ID from the route used to scope access. /// The ID of the asset to get annotations for. /// The field to filter on (e.g., "annotation_type", "is_prediction", "annotator_user_id"). /// The value to filter by. @@ -85,6 +88,7 @@ public async Task GetAnnotationsForTask( [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task GetAnnotationsForAsset( + int projectId, int assetId, [FromQuery] string? filterOn = null, [FromQuery] string? filterQuery = null, @@ -97,7 +101,7 @@ public async Task GetAnnotationsForAsset( try { var annotations = await _annotationService.GetAnnotationsForAssetAsync( - assetId, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize); + projectId, assetId, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize); return Ok(annotations); } catch (Exception ex) @@ -111,19 +115,20 @@ public async Task GetAnnotationsForAsset( } /// - /// Gets a specific annotation by its unique ID. + /// Gets a specific annotation by ID within the given project scope. /// - /// The ID of the annotation. + /// The project ID from the route used to scope access. + /// The ID of the annotation to retrieve. /// The requested annotation. [HttpGet("{annotationId:long}")] [Authorize(Policy = "CanAccessAnnotationWorkspace")] // Annotator, Reviewer, Manager [ProducesResponseType(typeof(AnnotationDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetAnnotationById(long annotationId) + public async Task GetAnnotationById(int projectId, long annotationId) { try { - var annotation = await _annotationService.GetAnnotationByIdAsync(annotationId); + var annotation = await _annotationService.GetAnnotationByIdAsync(projectId, annotationId); if (annotation == null) { @@ -168,10 +173,15 @@ public async Task CreateAnnotation(int projectId, [FromBody] Crea try { - var newAnnotation = await _annotationService.CreateAnnotationAsync(createAnnotationDto, annotatorUserId); + var newAnnotation = await _annotationService.CreateAnnotationAsync(projectId, createAnnotationDto, annotatorUserId); return CreatedAtAction(nameof(GetAnnotationById), new { projectId, annotationId = newAnnotation.Id }, newAnnotation); } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid create annotation request for project {ProjectId}", projectId); + return BadRequest(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, "An error occurred while creating annotation for task {TaskId}.", createAnnotationDto.TaskId); @@ -182,6 +192,7 @@ public async Task CreateAnnotation(int projectId, [FromBody] Crea /// /// Updates an existing annotation. /// + /// The project ID used to scope access. /// The ID of the annotation to update. /// The data to update the annotation with. /// The updated annotation. @@ -195,11 +206,11 @@ public async Task CreateAnnotation(int projectId, [FromBody] Crea [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateAnnotation(long annotationId, [FromBody] UpdateAnnotationDto updateAnnotationDto) + public async Task UpdateAnnotation(int projectId, long annotationId, [FromBody] UpdateAnnotationDto updateAnnotationDto) { try { - var updatedAnnotation = await _annotationService.UpdateAnnotationAsync(annotationId, updateAnnotationDto); + var updatedAnnotation = await _annotationService.UpdateAnnotationAsync(projectId, annotationId, updateAnnotationDto); if (updatedAnnotation == null) { @@ -218,6 +229,7 @@ public async Task UpdateAnnotation(long annotationId, [FromBody] /// /// Deletes an annotation by its ID. /// + /// The project ID used to scope access. /// The ID of the annotation to delete. /// No content if successful. /// If the annotation was successfully deleted. @@ -228,11 +240,11 @@ public async Task UpdateAnnotation(long annotationId, [FromBody] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteAnnotation(long annotationId) + public async Task DeleteAnnotation(int projectId, long annotationId) { try { - var result = await _annotationService.DeleteAnnotationAsync(annotationId); + var result = await _annotationService.DeleteAnnotationAsync(projectId, annotationId); if (!result) { diff --git a/server/Server/Controllers/AssetsController.cs b/server/Server/Controllers/AssetsController.cs index 63802c6..0c53ea0 100644 --- a/server/Server/Controllers/AssetsController.cs +++ b/server/Server/Controllers/AssetsController.cs @@ -62,14 +62,15 @@ public async Task GetAssetsForProject( /// /// Gets a specific asset by its unique ID. /// + /// The ID of the project from the route used to scope access. /// The ID of the asset. /// The requested asset. [HttpGet("{assetId:int}")] [ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetAssetById(int assetId) + public async Task GetAssetById(int projectId, int assetId) { - var asset = await _assetService.GetAssetByIdAsync(assetId); + var asset = await _assetService.GetAssetByIdAsync(projectId, assetId); return asset == null ? this.CreateNotFoundResponse("Asset", assetId) : Ok(asset); } @@ -177,6 +178,7 @@ public async Task CreateAsset(int projectId, [FromBody] CreateAss /// /// Updates an existing asset. /// + /// The ID of the project from the route used to scope access. /// The ID of the asset to update. /// The data to update the asset with. /// The updated asset. @@ -189,15 +191,16 @@ public async Task CreateAsset(int projectId, [FromBody] CreateAss [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateAsset(int assetId, [FromBody] UpdateAssetDto updateAssetDto) + public async Task UpdateAsset(int projectId, int assetId, [FromBody] UpdateAssetDto updateAssetDto) { - var updatedAsset = await _assetService.UpdateAssetAsync(assetId, updateAssetDto); + var updatedAsset = await _assetService.UpdateAssetAsync(projectId, assetId, updateAssetDto); return updatedAsset == null ? this.CreateNotFoundResponse("Asset", assetId) : Ok(updatedAsset); } /// /// Deletes an asset by its ID. /// + /// The ID of the project from the route used to scope access. /// The ID of the asset to delete. /// No content if successful. /// If the asset was successfully deleted. @@ -207,9 +210,9 @@ public async Task UpdateAsset(int assetId, [FromBody] UpdateAsset [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteAsset(int assetId) + public async Task DeleteAsset(int projectId, int assetId) { - var result = await _assetService.DeleteAssetAsync(assetId); + var result = await _assetService.DeleteAssetAsync(projectId, assetId); return !result ? this.CreateNotFoundResponse("Asset", assetId) : NoContent(); } @@ -299,6 +302,7 @@ public async Task UploadAssets( /// Transfers an asset to a different data source. /// This involves copying the file in MinIO and updating the database record. /// + /// The ID of the project from the route used to scope access. /// The ID of the asset to transfer. /// The transfer request containing the target data source ID. /// Success status of the transfer operation. @@ -312,11 +316,11 @@ public async Task UploadAssets( [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task TransferAsset(int assetId, [FromBody] TransferAssetDto transferAssetDto) + public async Task TransferAsset(int projectId, int assetId, [FromBody] TransferAssetDto transferAssetDto) { try { - var result = await _assetService.TransferAssetToDataSourceAsync(assetId, transferAssetDto.TargetDataSourceId); + var result = await _assetService.TransferAssetToDataSourceAsync(projectId, assetId, transferAssetDto.TargetDataSourceId); if (!result) { diff --git a/server/Server/Controllers/ConfigurationController.cs b/server/Server/Controllers/ConfigurationController.cs deleted file mode 100644 index 3e59a2e..0000000 --- a/server/Server/Controllers/ConfigurationController.cs +++ /dev/null @@ -1,150 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using server.Models.DTOs.Configuration; -using server.Services.Interfaces; -using System.Security.Claims; - -namespace server.Controllers -{ - /// - /// Controller for serving application configuration to the frontend. - /// Provides permission rules, feature flags, and other configuration data. - /// - [Route("api/[controller]")] - [ApiController] - [Authorize(Policy = "RequireAuthenticatedUser")] - [EnableRateLimiting("public")] - public class ConfigurationController : ControllerBase - { - private readonly IPermissionConfigurationService _permissionConfigurationService; - private readonly ILogger _logger; - - public ConfigurationController( - IPermissionConfigurationService permissionConfigurationService, - ILogger logger) - { - _permissionConfigurationService = permissionConfigurationService ?? throw new ArgumentNullException(nameof(permissionConfigurationService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Reloads the permission configuration from the configuration file. - /// This endpoint is restricted to admin users only. - /// - /// Success message - /// Configuration reloaded successfully - /// If the user is not authenticated - /// If the user is not an admin - /// If an error occurs reloading the configuration - [HttpPost("permissions/reload")] - [Authorize(Policy = "RequireAdminRole")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult ReloadPermissionConfiguration() - { - try - { - _logger.LogInformation("Admin user requesting permission configuration reload"); - - _permissionConfigurationService.ReloadConfiguration(); - - _logger.LogInformation("Permission configuration reloaded successfully"); - return Ok(new { message = "Permission configuration reloaded successfully" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to reload permission configuration"); - return StatusCode(StatusCodes.Status500InternalServerError, - "An error occurred while reloading the permission configuration"); - } - } - - /// - /// Gets page-specific permissions for the current user. - /// Hybrid mode endpoint that provides permissions on-demand for specific pages/routes. - /// - /// The page/route identifier to get permissions for - /// Optional project ID for project-specific permissions - /// User permissions for the requested page - /// Returns the page permissions - /// If the user is not authenticated - /// If an error occurs loading the permissions - [HttpGet("permissions/page")] - [ProducesResponseType(typeof(HashSet), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetPagePermissions([FromQuery] string page, [FromQuery] int? projectId = null) - { - try - { - if (string.IsNullOrWhiteSpace(page)) - { - return BadRequest("Page parameter is required"); - } - - _logger.LogDebug("Fetching page permissions for page '{Page}' with project {ProjectId}", page, projectId); - - var userId = HttpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized(); - } - - var permissions = await _permissionConfigurationService.GetPagePermissionsAsync(userId, page, projectId); - - _logger.LogDebug("Successfully retrieved {PermissionCount} permissions for page '{Page}' and project {ProjectId}", - permissions.Count, page, projectId); - - return Ok(permissions); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve page permissions for page '{Page}' and project {ProjectId}", page, projectId); - return StatusCode(StatusCodes.Status500InternalServerError, - "An error occurred while loading the page permissions"); - } - } - - /// - /// Gets the full user permission context including all project memberships and global permissions. - /// This can be used for comprehensive permission caching on the frontend. - /// - /// Complete user permission context - /// Returns the user permission context - /// If the user is not authenticated - /// If an error occurs loading the permissions - [HttpGet("permissions/user-context")] - [ProducesResponseType(typeof(UserPermissionContext), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetUserPermissionContext() - { - try - { - var userId = HttpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized(); - } - - _logger.LogDebug("Building permission context for user {UserId}", userId); - - var context = await _permissionConfigurationService.BuildUserPermissionContextAsync(userId); - - _logger.LogDebug("Successfully built permission context for user {UserId} with {PermissionCount} total permissions across {ProjectCount} projects", - userId, context.Permissions.Count, context.ProjectPermissions.Count); - - return Ok(context); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to build permission context for user"); - return StatusCode(StatusCodes.Status500InternalServerError, - "An error occurred while building the permission context"); - } - } - } -} \ No newline at end of file diff --git a/server/Server/Core/Workflow/Steps/AssetTransferStep.cs b/server/Server/Core/Workflow/Steps/AssetTransferStep.cs index 366c147..4c6900f 100644 --- a/server/Server/Core/Workflow/Steps/AssetTransferStep.cs +++ b/server/Server/Core/Workflow/Steps/AssetTransferStep.cs @@ -64,6 +64,7 @@ public async Task TransferAssetAsync( try { var transferResult = await _assetService.TransferAssetToDataSourceAsync( + context.Asset.ProjectId, context.Asset.AssetId, context.CurrentStage.TargetDataSourceId.Value); @@ -114,6 +115,7 @@ public async Task TransferAssetToAnnotationAsync( try { var transferResult = await _assetService.TransferAssetToDataSourceAsync( + context.Asset.ProjectId, context.Asset.AssetId, annotationDataSourceId); @@ -164,6 +166,7 @@ public async Task RollbackAsync(PipelineContext context, CancellationToken try { var rollbackResult = await _assetService.TransferAssetToDataSourceAsync( + context.Asset.ProjectId, context.Asset.AssetId, rollbackDataSourceId); @@ -186,4 +189,4 @@ public async Task RollbackAsync(PipelineContext context, CancellationToken return false; } } -} \ No newline at end of file +} diff --git a/server/Server/Extensions/ServiceCollectionExtensions.cs b/server/Server/Extensions/ServiceCollectionExtensions.cs index 3d02bc9..2649797 100644 --- a/server/Server/Extensions/ServiceCollectionExtensions.cs +++ b/server/Server/Extensions/ServiceCollectionExtensions.cs @@ -68,6 +68,12 @@ public static IServiceCollection AddApplicationConfigurations(this IServiceColle .ValidateDataAnnotations() .ValidateOnStart(); + // Configure and validate Security settings + services.AddOptions() + .Bind(configuration.GetSection(SecuritySettings.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + return services; } @@ -243,4 +249,4 @@ public static IServiceCollection AddPipelineServices(this IServiceCollection ser return services; } -} \ No newline at end of file +} diff --git a/server/Server/Models/Configurations/SecuritySettings.cs b/server/Server/Models/Configurations/SecuritySettings.cs new file mode 100644 index 0000000..e85684e --- /dev/null +++ b/server/Server/Models/Configurations/SecuritySettings.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace server.Configs; + +/// +/// Security-related application settings. +/// +public record SecuritySettings +{ + public const string SectionName = "Security"; + + /// + /// Default expiry for presigned URLs in seconds. + /// Keep small in production to reduce exposure window. + /// + [Range(60, 86400)] + public int PresignedUrlExpirySeconds { get; init; } = 300; // 5 minutes +} + diff --git a/server/Server/Models/DTOs/Authz/AuthorizationDtos.cs b/server/Server/Models/DTOs/Authz/AuthorizationDtos.cs new file mode 100644 index 0000000..7aecdb6 --- /dev/null +++ b/server/Server/Models/DTOs/Authz/AuthorizationDtos.cs @@ -0,0 +1,30 @@ +namespace server.Models.DTOs.Authz; + +public class AuthorizationCheckDto +{ + public required string Permission { get; init; } + public AuthorizationContextDto? Context { get; init; } +} + +public class AuthorizationContextDto +{ + public int? ProjectId { get; init; } +} + +public class AuthorizationBatchRequestDto +{ + public required List Checks { get; init; } +} + +public class AuthorizationResponseDto +{ + public required bool Allow { get; init; } + public string? PolicyVersion { get; init; } +} + +public class AuthorizationBatchResponseDto +{ + public required List Results { get; init; } + public string? PolicyVersion { get; init; } +} + diff --git a/server/Server/Services/AnnotationService.cs b/server/Server/Services/AnnotationService.cs index b2d745f..e80a9b9 100644 --- a/server/Server/Services/AnnotationService.cs +++ b/server/Server/Services/AnnotationService.cs @@ -9,23 +9,42 @@ namespace server.Services; public class AnnotationService : IAnnotationService { private readonly IAnnotationRepository _annotationRepository; + private readonly ITaskRepository _taskRepository; + private readonly IAssetRepository _assetRepository; private readonly ILogger _logger; public AnnotationService( IAnnotationRepository annotationRepository, + ITaskRepository taskRepository, + IAssetRepository assetRepository, ILogger logger) { _annotationRepository = annotationRepository ?? throw new ArgumentNullException(nameof(annotationRepository)); + _taskRepository = taskRepository ?? throw new ArgumentNullException(nameof(taskRepository)); + _assetRepository = assetRepository ?? throw new ArgumentNullException(nameof(assetRepository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task> GetAnnotationsForTaskAsync( + int projectId, int taskId, string? filterOn = null, string? filterQuery = null, string? sortBy = null, bool isAscending = true, int pageNumber = 1, int pageSize = 25) { _logger.LogInformation("Fetching annotations for task: {TaskId}", taskId); - + var task = await _taskRepository.GetByIdAsync(taskId); + if (task == null || task.ProjectId != projectId) + { + _logger.LogWarning("Task {TaskId} not found in project {ProjectId}", taskId, projectId); + return new PaginatedResponse + { + Data = Array.Empty(), + PageSize = pageSize, + CurrentPage = pageNumber, + TotalPages = 0, + TotalItems = 0 + }; + } var (annotations, totalCount) = await _annotationRepository.GetAllWithCountAsync( filter: a => a.TaskId == taskId, filterOn: filterOn, @@ -52,12 +71,25 @@ public async Task> GetAnnotationsForTaskAsync( } public async Task> GetAnnotationsForAssetAsync( + int projectId, int assetId, string? filterOn = null, string? filterQuery = null, string? sortBy = null, bool isAscending = true, int pageNumber = 1, int pageSize = 25) { _logger.LogInformation("Fetching annotations for asset: {AssetId}", assetId); - + var asset = await _assetRepository.GetByIdAsync(assetId); + if (asset == null || asset.ProjectId != projectId) + { + _logger.LogWarning("Asset {AssetId} not found in project {ProjectId}", assetId, projectId); + return new PaginatedResponse + { + Data = Array.Empty(), + PageSize = pageSize, + CurrentPage = pageNumber, + TotalPages = 0, + TotalItems = 0 + }; + } var (annotations, totalCount) = await _annotationRepository.GetAllWithCountAsync( filter: a => a.AssetId == assetId, filterOn: filterOn, @@ -83,7 +115,7 @@ public async Task> GetAnnotationsForAssetAsync( }; } - public async Task GetAnnotationByIdAsync(long annotationId) + public async Task GetAnnotationByIdAsync(int projectId, long annotationId) { _logger.LogInformation("Fetching annotation with ID: {AnnotationId}", annotationId); @@ -95,15 +127,31 @@ public async Task> GetAnnotationsForAssetAsync( return null; } + // Verify project scope via task + var task = await _taskRepository.GetByIdAsync(annotation.TaskId); + if (task == null || task.ProjectId != projectId) + { + _logger.LogWarning("Annotation {AnnotationId} does not belong to project {ProjectId}", annotationId, projectId); + return null; + } + _logger.LogInformation("Successfully fetched annotation with ID: {AnnotationId}", annotationId); return MapToDto(annotation); } - public async Task CreateAnnotationAsync(CreateAnnotationDto createDto, string annotatorUserId) + public async Task CreateAnnotationAsync(int projectId, CreateAnnotationDto createDto, string annotatorUserId) { _logger.LogInformation("Creating new annotation for task: {TaskId}, asset: {AssetId}", createDto.TaskId, createDto.AssetId); + // Validate task and asset belong to project + var task = await _taskRepository.GetByIdAsync(createDto.TaskId); + var asset = await _assetRepository.GetByIdAsync(createDto.AssetId); + if (task == null || asset == null || task.ProjectId != projectId || asset.ProjectId != projectId) + { + throw new ArgumentException("Task or asset does not belong to the specified project"); + } + var annotation = new Annotation { AnnotationType = createDto.AnnotationType, @@ -130,7 +178,7 @@ public async Task CreateAnnotationAsync(CreateAnnotationDto creat return MapToDto(annotation); } - public async Task UpdateAnnotationAsync(long annotationId, UpdateAnnotationDto updateDto) + public async Task UpdateAnnotationAsync(int projectId, long annotationId, UpdateAnnotationDto updateDto) { _logger.LogInformation("Updating annotation with ID: {AnnotationId}", annotationId); @@ -141,6 +189,13 @@ public async Task CreateAnnotationAsync(CreateAnnotationDto creat return null; } + var task = await _taskRepository.GetByIdAsync(existingAnnotation.TaskId); + if (task == null || task.ProjectId != projectId) + { + _logger.LogWarning("Annotation {AnnotationId} does not belong to project {ProjectId}", annotationId, projectId); + return null; + } + existingAnnotation.Data = updateDto.Data ?? existingAnnotation.Data; existingAnnotation.LabelId = updateDto.LabelId ?? existingAnnotation.LabelId; existingAnnotation.IsPrediction = updateDto.IsPrediction ?? @@ -159,7 +214,7 @@ public async Task CreateAnnotationAsync(CreateAnnotationDto creat return MapToDto(existingAnnotation); } - public async Task DeleteAnnotationAsync(long annotationId) + public async Task DeleteAnnotationAsync(int projectId, long annotationId) { _logger.LogInformation("Deleting annotation with ID: {AnnotationId}", annotationId); @@ -171,6 +226,13 @@ public async Task DeleteAnnotationAsync(long annotationId) return false; } + var task = await _taskRepository.GetByIdAsync(annotation.TaskId); + if (task == null || task.ProjectId != projectId) + { + _logger.LogWarning("Attempt to delete annotation {AnnotationId} from mismatched project {ProjectId}", annotationId, projectId); + return false; + } + _annotationRepository.Remove(annotation); await _annotationRepository.SaveChangesAsync(); diff --git a/server/Server/Services/AssetService.cs b/server/Server/Services/AssetService.cs index 636117a..af6e451 100644 --- a/server/Server/Services/AssetService.cs +++ b/server/Server/Services/AssetService.cs @@ -8,6 +8,8 @@ using server.Utils; using server.Exceptions; using LaberisTask = server.Models.Domain.Task; +using Microsoft.Extensions.Options; +using server.Configs; namespace server.Services; @@ -21,6 +23,7 @@ public class AssetService : IAssetService private readonly IDomainEventService _domainEventService; private readonly ITaskService _taskService; private readonly ILogger _logger; + private readonly int _defaultPresignedExpirySeconds; public AssetService( IAssetRepository assetRepository, @@ -30,7 +33,8 @@ public AssetService( IWorkflowStageRepository workflowStageRepository, IDomainEventService domainEventService, ITaskService taskService, - ILogger logger) + ILogger logger, + IOptions? securityOptions = null) { _assetRepository = assetRepository ?? throw new ArgumentNullException(nameof(assetRepository)); _fileStorageService = fileStorageService ?? throw new ArgumentNullException(nameof(fileStorageService)); @@ -40,16 +44,21 @@ public AssetService( _domainEventService = domainEventService ?? throw new ArgumentNullException(nameof(domainEventService)); _taskService = taskService ?? throw new ArgumentNullException(nameof(taskService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _defaultPresignedExpirySeconds = securityOptions?.Value.PresignedUrlExpirySeconds ?? 300; } #region GET Methods + /// + /// Retrieves all assets for the specified project with optional filters and sorting. + /// Generates presigned URLs with a short expiry configured by SecuritySettings. + /// public async Task> GetAssetsForProjectAsync( int projectId, int? dataSourceId = null, string? filterOn = null, string? filterQuery = null, string? sortBy = null, bool isAscending = true, int pageNumber = 1, int pageSize = 25, - bool includeUrls = true, int urlExpiryInSeconds = 3600) + bool includeUrls = true, int urlExpiryInSeconds = 300) { _logger.LogInformation("Fetching assets for project: {ProjectId}, dataSource: {DataSourceId}", projectId, dataSourceId); @@ -80,7 +89,8 @@ public async Task> GetAssetsForProjectAsync( if (dataSource != null) { var bucketName = _storageService.GenerateBucketName(asset.ProjectId, dataSource.Name); - imageUrl = await _storageService.GetPresignedUrlAsync(bucketName, asset.ExternalId, urlExpiryInSeconds); + var expiry = urlExpiryInSeconds > 0 ? urlExpiryInSeconds : _defaultPresignedExpirySeconds; + imageUrl = await _storageService.GetPresignedUrlAsync(bucketName, asset.ExternalId, expiry); } } catch (Exception ex) @@ -118,7 +128,10 @@ public async Task> GetAssetsForProjectAsync( }; } - public async Task GetAssetByIdAsync(int assetId) + /// + /// Retrieves an asset by ID within the given project scope, returning null on mismatch. + /// + public async Task GetAssetByIdAsync(int projectId, int assetId) { _logger.LogInformation("Fetching asset with ID: {AssetId}", assetId); @@ -130,6 +143,12 @@ public async Task> GetAssetsForProjectAsync( return null; } + if (asset.ProjectId != projectId) + { + _logger.LogWarning("Asset {AssetId} does not belong to project {ProjectId}", assetId, projectId); + return null; + } + // Generate presigned URL for the asset string? imageUrl = null; try @@ -139,7 +158,7 @@ public async Task> GetAssetsForProjectAsync( if (dataSource != null) { var bucketName = _storageService.GenerateBucketName(asset.ProjectId, dataSource.Name); - imageUrl = await _storageService.GetPresignedUrlAsync(bucketName, asset.ExternalId, 3600); // 1 hour expiry + imageUrl = await _storageService.GetPresignedUrlAsync(bucketName, asset.ExternalId, _defaultPresignedExpirySeconds); } } catch (Exception ex) @@ -213,7 +232,10 @@ public async Task CreateAssetAsync(int projectId, CreateAssetDto creat #endregion - public async Task UpdateAssetAsync(int assetId, UpdateAssetDto updateDto) + /// + /// Updates an asset within the given project scope, returning null if not found or mismatched. + /// + public async Task UpdateAssetAsync(int projectId, int assetId, UpdateAssetDto updateDto) { _logger.LogInformation("Updating asset with ID: {AssetId}", assetId); @@ -225,6 +247,12 @@ public async Task CreateAssetAsync(int projectId, CreateAssetDto creat return null; } + if (asset.ProjectId != projectId) + { + _logger.LogWarning("Asset {AssetId} does not belong to project {ProjectId}", assetId, projectId); + return null; + } + asset.Filename = updateDto.Filename ?? asset.Filename; asset.MimeType = updateDto.MimeType ?? asset.MimeType; asset.SizeBytes = updateDto.SizeBytes ?? asset.SizeBytes; @@ -251,7 +279,10 @@ public async Task CreateAssetAsync(int projectId, CreateAssetDto creat return MapToDto(asset); } - public async Task DeleteAssetAsync(int assetId) + /// + /// Deletes an asset by ID within the given project scope. + /// + public async Task DeleteAssetAsync(int projectId, int assetId) { _logger.LogInformation("Deleting asset with ID: {AssetId}", assetId); @@ -263,6 +294,12 @@ public async Task DeleteAssetAsync(int assetId) return false; } + if (asset.ProjectId != projectId) + { + _logger.LogWarning("Attempt to delete asset {AssetId} from mismatched project {ProjectId}", assetId, projectId); + return false; + } + _assetRepository.Remove(asset); await _assetRepository.SaveChangesAsync(); @@ -499,10 +536,14 @@ public async Task ValidateAssetBelongsToProjectAsync(int assetId, int proj /// Transfers an asset from its current data source to a target data source. /// This involves copying the file in MinIO and updating the database record. /// + /// The ID of the project the asset belongs to /// The ID of the asset to transfer /// The ID of the target data source /// True if the transfer was successful, false otherwise - public async Task TransferAssetToDataSourceAsync(int assetId, int targetDataSourceId) + /// + /// Transfers an asset to a target data source within the same project, validating project scope. + /// + public async Task TransferAssetToDataSourceAsync(int projectId, int assetId, int targetDataSourceId) { try { @@ -514,6 +555,12 @@ public async Task TransferAssetToDataSourceAsync(int assetId, int targetDa return false; } + if (asset.ProjectId != projectId) + { + _logger.LogWarning("Attempt to transfer asset {AssetId} from mismatched project {ProjectId}", assetId, projectId); + return false; + } + _logger.LogInformation("Starting asset transfer for asset {AssetId} from data source {CurrentDataSourceId} to {TargetDataSourceId}", asset.AssetId, asset.DataSourceId, targetDataSourceId); diff --git a/server/Server/Services/Interfaces/IAnnotationService.cs b/server/Server/Services/Interfaces/IAnnotationService.cs index c3f0603..8a95f30 100644 --- a/server/Server/Services/Interfaces/IAnnotationService.cs +++ b/server/Server/Services/Interfaces/IAnnotationService.cs @@ -8,6 +8,7 @@ public interface IAnnotationService /// /// Retrieves all annotations for a specific task, optionally filtered and sorted. /// + /// The ID of the project used to scope access. /// The ID of the task to retrieve annotations for. /// The field to filter on (e.g., "annotation_type", "is_prediction", "annotator_user_id"). /// The query string to filter by. @@ -17,6 +18,7 @@ public interface IAnnotationService /// The number of items per page. /// A task that represents the asynchronous operation, containing a collection of AnnotationDto. Task> GetAnnotationsForTaskAsync( + int projectId, int taskId, string? filterOn = null, string? filterQuery = null, string? sortBy = null, bool isAscending = true, int pageNumber = 1, int pageSize = 25 @@ -25,6 +27,7 @@ Task> GetAnnotationsForTaskAsync( /// /// Retrieves all annotations for a specific asset, optionally filtered and sorted. /// + /// The ID of the project used to scope access. /// The ID of the asset to retrieve annotations for. /// The field to filter on (e.g., "annotation_type", "is_prediction", "annotator_user_id"). /// The query string to filter by. @@ -34,6 +37,7 @@ Task> GetAnnotationsForTaskAsync( /// The number of items per page. /// A task that represents the asynchronous operation, containing a collection of AnnotationDto. Task> GetAnnotationsForAssetAsync( + int projectId, int assetId, string? filterOn = null, string? filterQuery = null, string? sortBy = null, bool isAscending = true, int pageNumber = 1, int pageSize = 25 @@ -42,30 +46,34 @@ Task> GetAnnotationsForAssetAsync( /// /// Retrieves an annotation by its ID. /// + /// The ID of the project used to scope access. /// The ID of the annotation to retrieve. /// A task that represents the asynchronous operation, containing the AnnotationDto if found, otherwise null. - Task GetAnnotationByIdAsync(long annotationId); + Task GetAnnotationByIdAsync(int projectId, long annotationId); /// /// Creates a new annotation. /// + /// The ID of the project used to scope access. /// The DTO containing information for the new annotation. /// The ID of the user creating the annotation. /// A task that represents the asynchronous operation, containing the newly created AnnotationDto. - Task CreateAnnotationAsync(CreateAnnotationDto createDto, string annotatorUserId); + Task CreateAnnotationAsync(int projectId, CreateAnnotationDto createDto, string annotatorUserId); /// /// Updates an existing annotation. /// + /// The ID of the project used to scope access. /// The ID of the annotation to update. /// The DTO containing updated annotation information. /// A task that represents the asynchronous operation, containing the updated AnnotationDto if successful, otherwise null. - Task UpdateAnnotationAsync(long annotationId, UpdateAnnotationDto updateDto); + Task UpdateAnnotationAsync(int projectId, long annotationId, UpdateAnnotationDto updateDto); /// /// Deletes an annotation by its ID. /// + /// The ID of the project used to scope access. /// The ID of the annotation to delete. /// A task that represents the asynchronous operation, returning true if the annotation was successfully deleted, otherwise false. - Task DeleteAnnotationAsync(long annotationId); + Task DeleteAnnotationAsync(int projectId, long annotationId); } diff --git a/server/Server/Services/Interfaces/IAssetService.cs b/server/Server/Services/Interfaces/IAssetService.cs index 4fef980..bf100cd 100644 --- a/server/Server/Services/Interfaces/IAssetService.cs +++ b/server/Server/Services/Interfaces/IAssetService.cs @@ -18,22 +18,23 @@ public interface IAssetService /// The page number for pagination (1-based index). /// The number of items per page. /// Whether to include presigned URLs in the response (default: true). - /// The expiry time for URLs in seconds (default: 1 hour). + /// The expiry time for URLs in seconds (default: 300 seconds). /// A task that represents the asynchronous operation, containing a collection of AssetDto. Task> GetAssetsForProjectAsync( int projectId, int? dataSourceId = null, string? filterOn = null, string? filterQuery = null, string? sortBy = null, bool isAscending = true, int pageNumber = 1, int pageSize = 25, - bool includeUrls = true, int urlExpiryInSeconds = 3600 + bool includeUrls = true, int urlExpiryInSeconds = 300 ); /// - /// Retrieves an asset by its ID. + /// Retrieves an asset by its ID within project scope. /// + /// The ID of the project to scope the asset to. /// The ID of the asset to retrieve. /// A task that represents the asynchronous operation, containing the AssetDto if found, otherwise null. - Task GetAssetByIdAsync(int assetId); + Task GetAssetByIdAsync(int projectId, int assetId); /// /// Gets the count of available assets for task creation in a specific data source. @@ -59,28 +60,31 @@ Task> GetAssetsForProjectAsync( Task CreateAssetAsync(int projectId, CreateAssetDto createDto); /// - /// Updates an existing asset. + /// Updates an existing asset within project scope. /// + /// The project ID used to scope access. /// The ID of the asset to update. /// The DTO containing updated asset information. /// A task that represents the asynchronous operation, containing the updated AssetDto if successful, otherwise null. - Task UpdateAssetAsync(int assetId, UpdateAssetDto updateDto); + Task UpdateAssetAsync(int projectId, int assetId, UpdateAssetDto updateDto); /// - /// Deletes an asset by its ID. + /// Deletes an asset by its ID within project scope. /// + /// The project ID used to scope access. /// The ID of the asset to delete. /// A task that represents the asynchronous operation, returning true if the asset was successfully deleted, otherwise false. - Task DeleteAssetAsync(int assetId); + Task DeleteAssetAsync(int projectId, int assetId); /// - /// Transfers an asset from its current data source to a target data source. + /// Transfers an asset from its current data source to a target data source within project scope. /// This involves copying the file in MinIO and updating the database record. /// + /// The project ID used to scope access. /// The ID of the asset to transfer /// The ID of the target data source /// True if the transfer was successful, false otherwise - Task TransferAssetToDataSourceAsync(int assetId, int targetDataSourceId); + Task TransferAssetToDataSourceAsync(int projectId, int assetId, int targetDataSourceId); /// /// Uploads a single asset file. @@ -97,4 +101,4 @@ Task> GetAssetsForProjectAsync( /// The bulk upload DTO containing the files and metadata. /// A task that represents the asynchronous operation, containing the bulk upload result. Task UploadAssetsAsync(int projectId, BulkUploadAssetDto bulkUploadDto); -} \ No newline at end of file +} diff --git a/server/Server/Services/Interfaces/IPermissionConfigurationService.cs b/server/Server/Services/Interfaces/IPermissionConfigurationService.cs index 31e6c9c..912e188 100644 --- a/server/Server/Services/Interfaces/IPermissionConfigurationService.cs +++ b/server/Server/Services/Interfaces/IPermissionConfigurationService.cs @@ -30,6 +30,13 @@ public interface IPermissionConfigurationService /// HashSet of global permission strings HashSet GetGlobalPermissions(); + /// + /// Gets global permissions for a specific user based on system roles. + /// + /// Identity roles of the user + /// Global permissions available to the user + HashSet GetGlobalPermissionsForUser(IList userRoles); + /// /// Builds a comprehensive permission context for a user based on their project memberships. /// @@ -52,4 +59,4 @@ public interface IPermissionConfigurationService /// void ReloadConfiguration(); } -} \ No newline at end of file +} From 8977f4fe0e940ed8e7fd358643bbffa528ec0554 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:38:51 +0200 Subject: [PATCH 03/15] refactor: Update asset service and controller tests to include project ID in method calls --- .../Controllers/AssetsControllerTests.cs | 60 +++++++++---------- .../Workflow/Steps/AssetTransferStepTests.cs | 28 ++++----- .../Services/AssetServiceTests.cs | 4 +- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/server/Server.Tests/Controllers/AssetsControllerTests.cs b/server/Server.Tests/Controllers/AssetsControllerTests.cs index ac91d22..41655b0 100644 --- a/server/Server.Tests/Controllers/AssetsControllerTests.cs +++ b/server/Server.Tests/Controllers/AssetsControllerTests.cs @@ -96,7 +96,7 @@ public async Task GetAssetsForProject_Should_ReturnOkResult_WithAssets() }; _mockAssetService.Setup(s => s.GetAssetsForProjectAsync( - projectId, null, null, null, null, true, 1, 25, true, 3600 + projectId, null, null, null, null, true, 1, 25, true, 300 ) ).ReturnsAsync(expectedAssets); @@ -111,7 +111,7 @@ public async Task GetAssetsForProject_Should_ReturnOkResult_WithAssets() Assert.Equal(expectedAssets.Data, paginatedResponse.Data); _mockAssetService.Verify(s => s.GetAssetsForProjectAsync( - projectId, null, null, null, null, true, 1, 25, true, 3600 + projectId, null, null, null, null, true, 1, 25, true, 300 ), Times.Once ); @@ -148,7 +148,7 @@ public async Task GetAssetsForProject_Should_ReturnOkResult_WithFilteredAssets() }; _mockAssetService.Setup(s => s.GetAssetsForProjectAsync( - projectId, null, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize, true, 3600) + projectId, null, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize, true, 300) ).ReturnsAsync(expectedAssets); // Act @@ -163,7 +163,7 @@ public async Task GetAssetsForProject_Should_ReturnOkResult_WithFilteredAssets() _mockAssetService.Verify( s => s.GetAssetsForProjectAsync( - projectId, null, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize, true, 3600 + projectId, null, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize, true, 300 ), Times.Once ); @@ -184,7 +184,7 @@ public async Task GetAssetsForProject_Should_ReturnOkResult_WithEmptyList_WhenNo }; _mockAssetService.Setup(s => s.GetAssetsForProjectAsync( - projectId, null, null, null, null, true, 1, 25, true, 3600 + projectId, null, null, null, null, true, 1, 25, true, 300 )).ReturnsAsync(expectedAssets); // Act @@ -203,7 +203,7 @@ public async Task GetAssetsForProject_Should_ThrowException_WhenServiceThrowsExc // Arrange var projectId = 1; _mockAssetService.Setup(s => s.GetAssetsForProjectAsync( - projectId, null, null, null, null, true, 1, 25, true, 3600 + projectId, null, null, null, null, true, 1, 25, true, 300 )).ThrowsAsync(new Exception("Database error")); // Act & Assert @@ -232,11 +232,11 @@ public async Task GetAssetById_Should_ReturnOkResult_WhenAssetExists() UpdatedAt = DateTime.UtcNow, }; - _mockAssetService.Setup(s => s.GetAssetByIdAsync(assetId)) + _mockAssetService.Setup(s => s.GetAssetByIdAsync(It.IsAny(), assetId)) .ReturnsAsync(expectedAsset); // Act - var result = await _controller.GetAssetById(assetId); + var result = await _controller.GetAssetById(1, assetId); // Assert var okResult = Assert.IsType(result); @@ -244,7 +244,7 @@ public async Task GetAssetById_Should_ReturnOkResult_WhenAssetExists() Assert.Equal(expectedAsset.Id, asset.Id); Assert.Equal(expectedAsset.Filename, asset.Filename); - _mockAssetService.Verify(s => s.GetAssetByIdAsync(assetId), Times.Once); + _mockAssetService.Verify(s => s.GetAssetByIdAsync(1, assetId), Times.Once); } [Fact] @@ -253,11 +253,11 @@ public async Task GetAssetById_Should_ReturnNotFound_WhenAssetDoesNotExist() // Arrange var assetId = 999; - _mockAssetService.Setup(s => s.GetAssetByIdAsync(assetId)) + _mockAssetService.Setup(s => s.GetAssetByIdAsync(It.IsAny(), assetId)) .ReturnsAsync((AssetDto?)null); // Act - var result = await _controller.GetAssetById(assetId); + var result = await _controller.GetAssetById(1, assetId); // Assert var notFoundResult = Assert.IsType(result); @@ -265,7 +265,7 @@ public async Task GetAssetById_Should_ReturnNotFound_WhenAssetDoesNotExist() Assert.Equal(404, errorResponse.StatusCode); Assert.Contains($"Asset with ID '{assetId}' was not found", errorResponse.Message); - _mockAssetService.Verify(s => s.GetAssetByIdAsync(assetId), Times.Once); + _mockAssetService.Verify(s => s.GetAssetByIdAsync(1, assetId), Times.Once); } [Fact] @@ -274,12 +274,12 @@ public async Task GetAssetById_Should_ThrowException_WhenServiceThrowsException( // Arrange var assetId = 1; - _mockAssetService.Setup(s => s.GetAssetByIdAsync(assetId)) + _mockAssetService.Setup(s => s.GetAssetByIdAsync(It.IsAny(), assetId)) .ThrowsAsync(new Exception("Database error")); // Act & Assert var exception = await Assert.ThrowsAsync(() => - _controller.GetAssetById(assetId)); + _controller.GetAssetById(1, assetId)); Assert.Equal("Database error", exception.Message); } @@ -381,11 +381,11 @@ public async Task UpdateAsset_Should_ReturnOkResult_WhenAssetUpdatedSuccessfully UpdatedAt = DateTime.UtcNow, }; - _mockAssetService.Setup(s => s.UpdateAssetAsync(assetId, updateAssetDto)) + _mockAssetService.Setup(s => s.UpdateAssetAsync(1, assetId, updateAssetDto)) .ReturnsAsync(updatedAsset); // Act - var result = await _controller.UpdateAsset(assetId, updateAssetDto); + var result = await _controller.UpdateAsset(1, assetId, updateAssetDto); // Assert var okResult = Assert.IsType(result); @@ -394,7 +394,7 @@ public async Task UpdateAsset_Should_ReturnOkResult_WhenAssetUpdatedSuccessfully Assert.Equal(updatedAsset.Filename, asset.Filename); Assert.Equal(updatedAsset.Status, asset.Status); - _mockAssetService.Verify(s => s.UpdateAssetAsync(assetId, updateAssetDto), Times.Once); + _mockAssetService.Verify(s => s.UpdateAssetAsync(1, assetId, updateAssetDto), Times.Once); } [Fact] @@ -408,11 +408,11 @@ public async Task UpdateAsset_Should_ReturnNotFound_WhenAssetDoesNotExist() Status = AssetStatus.IMPORTED }; - _mockAssetService.Setup(s => s.UpdateAssetAsync(assetId, updateAssetDto)) + _mockAssetService.Setup(s => s.UpdateAssetAsync(1, assetId, updateAssetDto)) .ReturnsAsync((AssetDto?)null); // Act - var result = await _controller.UpdateAsset(assetId, updateAssetDto); + var result = await _controller.UpdateAsset(1, assetId, updateAssetDto); // Assert var notFoundResult = Assert.IsType(result); @@ -420,7 +420,7 @@ public async Task UpdateAsset_Should_ReturnNotFound_WhenAssetDoesNotExist() Assert.Equal(404, errorResponse.StatusCode); Assert.Contains($"Asset with ID '{assetId}' was not found", errorResponse.Message); - _mockAssetService.Verify(s => s.UpdateAssetAsync(assetId, updateAssetDto), Times.Once); + _mockAssetService.Verify(s => s.UpdateAssetAsync(1, assetId, updateAssetDto), Times.Once); } [Fact] @@ -434,12 +434,12 @@ public async Task UpdateAsset_Should_ThrowException_WhenServiceThrowsException() Status = AssetStatus.IMPORTED }; - _mockAssetService.Setup(s => s.UpdateAssetAsync(assetId, updateAssetDto)) + _mockAssetService.Setup(s => s.UpdateAssetAsync(1, assetId, updateAssetDto)) .ThrowsAsync(new Exception("Database error")); // Act & Assert var exception = await Assert.ThrowsAsync(() => - _controller.UpdateAsset(assetId, updateAssetDto)); + _controller.UpdateAsset(1, assetId, updateAssetDto)); Assert.Equal("Database error", exception.Message); } @@ -453,16 +453,16 @@ public async Task DeleteAsset_Should_ReturnNoContent_WhenAssetDeletedSuccessfull // Arrange var assetId = 1; - _mockAssetService.Setup(s => s.DeleteAssetAsync(assetId)) + _mockAssetService.Setup(s => s.DeleteAssetAsync(1, assetId)) .ReturnsAsync(true); // Act - var result = await _controller.DeleteAsset(assetId); + var result = await _controller.DeleteAsset(1, assetId); // Assert Assert.IsType(result); - _mockAssetService.Verify(s => s.DeleteAssetAsync(assetId), Times.Once); + _mockAssetService.Verify(s => s.DeleteAssetAsync(1, assetId), Times.Once); } [Fact] @@ -471,11 +471,11 @@ public async Task DeleteAsset_Should_ReturnNotFound_WhenAssetDoesNotExist() // Arrange var assetId = 999; - _mockAssetService.Setup(s => s.DeleteAssetAsync(assetId)) + _mockAssetService.Setup(s => s.DeleteAssetAsync(1, assetId)) .ReturnsAsync(false); // Act - var result = await _controller.DeleteAsset(assetId); + var result = await _controller.DeleteAsset(1, assetId); // Assert var notFoundResult = Assert.IsType(result); @@ -483,7 +483,7 @@ public async Task DeleteAsset_Should_ReturnNotFound_WhenAssetDoesNotExist() Assert.Equal(404, errorResponse.StatusCode); Assert.Contains($"Asset with ID '{assetId}' was not found", errorResponse.Message); - _mockAssetService.Verify(s => s.DeleteAssetAsync(assetId), Times.Once); + _mockAssetService.Verify(s => s.DeleteAssetAsync(1, assetId), Times.Once); } [Fact] @@ -492,12 +492,12 @@ public async Task DeleteAsset_Should_ThrowException_WhenServiceThrowsException() // Arrange var assetId = 1; - _mockAssetService.Setup(s => s.DeleteAssetAsync(assetId)) + _mockAssetService.Setup(s => s.DeleteAssetAsync(1, assetId)) .ThrowsAsync(new Exception("Database error")); // Act & Assert var exception = await Assert.ThrowsAsync(() => - _controller.DeleteAsset(assetId)); + _controller.DeleteAsset(1, assetId)); Assert.Equal("Database error", exception.Message); } diff --git a/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs b/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs index 039b858..813b396 100644 --- a/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs +++ b/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs @@ -42,7 +42,7 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithValidContext_Sho var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value)) .ReturnsAsync(true); var step = CreateStep(); @@ -53,7 +53,7 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithValidContext_Sho // Assert Assert.NotNull(result); Assert.Equal(targetStage, result.TargetStage); - _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); + _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); } [Fact] @@ -67,7 +67,7 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithTransferFailure_ var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value)) .ReturnsAsync(false); var step = CreateStep(); @@ -132,7 +132,7 @@ public async System.Threading.Tasks.Task TransferAssetToAnnotationAsync_WithVali _mockDataSourceService.Setup(x => x.EnsureRequiredDataSourcesExistAsync(asset.ProjectId, false)) .ReturnsAsync(mockWorkflowDataSources); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, mockDataSource.Id)) + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, mockDataSource.Id)) .ReturnsAsync(true); var step = CreateStep(); @@ -142,7 +142,7 @@ public async System.Threading.Tasks.Task TransferAssetToAnnotationAsync_WithVali // Assert Assert.NotNull(result); - _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, mockDataSource.Id), Times.Once); + _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, mockDataSource.Id), Times.Once); } [Fact] @@ -160,7 +160,7 @@ public async System.Threading.Tasks.Task TransferAssetToAnnotationAsync_WithTran _mockDataSourceService.Setup(x => x.EnsureRequiredDataSourcesExistAsync(asset.ProjectId, false)) .ReturnsAsync(mockWorkflowDataSources); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, mockDataSource.Id)) + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, mockDataSource.Id)) .ReturnsAsync(false); var step = CreateStep(); @@ -183,7 +183,7 @@ public async System.Threading.Tasks.Task ExecuteAsync_ShouldDelegateToTransferAs var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value)) .ReturnsAsync(true); var step = CreateStep(); @@ -193,7 +193,7 @@ public async System.Threading.Tasks.Task ExecuteAsync_ShouldDelegateToTransferAs // Assert Assert.NotNull(result); - _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); + _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); } [Fact] @@ -207,7 +207,7 @@ public async System.Threading.Tasks.Task RollbackAsync_WithValidContext_ShouldTr var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value)) .ReturnsAsync(true); var step = CreateStep(); @@ -217,7 +217,7 @@ public async System.Threading.Tasks.Task RollbackAsync_WithValidContext_ShouldTr // Assert Assert.True(result); - _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); + _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); } [Fact] @@ -229,7 +229,7 @@ public async System.Threading.Tasks.Task RollbackAsync_WithTransferFailure_Shoul var currentStage = CreateTestWorkflowStage(1, WorkflowStageType.ANNOTATION, 1); var context = new PipelineContext(task, asset, currentStage, "user123"); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(It.IsAny(), It.IsAny())) + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(false); var step = CreateStep(); @@ -268,7 +268,7 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithVariousWorkflowS var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value)) .ReturnsAsync(true); var step = CreateStep(); @@ -278,7 +278,7 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithVariousWorkflowS // Assert Assert.NotNull(result); - _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); + _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.ProjectId, asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); } #region Helper Methods @@ -333,4 +333,4 @@ private AssetTransferStep CreateStep() } #endregion -} \ No newline at end of file +} diff --git a/server/Server.Tests/Services/AssetServiceTests.cs b/server/Server.Tests/Services/AssetServiceTests.cs index c7d5deb..efe7c6f 100644 --- a/server/Server.Tests/Services/AssetServiceTests.cs +++ b/server/Server.Tests/Services/AssetServiceTests.cs @@ -68,7 +68,7 @@ public async System.Threading.Tasks.Task GetAssetByIdAsync_Should_ReturnAssetDto .ReturnsAsync(asset); // Act - var result = await _assetService.GetAssetByIdAsync(assetId); + var result = await _assetService.GetAssetByIdAsync(asset.ProjectId, assetId); // Assert Assert.NotNull(result); @@ -88,7 +88,7 @@ public async System.Threading.Tasks.Task GetAssetByIdAsync_Should_ReturnNull_Whe .ReturnsAsync((Asset?)null); // Act - var result = await _assetService.GetAssetByIdAsync(assetId); + var result = await _assetService.GetAssetByIdAsync(1, assetId); // Assert Assert.Null(result); From 5e37479b8987eaf1c0047e7621c6eec6ecaaa259 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:39:04 +0200 Subject: [PATCH 04/15] refactor: Remove permission store and related logic, transitioning to backend-driven authorization --- .../src/stores/__tests__/authStore.test.ts | 7 +- frontend/src/stores/authStore.ts | 30 +-- frontend/src/stores/permissionStore.ts | 194 ------------------ 3 files changed, 5 insertions(+), 226 deletions(-) delete mode 100644 frontend/src/stores/permissionStore.ts diff --git a/frontend/src/stores/__tests__/authStore.test.ts b/frontend/src/stores/__tests__/authStore.test.ts index dbcbeb7..8455aef 100644 --- a/frontend/src/stores/__tests__/authStore.test.ts +++ b/frontend/src/stores/__tests__/authStore.test.ts @@ -11,12 +11,7 @@ vi.mock("@/services/auth/authService", () => ({ }, })); -vi.mock("../permissionStore", () => ({ - usePermissionStore: vi.fn(() => ({ - loadUserPermissions: vi.fn().mockResolvedValue(undefined), - clearPermissions: vi.fn(), - })), -})); +// No permission store in FE anymore; authorization is backend-driven. import { authService } from "@/services/auth/authService.ts"; import { RoleEnum, type AuthTokens, type LoginDto, type UserDto } from "@/services/auth/auth.types"; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index c2e983c..03514aa 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -4,7 +4,6 @@ import { authService } from "@/services/auth/authService"; import { env } from "@/config/env"; import { AppLogger } from "@/core/logger/logger"; import { LastProjectManager } from "@/core/persistence"; -import { usePermissionStore } from "./permissionStore"; import { isUnauthorizedError } from "@/core/errors/errors"; import type { AuthTokens, LoginDto, RegisterDto, RoleEnum, UserDto } from "@/services/auth/auth.types"; @@ -75,16 +74,7 @@ export const useAuthStore = defineStore("auth", { // Only fetch user data if we successfully got tokens await this.getCurrentUser(); - logger.info("User data fetched successfully, loading permissions"); - // Load permissions after getting user data - try { - const permissionStore = usePermissionStore(); - await permissionStore.loadUserPermissions(); - logger.info("User permissions loaded during auth initialization"); - } catch (permissionError) { - logger.warn("Failed to load permissions during initialization", permissionError); - // Don't fail initialization if permissions fail to load - } + logger.info("User data fetched successfully"); } else { logger.info("Token refresh failed during initialization - user will start as guest"); } @@ -105,15 +95,7 @@ export const useAuthStore = defineStore("auth", { this.user = response.user; this.tokens = response.tokens; - // Load user permissions after successful login - try { - const permissionStore = usePermissionStore(); - await permissionStore.loadUserPermissions(); - logger.info("User permissions loaded after login"); - } catch (permissionError) { - logger.warn("Failed to load permissions after login", permissionError); - // Don't fail login if permissions fail to load - } + // Permissions now checked on-demand via backend authorize } catch (error) { logger.error("Login failed", error); @@ -146,9 +128,7 @@ export const useAuthStore = defineStore("auth", { } catch (error) { logger.error("Logout request failed", error); } finally { - // Clear permission data on logout - const permissionStore = usePermissionStore(); - permissionStore.clearPermissions(); + // No frontend permission cache to clear this.user = null; this.tokens = null; @@ -162,9 +142,7 @@ export const useAuthStore = defineStore("auth", { LastProjectManager.clearLastProject(this.user.email); } - // Clear permission data - const permissionStore = usePermissionStore(); - permissionStore.clearPermissions(); + // No frontend permission cache to clear this.user = null; this.tokens = null; diff --git a/frontend/src/stores/permissionStore.ts b/frontend/src/stores/permissionStore.ts deleted file mode 100644 index 213def2..0000000 --- a/frontend/src/stores/permissionStore.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { defineStore } from "pinia"; -import { permissionService } from "@/services/auth"; -import type { UserPermissionContext } from "@/services/auth/permissions.types"; -import { AppLogger } from "@/core/logger/logger"; - -const logger = AppLogger.createStoreLogger('PermissionStore'); - -export const usePermissionStore = defineStore("permissions", { - state: () => ({ - userContext: null as UserPermissionContext | null, - isLoading: false, - isInitialized: false, - error: null as string | null, - }), - - getters: { - /** - * Gets all permissions for the current user (global + all project permissions) - */ - allPermissions(state): Set { - if (!state.userContext) return new Set(); - return new Set(state.userContext.permissions); - }, - - /** - * Gets global permissions (available regardless of project context) - */ - globalPermissions(state): Set { - if (!state.userContext) return new Set(); - return new Set(state.userContext.globalPermissions); - }, - - /** - * Gets permissions for a specific project - */ - getProjectPermissions: (state) => (projectId: number): Set => { - if (!state.userContext || !projectId) return new Set(); - const projectPermissions = state.userContext.projectPermissions[projectId]; - return new Set(projectPermissions || []); - }, - - /** - * Checks if user has a specific permission globally - */ - hasGlobalPermission: (state) => (permission: string): boolean => { - return state.userContext?.globalPermissions.includes(permission) || false; - }, - - /** - * Checks if user has a specific permission in any project - */ - hasAnyPermission: (state) => (permission: string): boolean => { - return state.userContext?.permissions.includes(permission) || false; - }, - - /** - * Checks if user has a specific permission in a specific project - */ - hasProjectPermission: (state) => (permission: string, projectId: number): boolean => { - if (!state.userContext || !projectId) return false; - const projectPermissions = state.userContext.projectPermissions[projectId]; - return projectPermissions?.includes(permission) || false; - }, - - /** - * Checks if user has any of the specified permissions globally - */ - hasAnyGlobalPermission: (state) => (permissions: string[]): boolean => { - if (!state.userContext) return false; - return permissions.some(permission => - state.userContext!.globalPermissions.includes(permission) - ); - }, - - /** - * Checks if user has all of the specified permissions globally - */ - hasAllGlobalPermissions: (state) => (permissions: string[]): boolean => { - if (!state.userContext) return false; - return permissions.every(permission => - state.userContext!.globalPermissions.includes(permission) - ); - }, - - /** - * Checks if user has any of the specified permissions in a project - */ - hasAnyProjectPermission: (state) => (permissions: string[], projectId: number): boolean => { - if (!state.userContext || !projectId) return false; - const projectPermissions = state.userContext.projectPermissions[projectId] || []; - return permissions.some(permission => projectPermissions.includes(permission)); - }, - - /** - * Checks if user has all of the specified permissions in a project - */ - hasAllProjectPermissions: (state) => (permissions: string[], projectId: number): boolean => { - if (!state.userContext || !projectId) return false; - const projectPermissions = state.userContext.projectPermissions[projectId] || []; - return permissions.every(permission => projectPermissions.includes(permission)); - }, - - /** - * Gets all project IDs where user has membership - */ - projectIds(state): number[] { - if (!state.userContext) return []; - return Object.keys(state.userContext.projectPermissions).map(id => parseInt(id)); - }, - - /** - * Checks if user is a member of a specific project - */ - isProjectMember: (state) => (projectId: number): boolean => { - if (!state.userContext || !projectId) return false; - return Object.prototype.hasOwnProperty.call(state.userContext.projectPermissions, projectId); - }, - }, - - actions: { - /** - * Loads the full user permission context from the backend - */ - async loadUserPermissions(): Promise { - if (this.isLoading) return; - - this.isLoading = true; - this.error = null; - - try { - logger.info("Loading user permission context"); - this.userContext = await permissionService.getUserPermissionContext(); - this.isInitialized = true; - - const permissionCount = this.userContext.permissions.length; - const projectCount = this.projectIds.length; - - logger.info(`Loaded ${permissionCount} total permissions for ${projectCount} projects`); - } catch (error) { - this.error = "Failed to load user permissions"; - logger.error("Failed to load user permission context", error); - throw error; - } finally { - this.isLoading = false; - } - }, - - /** - * Gets page-specific permissions (hybrid approach) - */ - async getPagePermissions(page: string, projectId?: number): Promise { - try { - logger.debug(`Getting page permissions for: ${page}${projectId ? ` (project: ${projectId})` : ''}`); - return await permissionService.getPagePermissions(page, projectId); - } catch (error) { - logger.error(`Failed to get page permissions for ${page}`, error); - return []; - } - }, - - /** - * Clears all permission data (for logout) - */ - clearPermissions(): void { - this.userContext = null; - this.isInitialized = false; - this.error = null; - logger.info("Cleared user permissions"); - }, - - /** - * Refreshes permission data from the backend - */ - async refreshPermissions(): Promise { - this.isInitialized = false; - await this.loadUserPermissions(); - }, - - /** - * Admin-only: Reload permission configuration from file - */ - async reloadConfiguration(): Promise { - try { - await permissionService.reloadPermissionConfiguration(); - // Refresh user permissions after configuration reload - await this.refreshPermissions(); - logger.info("Permission configuration reloaded successfully"); - } catch (error) { - logger.error("Failed to reload permission configuration", error); - throw error; - } - }, - }, -}); \ No newline at end of file From e47b4bd084c73dab3cf8fef1f500bc4f0ae0e041 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:39:17 +0200 Subject: [PATCH 05/15] refactor: Introduce AuthorizationService for backend-driven permission checks and remove PermissionService --- .../src/services/auth/authorizationService.ts | 46 +++++++++++++ frontend/src/services/auth/index.ts | 2 +- .../src/services/auth/permissionService.ts | 65 ------------------- .../src/services/auth/permissions.types.ts | 11 +--- 4 files changed, 49 insertions(+), 75 deletions(-) create mode 100644 frontend/src/services/auth/authorizationService.ts delete mode 100644 frontend/src/services/auth/permissionService.ts diff --git a/frontend/src/services/auth/authorizationService.ts b/frontend/src/services/auth/authorizationService.ts new file mode 100644 index 0000000..34567a6 --- /dev/null +++ b/frontend/src/services/auth/authorizationService.ts @@ -0,0 +1,46 @@ +import { BaseService } from '../base/baseService'; +import { AppLogger } from '@/core/logger/logger'; + +export interface AuthorizationCheckContext { + projectId?: number; + [key: string]: any; +} + +export interface AuthorizationCheck { + permission: string; + context?: AuthorizationCheckContext; +} + +export interface AuthorizationBatchResponse { + results: boolean[]; + policyVersion?: string; +} + +/** + * AuthorizationService calls backend to evaluate permissions authoritatively. + * This replaces frontend-cached permission context and supports batching. + */ +class AuthorizationService extends BaseService { + protected readonly baseUrl = '/permissions'; + private readonly log = AppLogger.createServiceLogger('AuthorizationService'); + + async authorize(check: AuthorizationCheck): Promise { + const resp = await this.post( + this.getBaseUrl('authorize'), + check + ); + this.log.info(`authorize(${check.permission}) => ${resp.allow}`); + return resp.allow; + } + + async authorizeBatch(checks: AuthorizationCheck[]): Promise { + const resp = await this.post<{ checks: AuthorizationCheck[] }, AuthorizationBatchResponse>( + this.getBaseUrl('authorize/batch'), + { checks } + ); + this.log.info(`authorizeBatch(${checks.length}) => [${resp.results.join(',')}]`); + return resp; + } +} + +export const authorizationService = new AuthorizationService("AuthorizationService"); diff --git a/frontend/src/services/auth/index.ts b/frontend/src/services/auth/index.ts index 79a358a..abd5cf8 100644 --- a/frontend/src/services/auth/index.ts +++ b/frontend/src/services/auth/index.ts @@ -1,2 +1,2 @@ export { authService } from './authService'; -export { permissionService } from './permissionService'; \ No newline at end of file +export { authorizationService } from './authorizationService'; diff --git a/frontend/src/services/auth/permissionService.ts b/frontend/src/services/auth/permissionService.ts deleted file mode 100644 index ff0b9f3..0000000 --- a/frontend/src/services/auth/permissionService.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { BaseService } from '../base/baseService'; -import type { UserPermissionContext } from './permissions.types'; - -/** - * Service class for managing application configuration and permissions. - */ -class PermissionService extends BaseService { - protected readonly baseUrl = '/configuration'; // TODO: Check BE and change endpoint to permission - - constructor() { - super('ConfigurationService'); - } - - /** - * Gets the full user permission context including all project memberships and global permissions. - * This is used for comprehensive permission caching on the frontend. - */ - async getUserPermissionContext(): Promise { - this.logger.info('Fetching user permission context...'); - - const response = await this.get( - this.getBaseUrl('permissions/user-context') - ); - - this.logger.info( - `Fetched permission context with ${response.permissions.length} total permissions across ${Object.keys(response.projectPermissions).length} projects` - ); - return response; - } - - /** - * Gets page-specific permissions for the current user. - * Hybrid mode endpoint that provides permissions on-demand for specific pages/routes. - */ - async getPagePermissions(page: string, projectId?: number): Promise { - this.logger.info(`Fetching page permissions for page '${page}'${projectId ? ` with project ${projectId}` : ''}...`); - - const params: Record = { page }; - if (projectId) { - params.projectId = projectId; - } - - const response = await this.get( - this.getBaseUrl('permissions/page'), - { params } - ); - - this.logger.info(`Fetched ${response.length} permissions for page '${page}'`); - return response; - } - - /** - * Admin-only endpoint to reload permission configuration from file - */ - async reloadPermissionConfiguration(): Promise { - this.logger.info('Reloading permission configuration...'); - - await this.post(this.getBaseUrl('permissions/reload'), undefined); - - this.logger.info('Permission configuration reloaded successfully'); - } -} - -// Export singleton instance -export const permissionService = new PermissionService(); diff --git a/frontend/src/services/auth/permissions.types.ts b/frontend/src/services/auth/permissions.types.ts index 5062eb9..43c3cf7 100644 --- a/frontend/src/services/auth/permissions.types.ts +++ b/frontend/src/services/auth/permissions.types.ts @@ -1,11 +1,4 @@ -/** - * User permission context containing all applicable permissions for a user - */ -export interface UserPermissionContext { - permissions: string[]; - projectPermissions: Record; - globalPermissions: string[]; -} +// FE no longer stores permission contexts; backend is source of truth. /** * Permission directive binding interface for v-permission directive @@ -89,4 +82,4 @@ export const PERMISSIONS = { DELETE: 'annotation:delete', REVIEW: 'annotation:review' } -} as const; \ No newline at end of file +} as const; From 6220e4d77a7548e601367cda175437143b14746b Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:39:27 +0200 Subject: [PATCH 06/15] refactor: Remove permission store dependency and streamline permission handling in API client --- frontend/src/services/apiClient.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index 8c4f12b..66e6549 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -2,7 +2,6 @@ import axios from 'axios'; import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import { env } from '@/config/env'; import type { useAuthStore } from '@/stores/authStore'; -import type { usePermissionStore } from '@/stores/permissionStore'; import { isAuthOrPublicPath, isAuthPath } from '@/router/routes'; import { isPermissionChangingEndpoint, getPermissionChangeDescription } from '@/core/interceptors'; import { AppLogger } from '@/core/logger/logger'; @@ -41,11 +40,9 @@ const processQueue = (error: Error | null, token: string | null = null) => { * Sets up the Axios interceptors for handling authentication and token refresh. * This should be called once when the application initializes. * @param authStore An instance of the authentication store. - * @param permissionStore An instance of the permission store. */ export function setupInterceptors( - authStore: ReturnType, - permissionStore: ReturnType + authStore: ReturnType ): void { // Request Interceptor: Add JWT token whenever available apiClient.interceptors.request.use( @@ -69,18 +66,9 @@ export function setupInterceptors( const { url, method } = response.config; if (response.status >= 200 && response.status < 300 && isPermissionChangingEndpoint(url, method || 'GET')) { - const description = getPermissionChangeDescription(url, method || 'GET'); logger.info(`Permission-changing request completed: ${method?.toUpperCase()} ${url} - ${description}`); - - // Automatically refresh permissions in the background - try { - await permissionStore.refreshPermissions(); - logger.info('Automatically refreshed permissions after API call'); - } catch (error) { - // Don't fail the original request if permission refresh fails - logger.warn('Failed to automatically refresh permissions after API call', error); - } + // FE now uses on-demand authorization checks; no global refresh required. } return response; @@ -158,4 +146,4 @@ export function setupInterceptors( ); } -export default apiClient; \ No newline at end of file +export default apiClient; From 9b2bbc637905b33003a12ed0c05e3a347253be30 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:40:05 +0200 Subject: [PATCH 07/15] refactor: Replace permission store with backend authorization service for route access control --- frontend/src/router/index.ts | 83 +++++++++++------------------------- 1 file changed, 25 insertions(+), 58 deletions(-) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 58b753f..ae813cf 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,9 +5,10 @@ import DefaultLayout from '@/layouts/DefaultLayout.vue'; import WorkspaceLayout from '@/layouts/WorkspaceLayout.vue'; import DataExplorerLayout from '@/layouts/DataExplorerLayout.vue'; import { useAuthStore } from '@/stores/authStore'; -import { usePermissionStore } from '@/stores/permissionStore'; +import { authorizationService } from '@/services/auth'; import { AppLogger } from '@/core/logger/logger'; import { PUBLIC_ROUTE_NAMES, AUTH_ROUTE_NAMES } from './routes'; +import { PERMISSIONS } from '@/services/auth/permissions.types'; const routes: Array = [ { @@ -80,6 +81,7 @@ const routes: Array = [ props: true, meta: { layout: WorkspaceLayout, + permissions: [PERMISSIONS.TASK.READ], } }, { @@ -97,6 +99,7 @@ const routes: Array = [ props: true, meta: { layout: DefaultLayout, + permissions: [PERMISSIONS.PROJECT.READ], }, children: [ { @@ -104,18 +107,21 @@ const routes: Array = [ name: 'ProjectDashboard', component: () => import('@/views/project/ProjectDashboardView.vue'), props: true, + meta: { permissions: [PERMISSIONS.PROJECT.READ] }, }, { path: 'label-schemes', name: 'ProjectLabels', component: () => import('@/views/project/LabelSchemesView.vue'), props: true, + meta: { permissions: [PERMISSIONS.LABEL_SCHEME.READ] }, }, { path: 'data-sources', name: 'ProjectDataSources', component: () => import('@/views/project/DataSourcesView.vue'), props: true, + meta: { permissions: [PERMISSIONS.DATA_SOURCE.UPDATE] }, }, { path: 'data-explorer/:dataSourceId', @@ -124,6 +130,7 @@ const routes: Array = [ props: true, meta: { layout: DataExplorerLayout, + permissions: [PERMISSIONS.DATA_SOURCE.UPDATE], } }, { @@ -131,24 +138,28 @@ const routes: Array = [ name: 'ProjectWorkflows', component: () => import('@/views/project/WorkflowsView.vue'), props: true, + meta: { permissions: [PERMISSIONS.WORKFLOW.READ] }, }, { path: 'workflows/:workflowId/pipeline', name: 'WorkflowPipeline', component: () => import('@/views/project/WorkflowPipelineView.vue'), props: true, + meta: { permissions: [PERMISSIONS.WORKFLOW.UPDATE] }, }, { path: 'workflows/:workflowId/stages/:stageId/tasks', name: 'StageTasks', component: () => import('@/views/project/TasksView.vue'), props: true, + meta: { permissions: [PERMISSIONS.TASK.READ] }, }, { path: 'settings', name: 'ProjectSettings', component: () => import('@/views/project/ProjectSettingsView.vue'), props: true, + meta: { permissions: [PERMISSIONS.PROJECT_SETTINGS.READ] }, }, ], }, @@ -177,7 +188,6 @@ const logger = AppLogger.createServiceLogger('Router'); // Navigation guards router.beforeEach(async (to, from, next) => { const authStore = useAuthStore(); - const permissionStore = usePermissionStore(); const isPublicRoute = PUBLIC_ROUTE_NAMES.includes(to.name as typeof PUBLIC_ROUTE_NAMES[number]); const isAuthRoute = AUTH_ROUTE_NAMES.includes(to.name as typeof AUTH_ROUTE_NAMES[number]); @@ -228,70 +238,27 @@ router.beforeEach(async (to, from, next) => { } else if (!isPublicRoute && !authStore.isAuthenticated) { // If user is not authenticated and trying to access protected routes, redirect to login next({ name: 'Login' }); - } else if (authStore.isAuthenticated && to.params.projectId) { - // Project-specific route - validate project membership using permission store - try { - const projectId = Number(to.params.projectId); - if (projectId && !isNaN(projectId)) { - // Ensure permissions are loaded - if (!permissionStore.isInitialized) { - await permissionStore.loadUserPermissions(); - } - - // Check if user is a member of the project - if (!permissionStore.isProjectMember(projectId)) { - logger.warn(`User is not a member of project ${projectId}`); - next({ name: 'Error', params: { type: 'unauthorized' } }); - return; - } - - // Check if route requires specific permissions - const requiredPermissions = to.meta?.permissions as string[] | undefined; - if (requiredPermissions && requiredPermissions.length > 0) { - const hasRequiredPermissions = permissionStore.hasAllProjectPermissions( - requiredPermissions, - projectId - ); - - if (!hasRequiredPermissions) { - logger.warn( - `User lacks required permissions for route ${String(to.name)}: ${requiredPermissions.join(', ')}` - ); - next({ name: 'Error', params: { type: 'forbidden' } }); - return; - } - } - - logger.info(`User has access to project ${projectId}`); - } + } else if (authStore.isAuthenticated) { + // If route defines permissions, verify via backend authorize (batch) + const requiredPermissions = (to.meta?.permissions as string[] | undefined) || []; + if (requiredPermissions.length === 0) { next(); - } catch (error) { - logger.error('Error validating project access', error); - // If validation fails, redirect to error page - next({ name: 'Error', params: { type: 'unauthorized' } }); + return; } - } else if (authStore.isAuthenticated && to.meta?.permissions) { - // Global route with permission requirements + try { - // Ensure permissions are loaded - if (!permissionStore.isInitialized) { - await permissionStore.loadUserPermissions(); - } - - const requiredPermissions = to.meta.permissions as string[]; - const hasRequiredPermissions = permissionStore.hasAllGlobalPermissions(requiredPermissions); - - if (!hasRequiredPermissions) { - logger.warn( - `User lacks required global permissions for route ${String(to.name)}: ${requiredPermissions.join(', ')}` - ); + const projectId = to.params.projectId ? Number(to.params.projectId) : undefined; + const checks = requiredPermissions.map(p => ({ permission: p, context: projectId ? { projectId } : undefined })); + const { results } = await authorizationService.authorizeBatch(checks); + const allow = results.every(Boolean); + if (!allow) { + logger.warn(`Permission denied for route ${String(to.name)} with perms: ${requiredPermissions.join(', ')}`); next({ name: 'Error', params: { type: 'forbidden' } }); return; } - next(); } catch (error) { - logger.error('Error validating global permissions', error); + logger.error('Authorization check failed', error); next({ name: 'Error', params: { type: 'unauthorized' } }); } } else { From 384270e33a114c8505cbcbba8ab3159c02240f86 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:40:11 +0200 Subject: [PATCH 08/15] refactor: Transition to authorization service for permission checks in vPermission directive --- frontend/src/directives/vPermission.ts | 68 ++++++++++++-------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/frontend/src/directives/vPermission.ts b/frontend/src/directives/vPermission.ts index f934f62..83f0f9a 100644 --- a/frontend/src/directives/vPermission.ts +++ b/frontend/src/directives/vPermission.ts @@ -1,7 +1,7 @@ import type { App, DirectiveBinding } from 'vue'; import { watch } from 'vue'; -import { usePermissionStore } from '@/stores/permissionStore'; import { useProjectStore } from '@/stores/projectStore'; +import { authorizationService } from '@/services/auth'; import { AppLogger } from '@/core/logger/logger'; import type { PermissionDirectiveBinding } from '@/services/auth/permissions.types'; @@ -9,32 +9,25 @@ const logger = AppLogger.createServiceLogger('vPermission'); export const vPermission = { mounted(el: HTMLElement, binding: DirectiveBinding) { - const permissionStore = usePermissionStore(); const projectStore = useProjectStore(); - // Initial check - checkPermission(el, binding); + // Initial check (async) + void checkPermission(el, binding); - // Watch for changes in permission store initialization and project ID - const unwatchPermissions = watch( - () => permissionStore.isInitialized, - () => { - checkPermission(el, binding); - } - ); + // Watch for project changes to re-evaluate const unwatchProject = watch( () => projectStore.currentProjectId, () => { - checkPermission(el, binding); + void checkPermission(el, binding); } ); // Store cleanup functions on the element for unmount - (el as any)._permissionWatchers = [unwatchPermissions, unwatchProject]; + (el as any)._permissionWatchers = [unwatchProject]; }, updated(el: HTMLElement, binding: DirectiveBinding) { - checkPermission(el, binding); + void checkPermission(el, binding); }, unmounted(el: HTMLElement) { // Cleanup watchers when directive is unmounted @@ -46,15 +39,8 @@ export const vPermission = { } }; -function checkPermission(el: HTMLElement, binding: DirectiveBinding) { - const permissionStore = usePermissionStore(); +async function checkPermission(el: HTMLElement, binding: DirectiveBinding) { const projectStore = useProjectStore(); - - // If permission store is not initialized, hide element by default - if (!permissionStore.isInitialized) { - hideElement(el); - return; - } const value = binding.value || {}; const { @@ -82,35 +68,41 @@ function checkPermission(el: HTMLElement, binding: DirectiveBinding ({ permission: p })) + ); + hasPermission = results.every(Boolean); } else { - hasPermission = permissionStore.hasAnyGlobalPermission(permissionsToCheck); + const { results } = await authorizationService.authorizeBatch( + permissionsToCheck.map(p => ({ permission: p })) + ); + hasPermission = results.some(Boolean); } } else { - // Check project permissions const projectId = project || projectStore.currentProject?.id; - if (!projectId) { logger.debug('No project ID available for permission check (project still loading)'); - hideElement(el); - return; + return; // remain hidden } - if (mode === 'all') { - hasPermission = permissionStore.hasAllProjectPermissions(permissionsToCheck, projectId); + const { results } = await authorizationService.authorizeBatch( + permissionsToCheck.map(p => ({ permission: p, context: { projectId } })) + ); + hasPermission = results.every(Boolean); } else { - hasPermission = permissionStore.hasAnyProjectPermission(permissionsToCheck, projectId); + const { results } = await authorizationService.authorizeBatch( + permissionsToCheck.map(p => ({ permission: p, context: { projectId } })) + ); + hasPermission = results.some(Boolean); } } - if (hasPermission) { - showElement(el); - } else { - hideElement(el); - } + if (hasPermission) showElement(el); } function showElement(el: HTMLElement) { @@ -127,4 +119,4 @@ export function registerPermissionDirective(app: App) { app.directive('permission', vPermission); } -export default vPermission; \ No newline at end of file +export default vPermission; From 17652046b4551e87144a432362089c425d5b3ecb Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:40:16 +0200 Subject: [PATCH 09/15] refactor: Transition to authorization service for permission checks and streamline permission handling --- frontend/src/composables/usePermissions.ts | 158 ++++++++++++++++----- 1 file changed, 122 insertions(+), 36 deletions(-) diff --git a/frontend/src/composables/usePermissions.ts b/frontend/src/composables/usePermissions.ts index 9cebcff..01e2c68 100644 --- a/frontend/src/composables/usePermissions.ts +++ b/frontend/src/composables/usePermissions.ts @@ -1,6 +1,6 @@ -import { computed, type ComputedRef } from 'vue'; +import { computed, reactive, type ComputedRef } from 'vue'; import { useAuthStore } from '@/stores/authStore'; -import { usePermissionStore } from '@/stores/permissionStore'; +import { authorizationService } from '@/services/auth'; import { useProjectStore } from '@/stores/projectStore'; /** @@ -9,7 +9,35 @@ import { useProjectStore } from '@/stores/projectStore'; */ export function usePermissions() { const authStore = useAuthStore(); - const permissionStore = usePermissionStore(); + // Lightweight reactive cache for authorization checks + const resultsCache = reactive>({}); + const pending = new Map>(); + + const makeKey = (perm: string, ctx?: { projectId?: number; global?: boolean }): string => { + const pid = ctx?.projectId ?? projectStore.currentProject?.id ?? 0; + const scope = ctx?.global ? 'g' : 'p'; + return `${scope}:${pid}:${perm}`; + }; + + const ensure = async (perm: string, ctx?: { projectId?: number; global?: boolean }): Promise => { + const key = makeKey(perm, ctx); + if (key in resultsCache) return resultsCache[key]; + if (pending.has(key)) return pending.get(key)!; + const promise = authorizationService + .authorize({ permission: perm, context: ctx?.projectId ? { projectId: ctx.projectId } : undefined }) + .then((allow) => { + resultsCache[key] = allow; + pending.delete(key); + return allow; + }) + .catch(() => { + resultsCache[key] = false; + pending.delete(key); + return false; + }); + pending.set(key, promise); + return promise; + }; const projectStore = useProjectStore(); // Permission-based checks @@ -18,37 +46,81 @@ export function usePermissions() { * Checks if user has a global permission (available regardless of project) */ const hasGlobalPermission = (permission: string): boolean => { - return permissionStore.hasGlobalPermission(permission); + // Trigger async fetch; return cached value if present + void ensure(permission, { global: true }); + const key = makeKey(permission, { global: true }); + return !!resultsCache[key]; }; /** * Checks if user has any of the specified global permissions */ const hasAnyGlobalPermission = (permissions: string[]): boolean => { - return permissionStore.hasAnyGlobalPermission(permissions); + permissions.forEach(p => void ensure(p, { global: true })); + return permissions.some(p => !!resultsCache[makeKey(p, { global: true })]); }; /** * Checks if user has all of the specified global permissions */ const hasAllGlobalPermissions = (permissions: string[]): boolean => { - return permissionStore.hasAllGlobalPermissions(permissions); + permissions.forEach(p => void ensure(p, { global: true })); + return permissions.every(p => !!resultsCache[makeKey(p, { global: true })]); }; /** - * Checks if user has a permission in the current project + * Checks if user has a permission in the current project (sync; warms cache asynchronously) */ const hasProjectPermission = (permission: string): boolean => { const currentProjectId = projectStore.currentProject?.id; if (!currentProjectId) return false; - return permissionStore.hasProjectPermission(permission, currentProjectId); + void ensure(permission, { projectId: currentProjectId }); + return !!resultsCache[makeKey(permission, { projectId: currentProjectId })]; + }; + + /** + * Asynchronously checks if user has a permission in the current project. + * Resolves after the authoritative backend check. + */ + const hasProjectPermissionAsync = async (permission: string): Promise => { + const currentProjectId = projectStore.currentProject?.id; + if (!currentProjectId) return false; + return ensure(permission, { projectId: currentProjectId }); }; /** * Checks if user has a permission in a specific project */ const hasPermissionInProject = (permission: string, projectId: number): boolean => { - return permissionStore.hasProjectPermission(permission, projectId); + if (!projectId) return false; + void ensure(permission, { projectId }); + return !!resultsCache[makeKey(permission, { projectId })]; + }; + + /** + * Prefetch a project-scoped permission to warm the cache. + */ + const prefetchProjectPermission = (permission: string, projectId?: number) => { + const pid = projectId ?? projectStore.currentProject?.id; + if (!pid) return Promise.resolve(false); + return ensure(permission, { projectId: pid }); + }; + + /** + * Wraps an action with a project-scoped permission check. If denied, logs and does nothing. + * Usage: const onClick = guardProjectAction(PERMISSIONS.TASK.READ, () => {...}) + */ + const guardProjectAction = (permission: string, action: (...args: any[]) => any, projectId?: number) => { + return async (...args: any[]) => { + const pid = projectId ?? projectStore.currentProject?.id; + if (!pid) return; + const allowed = await ensure(permission, { projectId: pid }); + if (!allowed) { + // TODO: soft-fail: optional place to integrate a toast/notification + return; + } + return action(...args); + }; }; /** @@ -57,7 +129,8 @@ export function usePermissions() { const hasAnyProjectPermission = (permissions: string[]): boolean => { const currentProjectId = projectStore.currentProject?.id; if (!currentProjectId) return false; - return permissionStore.hasAnyProjectPermission(permissions, currentProjectId); + permissions.forEach(p => void ensure(p, { projectId: currentProjectId })); + return permissions.some(p => !!resultsCache[makeKey(p, { projectId: currentProjectId })]); }; /** @@ -66,33 +139,24 @@ export function usePermissions() { const hasAllProjectPermissions = (permissions: string[]): boolean => { const currentProjectId = projectStore.currentProject?.id; if (!currentProjectId) return false; - return permissionStore.hasAllProjectPermissions(permissions, currentProjectId); + permissions.forEach(p => void ensure(p, { projectId: currentProjectId })); + return permissions.every(p => !!resultsCache[makeKey(p, { projectId: currentProjectId })]); }; /** * Checks if user has a permission anywhere (global or any project) */ const hasAnyPermission = (permission: string): boolean => { - return permissionStore.hasAnyPermission(permission); - }; - - /** - * Checks if user is a member of the current project - */ - const isCurrentProjectMember = computed(() => { + // Prefer project context when available const currentProjectId = projectStore.currentProject?.id; - if (!currentProjectId) return false; - return permissionStore.isProjectMember(currentProjectId); - }); + if (currentProjectId) { + return hasProjectPermission(permission); + } + return hasGlobalPermission(permission); + }; - /** - * Gets all permissions for the current project - */ - const currentProjectPermissions = computed(() => { - const currentProjectId = projectStore.currentProject?.id; - if (!currentProjectId) return new Set(); - return permissionStore.getProjectPermissions(currentProjectId); - }); + // Project membership proxy: treat project:read as membership capability + const isCurrentProjectMember = computed(() => hasProjectPermission('project:read')); // Reactive computed permission checkers @@ -126,13 +190,33 @@ export function usePermissions() { const canUpdateProject = computed(() => hasProjectPermission('project:update')); const canDeleteProject = computed(() => hasProjectPermission('project:delete')); const canManageProjectMembers = computed(() => hasProjectPermission('projectMember:invite')); - const canManageDataSources = computed(() => hasProjectPermission('dataSource:create')); - const canManageWorkflows = computed(() => hasProjectPermission('workflow:create')); - const canManageLabelSchemes = computed(() => hasProjectPermission('labelScheme:create')); + const canManageDataSources = computed(() => hasProjectPermission('dataSource:update')); + const canManageWorkflows = computed(() => hasProjectPermission('workflow:update')); + const canManageLabelSchemes = computed(() => hasProjectPermission('labelScheme:update')); const canCreateAnnotations = computed(() => hasProjectPermission('annotation:create')); const canReviewAnnotations = computed(() => hasProjectPermission('annotation:review')); const canAssignTasks = computed(() => hasProjectPermission('task:assign')); + // Manager heuristic: any of the manager-level update permissions in current project + const isManager = (projectId?: number): boolean => { + const pid = projectId ?? projectStore.currentProject?.id; + if (!pid) return false; + const perms = [ + 'project:update', + 'workflow:update', + 'dataSource:update', + 'projectSettings:update', + 'labelScheme:update', + 'labelScheme:create', + 'labelScheme:delete', + ]; + // Warm cache + perms.forEach(p => void ensure(p, { projectId: pid })); + return perms.some(p => !!resultsCache[makeKey(p, { projectId: pid })]); + }; + + const isManagerCurrentProject = computed(() => isManager()); + return { // Global permission checks hasGlobalPermission, @@ -141,6 +225,9 @@ export function usePermissions() { // Project permission checks hasProjectPermission, + hasProjectPermissionAsync, + prefetchProjectPermission, + guardProjectAction, hasPermissionInProject, hasAnyProjectPermission, hasAllProjectPermissions, @@ -148,7 +235,6 @@ export function usePermissions() { // General permission checks hasAnyPermission, isCurrentProjectMember, - currentProjectPermissions, // Reactive permission checkers canDoGlobally, @@ -168,10 +254,10 @@ export function usePermissions() { canCreateAnnotations, canReviewAnnotations, canAssignTasks, + isManager, + isManagerCurrentProject, - // Store references for advanced usage - permissionStore, authStore, projectStore, }; -} \ No newline at end of file +} From a008547987d50545d52954056835d5fbee1bcd09 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Mon, 8 Sep 2025 20:40:56 +0200 Subject: [PATCH 10/15] refactor: Update permission checks and streamline access control across various components --- .../project/labels/LabelSchemeCard.vue | 4 +- .../project/workflow/WorkflowCard.vue | 5 +- frontend/src/views/AnnotationWorkspace.vue | 2 +- frontend/src/views/ProjectView.vue | 6 +- .../views/dataExplorer/DataExplorerView.vue | 13 +-- .../src/views/project/DataSourcesView.vue | 8 +- .../src/views/project/ProjectDetailView.vue | 10 +- frontend/src/views/project/TasksView.vue | 103 ++++++++---------- .../views/project/WorkflowPipelineView.vue | 21 ++-- frontend/src/views/project/WorkflowsView.vue | 2 +- 10 files changed, 78 insertions(+), 96 deletions(-) diff --git a/frontend/src/components/project/labels/LabelSchemeCard.vue b/frontend/src/components/project/labels/LabelSchemeCard.vue index 969e509..9c51d9e 100644 --- a/frontend/src/components/project/labels/LabelSchemeCard.vue +++ b/frontend/src/components/project/labels/LabelSchemeCard.vue @@ -74,10 +74,10 @@ import {useToast} from '@/composables/useToast'; import LabelChip from './LabelChip.vue'; import CreateLabelForm from './CreateLabelForm.vue'; import Button from '@/components/common/Button.vue'; +import {PERMISSIONS} from '@/services/auth/permissions.types'; import Card from '@/components/common/Card.vue'; import ModalWindow from '@/components/common/modal/ModalWindow.vue'; import {AppLogger} from '@/core/logger/logger'; -import {PERMISSIONS} from '@/services/auth/permissions.types'; const logger = AppLogger.createComponentLogger('LabelSchemeCard'); @@ -216,4 +216,4 @@ onMounted(fetchLabels); opacity: 0.7; border-color: var(--color-gray-300); } - \ No newline at end of file + diff --git a/frontend/src/components/project/workflow/WorkflowCard.vue b/frontend/src/components/project/workflow/WorkflowCard.vue index ee62bef..1447cb2 100644 --- a/frontend/src/components/project/workflow/WorkflowCard.vue +++ b/frontend/src/components/project/workflow/WorkflowCard.vue @@ -36,6 +36,7 @@ v-else :to="pipelineUrl" class="action-button secondary" + v-permission="{ permission: PERMISSIONS.WORKFLOW.UPDATE }" > View Pipeline @@ -47,6 +48,7 @@ :to="pipelineUrl" class="action-button secondary small" title="View workflow pipeline" + v-permission="{ permission: PERMISSIONS.WORKFLOW.UPDATE }" > Pipeline @@ -63,6 +65,7 @@ import { type ProjectRole } from '@/services/project/project.types'; import { WorkflowNavigationHelper } from '@/core/workflow'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { faDiagramProject } from '@fortawesome/free-solid-svg-icons'; +import { PERMISSIONS } from '@/services/auth/permissions.types'; interface Props { workflow: Workflow; @@ -233,4 +236,4 @@ const formatDate = (dateString: string): string => { } } } - \ No newline at end of file + diff --git a/frontend/src/views/AnnotationWorkspace.vue b/frontend/src/views/AnnotationWorkspace.vue index f5d7c7b..5b6cedf 100644 --- a/frontend/src/views/AnnotationWorkspace.vue +++ b/frontend/src/views/AnnotationWorkspace.vue @@ -139,7 +139,7 @@ import ModalWindow from "@/components/common/modal/ModalWindow.vue"; import {FontAwesomeIcon} from "@fortawesome/vue-fontawesome"; import {faPause, faForward, faUndo} from "@fortawesome/free-solid-svg-icons"; import {usePermissions} from "@/composables/usePermissions"; -import {PERMISSIONS} from "@/services/auth/permissions.types"; +import { PERMISSIONS } from '@/services/auth/permissions.types'; import {useToast} from "@/composables/useToast"; const props = defineProps({ diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index ada022e..e102a27 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -67,9 +67,7 @@ const projects = ref([]); const loading = ref(false); const error = ref(null); -const canCreateProject = computed(() => { - return hasGlobalPermission(PERMISSIONS.PROJECT.CREATE); -}); +const canCreateProject = computed(() => hasGlobalPermission(PERMISSIONS.PROJECT.CREATE)); const openModal = () => isModalOpen.value = true; const closeModal = () => isModalOpen.value = false; @@ -183,4 +181,4 @@ onMounted(() => { transform: scale(1.1); } } - \ No newline at end of file + diff --git a/frontend/src/views/dataExplorer/DataExplorerView.vue b/frontend/src/views/dataExplorer/DataExplorerView.vue index ccb4a13..84b9fbb 100644 --- a/frontend/src/views/dataExplorer/DataExplorerView.vue +++ b/frontend/src/views/dataExplorer/DataExplorerView.vue @@ -1,10 +1,10 @@