From 85bd4d6f5867de84ece65ee1f348f53aa5cb0079 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 15 Jan 2026 19:23:31 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Th=C3=AAm=20c=C3=A1c=20controller=20v?= =?UTF-8?q?=C3=A0=20query=20qu=E1=BA=A3n=20tr=E1=BB=8B=20cho=20Storage=20S?= =?UTF-8?q?ervice,=20c=E1=BA=A3i=20ti=E1=BA=BFn=20qu=E1=BA=A3n=20l=C3=BD?= =?UTF-8?q?=20c=E1=BA=A5p=20=C4=91=E1=BB=99=20th=C3=A0nh=20vi=C3=AAn=20v?= =?UTF-8?q?=E1=BB=9Bi=20c=C3=A1c=20b=C3=A0i=20ki=E1=BB=83m=20tra=20m?= =?UTF-8?q?=E1=BB=9Bi,=20v=C3=A0=20c=E1=BA=ADp=20nh=E1=BA=ADt=20c=C3=A1c?= =?UTF-8?q?=20controller=20c=C3=B9ng=20ch=C3=ADnh=20s=C3=A1ch=20=E1=BB=A7y?= =?UTF-8?q?=20quy=E1=BB=81n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deployments/local/.env | 3 + deployments/local/docker-compose.yml | 46 +++ .../iam-service-net/docs/en/ARCHITECTURE.md | 100 ++++++ services/iam-service-net/docs/en/README.md | 28 ++ .../iam-service-net/docs/vi/ARCHITECTURE.md | 137 ++++++++ services/iam-service-net/docs/vi/README.md | 28 ++ .../Controllers/AccessRequestsController.cs | 3 +- .../Controllers/AccessReviewsController.cs | 3 +- .../Controllers/AuditController.cs | 3 +- .../Controllers/ComplianceController.cs | 3 +- .../Controllers/GroupsController.cs | 3 +- .../Controllers/OrganizationsController.cs | 3 +- .../Controllers/PrivilegedAccessController.cs | 3 +- .../Controllers/RolesController.cs | 3 +- .../Controllers/UsersController.cs | 20 +- .../Controllers/VerificationsController.cs | 3 +- .../Authorization/OwnerOrAdminRequirement.cs | 1 + .../Controllers/AuthorizationPolicyTests.cs | 304 ++++++++++++++++++ .../Controllers/LevelsController.cs | 27 ++ .../Controllers/AdminLevelsControllerTests.cs | 248 ++++++++++++++ .../CustomWebApplicationFactory.cs | 3 + ...reateLevelDefinitionCommandHandlerTests.cs | 148 +++++++++ ...ivateLevelDefinitionCommandHandlerTests.cs | 113 +++++++ ...pdateLevelDefinitionCommandHandlerTests.cs | 160 +++++++++ .../docs/en/ARCHITECTURE.md | 48 +++ .../storage-service-net/docs/en/README.md | 33 ++ .../docs/vi/ARCHITECTURE.md | 48 +++ .../storage-service-net/docs/vi/README.md | 33 ++ .../Queries/Admin/AdminQueryHandlers.cs | 292 +++++++++++++++++ .../Controllers/Admin/AdminFilesController.cs | 95 ++++++ .../Controllers/Admin/AdminQuotaController.cs | 112 +++++++ .../Admin/AdminSharesController.cs | 88 +++++ .../Admin/AdminStatisticsController.cs | 68 ++++ services/wallet-service-net/Dockerfile | 10 +- .../wallet-service-net/docker-compose.yml | 72 ----- .../Properties/launchSettings.json | 2 +- .../src/WalletService.API/appsettings.json | 2 +- 37 files changed, 2204 insertions(+), 92 deletions(-) create mode 100644 services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthorizationPolicyTests.cs create mode 100644 services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/AdminLevelsControllerTests.cs create mode 100644 services/membership-service-net/tests/MembershipService.UnitTests/Handlers/CreateLevelDefinitionCommandHandlerTests.cs create mode 100644 services/membership-service-net/tests/MembershipService.UnitTests/Handlers/DeactivateLevelDefinitionCommandHandlerTests.cs create mode 100644 services/membership-service-net/tests/MembershipService.UnitTests/Handlers/UpdateLevelDefinitionCommandHandlerTests.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Queries/Admin/AdminQueryHandlers.cs create mode 100644 services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminFilesController.cs create mode 100644 services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminQuotaController.cs create mode 100644 services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminSharesController.cs create mode 100644 services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminStatisticsController.cs delete mode 100644 services/wallet-service-net/docker-compose.yml diff --git a/deployments/local/.env b/deployments/local/.env index 98f01e18..829c19cd 100644 --- a/deployments/local/.env +++ b/deployments/local/.env @@ -24,6 +24,9 @@ STORAGE_DATABASE_URL="Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.te # Social Service Database (if separate) SOCIAL_DATABASE_URL="Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=social_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require" +# Wallet Service Database +WALLET_DATABASE_URL="Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=wallet_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require" + # ============================================================================= # REDIS CACHE / BỘ NHỚ ĐỆM REDIS # ============================================================================= diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index ae8493b7..aad24f4a 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -261,6 +261,52 @@ services: - "traefik.http.services.iam-service-net.loadbalancer.healthcheck.path=/health/live" - "traefik.http.services.iam-service-net.loadbalancer.healthcheck.interval=10s" + # Wallet Service .NET - Wallet & PPoint Management + wallet-service-net: + build: + context: ../../services/wallet-service-net + dockerfile: Dockerfile + image: goodgo/wallet-service-net:latest + container_name: wallet-service-net-local + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + # EN: Database - Neon PostgreSQL + # VI: Cơ sở dữ liệu - Neon PostgreSQL + - ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=wallet_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require + # EN: IAM Service Communication + # VI: Giao tiếp IAM Service + - IamService__BaseUrl=http://iam-service-net:8080 + - IamService__ServiceName=wallet-service + # EN: JWT Configuration + # VI: Cấu hình JWT + - Jwt__Authority=http://iam-service-net:8080 + - Jwt__Audience=goodgo-api + - Jwt__RequireHttpsMetadata=false + ports: + - "5004:8080" + depends_on: + iam-service-net: + condition: service_healthy + traefik: + condition: service_started + networks: + - microservices-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + labels: + - "traefik.enable=true" + - "traefik.http.routers.wallet-service.rule=PathPrefix(`/api/v1/wallets`) || PathPrefix(`/api/v1/points`)" + - "traefik.http.routers.wallet-service.entrypoints=web" + - "traefik.http.services.wallet-service.loadbalancer.server.port=8080" + - "traefik.http.services.wallet-service.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.wallet-service.loadbalancer.healthcheck.interval=10s" + # =========================================================================== # OBSERVABILITY (Optional - Uncomment to enable) diff --git a/services/iam-service-net/docs/en/ARCHITECTURE.md b/services/iam-service-net/docs/en/ARCHITECTURE.md index 7f7c2317..2ef9a3d4 100644 --- a/services/iam-service-net/docs/en/ARCHITECTURE.md +++ b/services/iam-service-net/docs/en/ARCHITECTURE.md @@ -468,6 +468,106 @@ graph TD style SOCIAL fill:#e67e22,stroke:#d35400,color:#fff ``` +## Authorization Policies + +### Overview + +IAM Service uses **Policy-Based Authorization** to protect API endpoints: + +```mermaid +graph TB + subgraph "Request Flow" + REQ[HTTP Request] --> AUTH[Authentication
JWT Bearer] + AUTH --> POLICY[Policy Check] + POLICY --> HANDLER[Authorization Handler] + end + + subgraph "Policies" + SUPER[RequireSuperAdmin] + ADMIN[RequireAdmin] + AUDITOR[RequireAuditor] + OWNER[OwnerOrAdmin] + end + + subgraph "Roles" + R_SUPER[SuperAdmin] + R_ADMIN[Admin] + R_AUDITOR[Auditor] + R_USER[User] + end + + HANDLER --> SUPER + HANDLER --> ADMIN + HANDLER --> AUDITOR + HANDLER --> OWNER + + SUPER --> R_SUPER + ADMIN --> R_SUPER + ADMIN --> R_ADMIN + AUDITOR --> R_SUPER + AUDITOR --> R_ADMIN + AUDITOR --> R_AUDITOR + OWNER --> R_SUPER + OWNER --> R_ADMIN + OWNER --> R_USER + + style SUPER fill:#c0392b,stroke:#922b21,color:#fff + style ADMIN fill:#e74c3c,stroke:#c0392b,color:#fff + style AUDITOR fill:#9b59b6,stroke:#7d3c98,color:#fff + style OWNER fill:#3498db,stroke:#2980b9,color:#fff +``` + +### Policy Definitions + +| Policy | Required Roles | Description | +|--------|---------------|-------------| +| `RequireSuperAdmin` | SuperAdmin | Highest level - PAM, system config | +| `RequireAdmin` | SuperAdmin, Admin | User, role, organization management | +| `RequireAuditor` | SuperAdmin, Admin, Auditor | Audit logs, compliance reports | +| `OwnerOrAdmin` | Admin or resource owner | User self-service profile | + +### Authorization Flow + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant AuthorizationService + participant PolicyHandler + participant ClaimsPrincipal + + Client->>Controller: Request with JWT + Controller->>AuthorizationService: Authorize(Policy) + AuthorizationService->>PolicyHandler: EvaluatePolicy() + PolicyHandler->>ClaimsPrincipal: GetRoles() + ClaimsPrincipal-->>PolicyHandler: ["Admin", "User"] + + alt Role Match + PolicyHandler-->>AuthorizationService: Success + AuthorizationService-->>Controller: Authorized + Controller-->>Client: 200 OK + else Role Not Match + PolicyHandler-->>AuthorizationService: Fail + AuthorizationService-->>Controller: Forbidden + Controller-->>Client: 403 Forbidden + end +``` + +### Controller Policy Mapping + +| Controller | Policy | Endpoints | +|------------|--------|-----------| +| UsersController | RequireAdmin / OwnerOrAdmin | GET /users (Admin), GET/PUT /{id} (Owner or Admin) | +| RolesController | RequireAdmin | All endpoints | +| OrganizationsController | RequireAdmin | All endpoints | +| GroupsController | RequireAdmin | All endpoints | +| AccessRequestsController | RequireAdmin | All endpoints | +| AccessReviewsController | RequireAdmin | All endpoints | +| PrivilegedAccessController | RequireSuperAdmin | All endpoints (most sensitive) | +| AuditController | RequireAuditor | GET /audit/logs | +| ComplianceController | RequireAuditor | All endpoints | +| VerificationsController | RequireAdmin | All endpoints | + ## Email Verification Flow ```mermaid diff --git a/services/iam-service-net/docs/en/README.md b/services/iam-service-net/docs/en/README.md index d880da74..26ebf848 100644 --- a/services/iam-service-net/docs/en/README.md +++ b/services/iam-service-net/docs/en/README.md @@ -92,6 +92,34 @@ dotnet ef database update \ ## API Endpoints +### Authorization Policies + +> **Note:** All API endpoints require authentication (Bearer JWT Token). +> Some endpoints require specific roles as shown below. + +| Policy | Required Role | Applied To | +|--------|---------------|------------| +| `RequireSuperAdmin` | SuperAdmin | PAM (Privileged Access Management) | +| `RequireAdmin` | Admin, SuperAdmin | User/Role/Group/Organization management | +| `RequireAuditor` | Auditor, Admin, SuperAdmin | Audit logs, Compliance reports | +| `OwnerOrAdmin` | Owner or Admin | User self-service profile management | + +**Authorization by Controller:** + +| Controller | Policy | Description | +|------------|--------|-------------| +| Users (GET /users, DELETE) | RequireAdmin | List users, delete user | +| Users (GET/PUT /{id}) | OwnerOrAdmin | User access own profile or Admin access any | +| Roles | RequireAdmin | Role management | +| Organizations | RequireAdmin | Organization management | +| Groups | RequireAdmin | Group management | +| Access Requests | RequireAdmin | Access request workflow | +| Access Reviews | RequireAdmin | Periodic access review | +| Privileged Access | RequireSuperAdmin | PAM - most sensitive | +| Audit | RequireAuditor | View audit logs | +| Compliance | RequireAuditor | Compliance reports | +| Verifications | RequireAdmin | Identity verification | + ### Authentication (`/api/v1/auth`) | Method | Endpoint | Description | Auth | diff --git a/services/iam-service-net/docs/vi/ARCHITECTURE.md b/services/iam-service-net/docs/vi/ARCHITECTURE.md index f0de216e..7c28bff7 100644 --- a/services/iam-service-net/docs/vi/ARCHITECTURE.md +++ b/services/iam-service-net/docs/vi/ARCHITECTURE.md @@ -468,6 +468,143 @@ graph TD style SOCIAL fill:#e67e22,stroke:#d35400,color:#fff ``` +## Authorization Policies + +### Tổng Quan Authorization + +IAM Service sử dụng **Policy-Based Authorization** để phân quyền API endpoints: + +```mermaid +graph TB + subgraph "Request Flow" + REQ[HTTP Request] --> AUTH[Authentication
JWT Bearer] + AUTH --> POLICY[Policy Check] + POLICY --> HANDLER[Authorization Handler] + end + + subgraph "Policies" + SUPER[RequireSuperAdmin] + ADMIN[RequireAdmin] + AUDITOR[RequireAuditor] + OWNER[OwnerOrAdmin] + end + + subgraph "Roles" + R_SUPER[SuperAdmin] + R_ADMIN[Admin] + R_AUDITOR[Auditor] + R_USER[User] + end + + HANDLER --> SUPER + HANDLER --> ADMIN + HANDLER --> AUDITOR + HANDLER --> OWNER + + SUPER --> R_SUPER + ADMIN --> R_SUPER + ADMIN --> R_ADMIN + AUDITOR --> R_SUPER + AUDITOR --> R_ADMIN + AUDITOR --> R_AUDITOR + OWNER --> R_SUPER + OWNER --> R_ADMIN + OWNER --> R_USER + + style SUPER fill:#c0392b,stroke:#922b21,color:#fff + style ADMIN fill:#e74c3c,stroke:#c0392b,color:#fff + style AUDITOR fill:#9b59b6,stroke:#7d3c98,color:#fff + style OWNER fill:#3498db,stroke:#2980b9,color:#fff +``` + +### Bảng Policies + +| Policy | Required Roles | Mô tả | +|--------|---------------|-------| +| `RequireSuperAdmin` | SuperAdmin | Quyền cao nhất - PAM, system config | +| `RequireAdmin` | SuperAdmin, Admin | Quản lý users, roles, organizations | +| `RequireAuditor` | SuperAdmin, Admin, Auditor | Xem audit logs, compliance reports | +| `OwnerOrAdmin` | Admin hoặc chính user | User tự quản lý profile | + +### Luồng Authorization + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant AuthorizationService + participant PolicyHandler + participant ClaimsPrincipal + + Client->>Controller: Request với JWT + Controller->>AuthorizationService: Authorize(Policy) + AuthorizationService->>PolicyHandler: EvaluatePolicy() + PolicyHandler->>ClaimsPrincipal: GetRoles() + ClaimsPrincipal-->>PolicyHandler: ["Admin", "User"] + + alt Role Match + PolicyHandler-->>AuthorizationService: Success + AuthorizationService-->>Controller: Authorized + Controller-->>Client: 200 OK + else Role Not Match + PolicyHandler-->>AuthorizationService: Fail + AuthorizationService-->>Controller: Forbidden + Controller-->>Client: 403 Forbidden + end +``` + +### Áp Dụng Theo Controller + +| Controller | Policy | Endpoints | +|------------|--------|-----------| +| UsersController | RequireAdmin / OwnerOrAdmin | GET /users (Admin), GET/PUT /{id} (Owner or Admin) | +| RolesController | RequireAdmin | Tất cả endpoints | +| OrganizationsController | RequireAdmin | Tất cả endpoints | +| GroupsController | RequireAdmin | Tất cả endpoints | +| AccessRequestsController | RequireAdmin | Tất cả endpoints | +| AccessReviewsController | RequireAdmin | Tất cả endpoints | +| PrivilegedAccessController | RequireSuperAdmin | Tất cả endpoints (nhạy cảm nhất) | +| AuditController | RequireAuditor | GET /audit/logs | +| ComplianceController | RequireAuditor | Tất cả endpoints | +| VerificationsController | RequireAdmin | Tất cả endpoints | + +### Custom Authorization Handler: OwnerOrAdmin + +```csharp +// EN: OwnerOrAdmin allows: +// 1. Admin/SuperAdmin - always allowed +// 2. User accessing their own resource (userId in route matches current user) +// VI: OwnerOrAdmin cho phép: +// 1. Admin/SuperAdmin - luôn được phép +// 2. User truy cập resource của chính mình + +public class OwnerOrAdminHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OwnerOrAdminRequirement requirement) + { + // Admin/SuperAdmin always allowed + if (context.User.IsInRole("Admin") || context.User.IsInRole("SuperAdmin")) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + // Check if user accessing own resource + var currentUserId = context.User.FindFirst("sub")?.Value; + var routeUserId = httpContext.GetRouteValue("id")?.ToString(); + + if (currentUserId == routeUserId) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} +``` + ## Luồng Xác Thực Email ```mermaid diff --git a/services/iam-service-net/docs/vi/README.md b/services/iam-service-net/docs/vi/README.md index 264089a1..c90085bb 100644 --- a/services/iam-service-net/docs/vi/README.md +++ b/services/iam-service-net/docs/vi/README.md @@ -105,6 +105,34 @@ dotnet ef migrations list \ ## API Endpoints +### Authorization Policies (Phân Quyền API) + +> **Lưu ý:** Tất cả API endpoints yêu cầu xác thực (Bearer JWT Token). +> Một số endpoints yêu cầu role cụ thể như bảng dưới đây. + +| Policy | Required Role | Áp Dụng Cho | +|--------|---------------|-------------| +| `RequireSuperAdmin` | SuperAdmin | PAM (Privileged Access Management) | +| `RequireAdmin` | Admin, SuperAdmin | User/Role/Group/Organization management | +| `RequireAuditor` | Auditor, Admin, SuperAdmin | Audit logs, Compliance reports | +| `OwnerOrAdmin` | Owner hoặc Admin | User tự quản lý profile của mình | + +**Chi tiết phân quyền theo Controller:** + +| Controller | Policy | Mô tả | +|------------|--------|-------| +| Users (GET /users, DELETE) | RequireAdmin | Xem danh sách users, xóa user | +| Users (GET/PUT /{id}) | OwnerOrAdmin | User xem/sửa profile mình hoặc Admin | +| Roles | RequireAdmin | Quản lý roles | +| Organizations | RequireAdmin | Quản lý tổ chức | +| Groups | RequireAdmin | Quản lý nhóm | +| Access Requests | RequireAdmin | Xử lý yêu cầu truy cập | +| Access Reviews | RequireAdmin | Xem xét truy cập định kỳ | +| Privileged Access | RequireSuperAdmin | PAM - nhạy cảm nhất | +| Audit | RequireAuditor | Xem audit logs | +| Compliance | RequireAuditor | Báo cáo tuân thủ | +| Verifications | RequireAdmin | Xác thực danh tính | + ### Authentication (`/api/v1/auth`) | Method | Endpoint | Mô Tả | Auth | diff --git a/services/iam-service-net/src/IamService.API/Controllers/AccessRequestsController.cs b/services/iam-service-net/src/IamService.API/Controllers/AccessRequestsController.cs index 68bb0c38..7975644e 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/AccessRequestsController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/AccessRequestsController.cs @@ -17,7 +17,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/access-requests")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Access request management - requires authentication")] +[Authorize(Policy = "RequireAdmin")] +[SwaggerTag("Access request management - requires Admin role")] public class AccessRequestsController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.API/Controllers/AccessReviewsController.cs b/services/iam-service-net/src/IamService.API/Controllers/AccessReviewsController.cs index e0b6d308..b14bc9de 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/AccessReviewsController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/AccessReviewsController.cs @@ -12,7 +12,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/access-reviews")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Access review management - periodic access certification")] +[Authorize(Policy = "RequireAdmin")] +[SwaggerTag("Access review management - requires Admin role")] public class AccessReviewsController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.API/Controllers/AuditController.cs b/services/iam-service-net/src/IamService.API/Controllers/AuditController.cs index bf61bf75..453ef5a7 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/AuditController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/AuditController.cs @@ -12,7 +12,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/audit")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Audit log management")] +[Authorize(Policy = "RequireAuditor")] +[SwaggerTag("Audit log management - requires Auditor role")] public class AuditController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.API/Controllers/ComplianceController.cs b/services/iam-service-net/src/IamService.API/Controllers/ComplianceController.cs index fb20d685..d574fbf3 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/ComplianceController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/ComplianceController.cs @@ -13,7 +13,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/compliance")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Compliance reporting")] +[Authorize(Policy = "RequireAuditor")] +[SwaggerTag("Compliance reporting - requires Auditor role")] public class ComplianceController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.API/Controllers/GroupsController.cs b/services/iam-service-net/src/IamService.API/Controllers/GroupsController.cs index 602b82eb..06e56d4f 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/GroupsController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/GroupsController.cs @@ -17,7 +17,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/groups")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Group management endpoints - requires authentication")] +[Authorize(Policy = "RequireAdmin")] +[SwaggerTag("Group management endpoints - requires Admin role")] public class GroupsController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.API/Controllers/OrganizationsController.cs b/services/iam-service-net/src/IamService.API/Controllers/OrganizationsController.cs index b2f7a7c6..3e750ef7 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/OrganizationsController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/OrganizationsController.cs @@ -18,7 +18,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/organizations")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Organization management endpoints - requires authentication")] +[Authorize(Policy = "RequireAdmin")] +[SwaggerTag("Organization management endpoints - requires Admin role")] public class OrganizationsController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.API/Controllers/PrivilegedAccessController.cs b/services/iam-service-net/src/IamService.API/Controllers/PrivilegedAccessController.cs index 9788bbf3..920e0842 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/PrivilegedAccessController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/PrivilegedAccessController.cs @@ -13,7 +13,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/privileged-access")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Privileged Access Management (PAM) - Just-In-Time elevated access")] +[Authorize(Policy = "RequireSuperAdmin")] +[SwaggerTag("Privileged Access Management (PAM) - requires SuperAdmin role")] public class PrivilegedAccessController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs b/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs index e118f6cd..e2fd70b6 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs @@ -18,7 +18,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/roles")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Role management endpoints - requires authentication")] +[Authorize(Policy = "RequireAdmin")] +[SwaggerTag("Role management endpoints - requires Admin role")] public class RolesController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs b/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs index 3fe8ab8b..2eebbbcd 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs @@ -46,14 +46,17 @@ public class UsersController : ControllerBase /// Cancellation token /// Paginated list of users [HttpGet] + [Authorize(Policy = "RequireAdmin")] [SwaggerOperation( Summary = "Get all users", - Description = "Retrieves a paginated list of all users. Requires authentication.", + Description = "Retrieves a paginated list of all users. Requires Admin role.", OperationId = "GetUsers")] [SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved users", typeof(ApiResponse>))] [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Insufficient permissions")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task GetUsers( [FromQuery, SwaggerParameter("Page number (1-based)", Required = false)] int pageNumber = 1, [FromQuery, SwaggerParameter("Number of items per page", Required = false)] int pageSize = 10, @@ -93,15 +96,18 @@ public class UsersController : ControllerBase /// Cancellation token /// User information [HttpGet("{id:guid}")] + [Authorize(Policy = "OwnerOrAdmin")] [SwaggerOperation( Summary = "Get user by ID", - Description = "Retrieves a specific user by their unique identifier.", + Description = "Retrieves a specific user. Users can access their own profile; Admins can access any.", OperationId = "GetUserById")] [SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved user", typeof(ApiResponse))] [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Insufficient permissions")] [SwaggerResponse(StatusCodes.Status404NotFound, "User not found")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetUserById( [FromRoute, SwaggerParameter("User ID", Required = true)] Guid id, @@ -137,17 +143,20 @@ public class UsersController : ControllerBase /// Cancellation token /// Updated user information [HttpPut("{id:guid}")] + [Authorize(Policy = "OwnerOrAdmin")] [SwaggerOperation( Summary = "Update user", - Description = "Updates a user's information (first name, last name).", + Description = "Updates a user's information. Users can update their own profile; Admins can update any.", OperationId = "UpdateUser")] [SwaggerResponse(StatusCodes.Status200OK, "User updated successfully", typeof(ApiResponse))] [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request data")] [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Insufficient permissions")] [SwaggerResponse(StatusCodes.Status404NotFound, "User not found")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task UpdateUser( [FromRoute, SwaggerParameter("User ID to update", Required = true)] Guid id, @@ -183,15 +192,18 @@ public class UsersController : ControllerBase /// Cancellation token /// Deletion result [HttpDelete("{id:guid}")] + [Authorize(Policy = "RequireAdmin")] [SwaggerOperation( Summary = "Delete user", - Description = "Soft deletes (deactivates) a user. The user data is retained but marked as inactive.", + Description = "Soft deletes (deactivates) a user. Requires Admin role.", OperationId = "DeleteUser")] [SwaggerResponse(StatusCodes.Status200OK, "User deleted successfully")] [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Insufficient permissions")] [SwaggerResponse(StatusCodes.Status404NotFound, "User not found")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DeleteUser( [FromRoute, SwaggerParameter("User ID to delete", Required = true)] Guid id, diff --git a/services/iam-service-net/src/IamService.API/Controllers/VerificationsController.cs b/services/iam-service-net/src/IamService.API/Controllers/VerificationsController.cs index 8baa1b3b..d7a04089 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/VerificationsController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/VerificationsController.cs @@ -16,7 +16,8 @@ namespace IamService.API.Controllers; [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/verifications")] [Authorize(AuthenticationSchemes = "Bearer")] -[SwaggerTag("Identity verification endpoints - requires authentication")] +[Authorize(Policy = "RequireAdmin")] +[SwaggerTag("Identity verification endpoints - requires Admin role")] public class VerificationsController : ControllerBase { private readonly IMediator _mediator; diff --git a/services/iam-service-net/src/IamService.Infrastructure/Authorization/OwnerOrAdminRequirement.cs b/services/iam-service-net/src/IamService.Infrastructure/Authorization/OwnerOrAdminRequirement.cs index 183c2558..adcf400d 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/Authorization/OwnerOrAdminRequirement.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/Authorization/OwnerOrAdminRequirement.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; namespace IamService.Infrastructure.Authorization; diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthorizationPolicyTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthorizationPolicyTests.cs new file mode 100644 index 00000000..136e0d18 --- /dev/null +++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthorizationPolicyTests.cs @@ -0,0 +1,304 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using Xunit; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Authorization Policies. +/// VI: Functional tests cho Authorization Policies. +/// +/// +/// EN: These tests require additional authentication configuration in CustomWebApplicationFactory +/// to properly inject role claims into the test JWT. The tests verify that: +/// - RequireSuperAdmin: Only SuperAdmin can access PAM endpoints +/// - RequireAdmin: Admin/SuperAdmin can access management endpoints +/// - RequireAuditor: Auditor/Admin/SuperAdmin can access audit endpoints +/// - OwnerOrAdmin: User can access own profile or Admin can access any +/// +/// TODO: Configure test authentication to properly inject role claims. +/// Current unit tests in IamService.UnitTests cover authorization handler logic. +/// VI: Các tests này yêu cầu cấu hình thêm authentication trong CustomWebApplicationFactory +/// để inject role claims vào test JWT. Unit tests trong IamService.UnitTests đã cover +/// logic authorization handler. +/// +[Collection("Sequential")] +public class AuthorizationPolicyTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; + + public AuthorizationPolicyTests(CustomWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + #region Token Generation Helpers + + /// + /// EN: Generate a test JWT token with specified roles. + /// VI: Tạo test JWT token với các roles được chỉ định. + /// + /// + /// EN: Creates an unsigned JWT that the CustomWebApplicationFactory accepts. + /// The test factory is configured to skip signature validation. + /// VI: Tạo unsigned JWT mà CustomWebApplicationFactory chấp nhận. + /// Test factory được cấu hình để bỏ qua signature validation. + /// + private string GenerateTestToken(Guid userId, string email, params string[] roles) + { + var claims = new List + { + new("sub", userId.ToString()), + new("email", email), + new("name", "Test User"), + new("iss", "http://localhost"), + new("aud", "api"), + new("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()), + new("exp", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString()) + }; + + // Add role claims + foreach (var role in roles) + { + claims.Add(new Claim("role", role)); + } + + // Create unsigned token descriptor + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddHours(1), + Issuer = "http://localhost", + Audience = "api" + }; + + // Generate JWT without signature (for test purposes) + var handler = new JwtSecurityTokenHandler(); + var token = handler.CreateJwtSecurityToken(tokenDescriptor); + + return handler.WriteToken(token); + } + + private HttpRequestMessage CreateRequest(HttpMethod method, string url, string token) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return request; + } + + #endregion + + #region RequireAdmin Policy Tests + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GetUsers_WithoutAdminRole_ShouldReturn403() + { + // Arrange - User with no admin role + var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User"); + var request = CreateRequest(HttpMethod.Get, "/api/v1/users", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GetUsers_WithAdminRole_ShouldReturn200() + { + // Arrange - User with Admin role + var token = GenerateTestToken(Guid.NewGuid(), "admin@example.com", "Admin"); + var request = CreateRequest(HttpMethod.Get, "/api/v1/users", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GetUsers_WithSuperAdminRole_ShouldReturn200() + { + // Arrange - SuperAdmin can access Admin endpoints + var token = GenerateTestToken(Guid.NewGuid(), "superadmin@example.com", "SuperAdmin"); + var request = CreateRequest(HttpMethod.Get, "/api/v1/users", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + #endregion + + #region RequireSuperAdmin Policy Tests (PAM) + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GetActivePrivilegedAccess_WithAdminRole_ShouldReturn403() + { + // Arrange - Admin cannot access SuperAdmin endpoints + var userId = Guid.NewGuid(); + var token = GenerateTestToken(userId, "admin@example.com", "Admin"); + var request = CreateRequest(HttpMethod.Get, $"/api/v1/privileged-access/active?userId={userId}", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GetActivePrivilegedAccess_WithSuperAdminRole_ShouldReturn200() + { + // Arrange - Only SuperAdmin can access PAM + var userId = Guid.NewGuid(); + var token = GenerateTestToken(userId, "superadmin@example.com", "SuperAdmin"); + var request = CreateRequest(HttpMethod.Get, $"/api/v1/privileged-access/active?userId={userId}", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + #endregion + + #region RequireAuditor Policy Tests + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GetAuditLogs_WithUserRole_ShouldReturn403() + { + // Arrange - Regular user cannot access audit logs + var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User"); + var request = CreateRequest(HttpMethod.Get, "/api/v1/audit/logs", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GetAuditLogs_WithAuditorRole_ShouldReturn200() + { + // Arrange - Auditor can access audit logs + var token = GenerateTestToken(Guid.NewGuid(), "auditor@example.com", "Auditor"); + var request = CreateRequest(HttpMethod.Get, "/api/v1/audit/logs", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GetComplianceReports_WithAdminRole_ShouldReturn200() + { + // Arrange - Admin can access compliance (auditor policy allows admin) + var token = GenerateTestToken(Guid.NewGuid(), "admin@example.com", "Admin"); + var request = CreateRequest(HttpMethod.Get, "/api/v1/compliance/reports", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + #endregion + + #region Controller-Level Policy Tests + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task RolesController_WithUserRole_ShouldReturn403() + { + // Arrange - User cannot access roles management + var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User"); + var request = CreateRequest(HttpMethod.Get, "/api/v1/roles", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task OrganizationsController_WithUserRole_ShouldReturn403() + { + // Arrange - User cannot access organizations management + var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User"); + var orgId = Guid.NewGuid(); + var request = CreateRequest(HttpMethod.Get, $"/api/v1/organizations/{orgId}", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact(Skip = "Requires test authentication configuration for role claims")] + public async Task GroupsController_WithUserRole_ShouldReturn403() + { + // Arrange - User cannot access groups management + var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User"); + var groupId = Guid.NewGuid(); + var request = CreateRequest(HttpMethod.Get, $"/api/v1/groups/{groupId}", token); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + #endregion + + #region Edge Cases + + [Fact] + public async Task AnyEndpoint_WithoutToken_ShouldReturn401() + { + // Arrange - No token provided + var response = await _client.GetAsync("/api/v1/users"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task AnyEndpoint_WithInvalidToken_ShouldReturn401() + { + // Arrange - Invalid token + var request = CreateRequest(HttpMethod.Get, "/api/v1/users", "invalid-token"); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + #endregion +} diff --git a/services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs b/services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs index 88b9cec1..38e782de 100644 --- a/services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs +++ b/services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs @@ -68,6 +68,15 @@ public class LevelsController : ControllerBase { return Conflict(new { message = ex.Message }); } + catch (ArgumentException ex) + { + return BadRequest(new { message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating level definition"); + return StatusCode(500, new { message = "An error occurred while creating level definition" }); + } } /// @@ -97,6 +106,15 @@ public class LevelsController : ControllerBase { return NotFound(new { message = ex.Message }); } + catch (ArgumentException ex) + { + return BadRequest(new { message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating level definition {LevelId}", id); + return StatusCode(500, new { message = "An error occurred while updating level definition" }); + } } /// @@ -121,6 +139,15 @@ public class LevelsController : ControllerBase { return NotFound(new { message = ex.Message }); } + catch (ArgumentException ex) + { + return BadRequest(new { message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deactivating level definition {LevelId}", id); + return StatusCode(500, new { message = "An error occurred while deactivating level definition" }); + } } } diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/AdminLevelsControllerTests.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/AdminLevelsControllerTests.cs new file mode 100644 index 00000000..14a4b086 --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/AdminLevelsControllerTests.cs @@ -0,0 +1,248 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using MembershipService.API.Application.Commands; +using Xunit; + +namespace MembershipService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Admin Level endpoints. +/// VI: Functional tests cho Admin Level endpoints. +/// +[Collection("Sequential")] +public class AdminLevelsControllerTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public AdminLevelsControllerTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + #region POST /api/v1/levels - Create Level + + [Fact] + public async Task CreateLevel_WithAuth_ShouldReturn201() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + var command = new CreateLevelDefinitionCommand + { + LevelNumber = 10, + Name = "Test Level", + RequiredExp = 5000, + Description = "Test level for functional tests", + BadgeColor = "#FF5733" + }; + + // Act + var response = await client.PostAsJsonAsync("/api/v1/levels", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.LevelNumber.Should().Be(10); + result.Name.Should().Be("Test Level"); + result.RequiredExp.Should().Be(5000); + } + + [Fact] + public async Task CreateLevel_WithoutAuth_ShouldReturn401() + { + // Arrange + var client = _factory.CreateClient(); // No auth + var command = new CreateLevelDefinitionCommand + { + LevelNumber = 11, + Name = "Unauthorized Level", + RequiredExp = 6000 + }; + + // Act + var response = await client.PostAsJsonAsync("/api/v1/levels", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task CreateLevel_DuplicateLevelNumber_ShouldReturn409() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + + // First create a level + var firstCommand = new CreateLevelDefinitionCommand + { + LevelNumber = 50, // Use unique number to avoid conflicts with other tests + Name = "First Level", + RequiredExp = 500 + }; + var firstResponse = await client.PostAsJsonAsync("/api/v1/levels", firstCommand); + firstResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Now try to create duplicate + var duplicateCommand = new CreateLevelDefinitionCommand + { + LevelNumber = 50, // Same level number - should conflict + Name = "Duplicate", + RequiredExp = 500 + }; + + // Act + var response = await client.PostAsJsonAsync("/api/v1/levels", duplicateCommand); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + #endregion + + #region PUT /api/v1/levels/{id} - Update Level + + [Fact] + public async Task UpdateLevel_WithAuth_ShouldReturn200() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + + // First create a level to update + var createCommand = new CreateLevelDefinitionCommand + { + LevelNumber = 60, + Name = "ToUpdate", + RequiredExp = 6000, + Description = "Original description" + }; + var createResponse = await client.PostAsJsonAsync("/api/v1/levels", createCommand); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var createdLevel = await createResponse.Content.ReadFromJsonAsync(); + + var updateCommand = new UpdateLevelDefinitionCommand + { + Id = createdLevel!.Id, + Name = "Updated Level", + Description = "Updated description" + }; + + // Act + var response = await client.PutAsJsonAsync($"/api/v1/levels/{createdLevel.Id}", updateCommand); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Name.Should().Be("Updated Level"); + result.Description.Should().Be("Updated description"); + } + + [Fact] + public async Task UpdateLevel_WithoutAuth_ShouldReturn401() + { + // Arrange + var client = _factory.CreateClient(); // No auth + var randomId = Guid.NewGuid(); + var command = new UpdateLevelDefinitionCommand + { + Id = randomId, + Name = "Unauthorized Update" + }; + + // Act + var response = await client.PutAsJsonAsync($"/api/v1/levels/{randomId}", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task UpdateLevel_NotFound_ShouldReturn404() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + var nonExistentId = Guid.NewGuid(); + var command = new UpdateLevelDefinitionCommand + { + Id = nonExistentId, + Name = "NonExistent" + }; + + // Act + var response = await client.PutAsJsonAsync($"/api/v1/levels/{nonExistentId}", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + #endregion + + #region DELETE /api/v1/levels/{id} - Deactivate Level + + [Fact] + public async Task DeactivateLevel_WithAuth_ShouldReturn204() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + + // First create a level to deactivate + var createCommand = new CreateLevelDefinitionCommand + { + LevelNumber = 99, + Name = "ToDeactivate", + RequiredExp = 9999 + }; + var createResponse = await client.PostAsJsonAsync("/api/v1/levels", createCommand); + var createdLevel = await createResponse.Content.ReadFromJsonAsync(); + + // Act + var response = await client.DeleteAsync($"/api/v1/levels/{createdLevel!.Id}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeactivateLevel_WithoutAuth_ShouldReturn401() + { + // Arrange + var client = _factory.CreateClient(); // No auth + var randomId = Guid.NewGuid(); + + // Act + var response = await client.DeleteAsync($"/api/v1/levels/{randomId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task DeactivateLevel_NotFound_ShouldReturn404() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + var nonExistentId = Guid.NewGuid(); + + // Act + var response = await client.DeleteAsync($"/api/v1/levels/{nonExistentId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + #endregion + + #region Helper DTOs + + private class LevelDto + { + public Guid Id { get; set; } + public int LevelNumber { get; set; } + public string Name { get; set; } = string.Empty; + } + + #endregion +} diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs index b2cfe585..0068412f 100644 --- a/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs +++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs @@ -52,6 +52,9 @@ public class CustomWebApplicationFactory : WebApplicationFactory services.AddDbContext(options => { options.UseInMemoryDatabase(databaseName); + // EN: Suppress transaction warning since in-memory doesn't support transactions + // VI: Bỏ qua cảnh báo transaction vì in-memory không hỗ trợ transactions + options.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning)); }); // EN: Re-register repositories diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/CreateLevelDefinitionCommandHandlerTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/CreateLevelDefinitionCommandHandlerTests.cs new file mode 100644 index 00000000..b81b2ec9 --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/CreateLevelDefinitionCommandHandlerTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using MembershipService.API.Application.Commands; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using MembershipService.Domain.SeedWork; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace MembershipService.UnitTests.Handlers; + +/// +/// EN: Unit tests for CreateLevelDefinitionCommandHandler. +/// VI: Unit tests cho CreateLevelDefinitionCommandHandler. +/// +public class CreateLevelDefinitionCommandHandlerTests +{ + private readonly ILevelDefinitionRepository _repository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly CreateLevelDefinitionCommandHandler _handler; + + public CreateLevelDefinitionCommandHandlerTests() + { + _repository = Substitute.For(); + _unitOfWork = Substitute.For(); + _logger = Substitute.For>(); + + _repository.UnitOfWork.Returns(_unitOfWork); + + _handler = new CreateLevelDefinitionCommandHandler(_repository, _logger); + } + + [Fact] + public async Task Handle_ValidCommand_ShouldCreateLevelDefinition() + { + // Arrange + var command = new CreateLevelDefinitionCommand + { + LevelNumber = 6, + Name = "Master", + RequiredExp = 2000, + Description = "Master level", + BadgeColor = "#8B00FF" + }; + + _repository.ExistsByLevelNumberAsync(command.LevelNumber) + .Returns(false); + _repository.Add(Arg.Any()) + .Returns(x => x.Arg()); + _unitOfWork.SaveEntitiesAsync(Arg.Any()) + .Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.LevelNumber.Should().Be(6); + result.Name.Should().Be("Master"); + result.RequiredExp.Should().Be(2000); + result.Id.Should().NotBeEmpty(); + + _repository.Received(1).Add(Arg.Any()); + await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_DuplicateLevelNumber_ShouldThrowInvalidOperationException() + { + // Arrange + var command = new CreateLevelDefinitionCommand + { + LevelNumber = 1, + Name = "Duplicate", + RequiredExp = 0 + }; + + _repository.ExistsByLevelNumberAsync(command.LevelNumber) + .Returns(true); + + // Act + var act = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*already exists*"); + + _repository.DidNotReceive().Add(Arg.Any()); + } + + [Fact] + public async Task Handle_WithOptionalFields_ShouldCreateWithAllFields() + { + // Arrange + var command = new CreateLevelDefinitionCommand + { + LevelNumber = 7, + Name = "Legend", + RequiredExp = 5000, + Description = "Legend tier for elite members", + IconUrl = "https://example.com/legend.png", + BadgeColor = "#FFD700" + }; + + _repository.ExistsByLevelNumberAsync(command.LevelNumber) + .Returns(false); + _repository.Add(Arg.Any()) + .Returns(x => x.Arg()); + _unitOfWork.SaveEntitiesAsync(Arg.Any()) + .Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.LevelNumber.Should().Be(7); + result.Name.Should().Be("Legend"); + result.RequiredExp.Should().Be(5000); + } + + [Fact] + public async Task Handle_MinimalCommand_ShouldCreateWithDefaults() + { + // Arrange + var command = new CreateLevelDefinitionCommand + { + LevelNumber = 8, + Name = "Minimal", + RequiredExp = 100 + }; + + _repository.ExistsByLevelNumberAsync(command.LevelNumber) + .Returns(false); + _repository.Add(Arg.Any()) + .Returns(x => x.Arg()); + _unitOfWork.SaveEntitiesAsync(Arg.Any()) + .Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.LevelNumber.Should().Be(8); + result.Name.Should().Be("Minimal"); + } +} diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/DeactivateLevelDefinitionCommandHandlerTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/DeactivateLevelDefinitionCommandHandlerTests.cs new file mode 100644 index 00000000..133da535 --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/DeactivateLevelDefinitionCommandHandlerTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using MembershipService.API.Application.Commands; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using MembershipService.Domain.SeedWork; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace MembershipService.UnitTests.Handlers; + +/// +/// EN: Unit tests for DeactivateLevelDefinitionCommandHandler. +/// VI: Unit tests cho DeactivateLevelDefinitionCommandHandler. +/// +public class DeactivateLevelDefinitionCommandHandlerTests +{ + private readonly ILevelDefinitionRepository _repository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly DeactivateLevelDefinitionCommandHandler _handler; + + public DeactivateLevelDefinitionCommandHandlerTests() + { + _repository = Substitute.For(); + _unitOfWork = Substitute.For(); + _logger = Substitute.For>(); + + _repository.UnitOfWork.Returns(_unitOfWork); + + _handler = new DeactivateLevelDefinitionCommandHandler(_repository, _logger); + } + + [Fact] + public async Task Handle_ValidLevel_ShouldDeactivate() + { + // Arrange + var levelId = Guid.NewGuid(); + var existingLevel = new LevelDefinition(5, "Diamond", 1000, "Top tier"); + + var command = new DeactivateLevelDefinitionCommand(levelId); + + _repository.GetAsync(levelId).Returns(existingLevel); + _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().BeTrue(); + existingLevel.IsActive.Should().BeFalse(); + + _repository.Received(1).Update(Arg.Any()); + await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_LevelNotFound_ShouldThrowKeyNotFoundException() + { + // Arrange + var command = new DeactivateLevelDefinitionCommand(Guid.NewGuid()); + + _repository.GetAsync(command.Id).Returns((LevelDefinition?)null); + + // Act + var act = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + + _repository.DidNotReceive().Update(Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyInactiveLevel_ShouldStillSucceed() + { + // Arrange + var levelId = Guid.NewGuid(); + var existingLevel = new LevelDefinition(6, "Master", 2000, "Master tier"); + existingLevel.Deactivate(); // Already inactive + + var command = new DeactivateLevelDefinitionCommand(levelId); + + _repository.GetAsync(levelId).Returns(existingLevel); + _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().BeTrue(); + existingLevel.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Handle_WithDefaultConstructor_ShouldWork() + { + // Arrange + var levelId = Guid.NewGuid(); + var existingLevel = new LevelDefinition(7, "Legend", 5000, "Legend tier"); + + var command = new DeactivateLevelDefinitionCommand { Id = levelId }; + + _repository.GetAsync(levelId).Returns(existingLevel); + _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().BeTrue(); + } +} diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/UpdateLevelDefinitionCommandHandlerTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/UpdateLevelDefinitionCommandHandlerTests.cs new file mode 100644 index 00000000..e3198e5a --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/UpdateLevelDefinitionCommandHandlerTests.cs @@ -0,0 +1,160 @@ +using FluentAssertions; +using MembershipService.API.Application.Commands; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using MembershipService.Domain.SeedWork; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace MembershipService.UnitTests.Handlers; + +/// +/// EN: Unit tests for UpdateLevelDefinitionCommandHandler. +/// VI: Unit tests cho UpdateLevelDefinitionCommandHandler. +/// +public class UpdateLevelDefinitionCommandHandlerTests +{ + private readonly ILevelDefinitionRepository _repository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly UpdateLevelDefinitionCommandHandler _handler; + + public UpdateLevelDefinitionCommandHandlerTests() + { + _repository = Substitute.For(); + _unitOfWork = Substitute.For(); + _logger = Substitute.For>(); + + _repository.UnitOfWork.Returns(_unitOfWork); + + _handler = new UpdateLevelDefinitionCommandHandler(_repository, _logger); + } + + [Fact] + public async Task Handle_ValidUpdate_ShouldUpdateLevelDefinition() + { + // Arrange + var levelId = Guid.NewGuid(); + var existingLevel = new LevelDefinition(1, "Bronze", 0, "Old description"); + + var command = new UpdateLevelDefinitionCommand + { + Id = levelId, + Name = "Bronze Elite", + RequiredExp = 50, + Description = "Updated description" + }; + + _repository.GetAsync(levelId).Returns(existingLevel); + _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Name.Should().Be("Bronze Elite"); + result.RequiredExp.Should().Be(50); + result.Description.Should().Be("Updated description"); + + _repository.Received(1).Update(Arg.Any()); + await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_LevelNotFound_ShouldThrowKeyNotFoundException() + { + // Arrange + var command = new UpdateLevelDefinitionCommand + { + Id = Guid.NewGuid(), + Name = "NonExistent" + }; + + _repository.GetAsync(command.Id).Returns((LevelDefinition?)null); + + // Act + var act = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + + _repository.DidNotReceive().Update(Arg.Any()); + } + + [Fact] + public async Task Handle_PartialUpdate_ShouldOnlyUpdateProvidedFields() + { + // Arrange + var levelId = Guid.NewGuid(); + var existingLevel = new LevelDefinition(2, "Silver", 100, "Original description", null, "#C0C0C0"); + + var command = new UpdateLevelDefinitionCommand + { + Id = levelId, + Name = "Silver Plus" + // Not updating RequiredExp, Description, etc. + }; + + _repository.GetAsync(levelId).Returns(existingLevel); + _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Name.Should().Be("Silver Plus"); + result.RequiredExp.Should().Be(100); // Unchanged + result.BadgeColor.Should().Be("#C0C0C0"); // Unchanged + } + + [Fact] + public async Task Handle_ClearDescription_ShouldSetToNull() + { + // Arrange + var levelId = Guid.NewGuid(); + var existingLevel = new LevelDefinition(3, "Gold", 300, "Has description"); + + var command = new UpdateLevelDefinitionCommand + { + Id = levelId, + ClearDescription = true + }; + + _repository.GetAsync(levelId).Returns(existingLevel); + _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Description.Should().BeNull(); + } + + [Fact] + public async Task Handle_UpdateBadgeColor_ShouldUpdateColor() + { + // Arrange + var levelId = Guid.NewGuid(); + var existingLevel = new LevelDefinition(4, "Platinum", 600, null, null, "#E5E4E2"); + + var command = new UpdateLevelDefinitionCommand + { + Id = levelId, + BadgeColor = "#00CED1" + }; + + _repository.GetAsync(levelId).Returns(existingLevel); + _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.BadgeColor.Should().Be("#00CED1"); + } +} diff --git a/services/storage-service-net/docs/en/ARCHITECTURE.md b/services/storage-service-net/docs/en/ARCHITECTURE.md index e414c38a..1536be50 100644 --- a/services/storage-service-net/docs/en/ARCHITECTURE.md +++ b/services/storage-service-net/docs/en/ARCHITECTURE.md @@ -96,6 +96,23 @@ Application entry point and CQRS implementation: | **DeleteFileCommand** | Handle file deletions | | **Query Handlers** | Handle read operations | +### 4. Admin Backoffice Layer + +Controllers and Commands for Admin storage management: + +| Component | Purpose | +|-----------|---------| +| **AdminQuotaController** | Manage user quotas (GET all, PUT update) | +| **AdminFilesController** | View/delete all users' files | +| **AdminSharesController** | View/revoke violating file shares | +| **AdminStatisticsController** | Storage statistics dashboard | +| **UpdateUserQuotaCommand** | Admin update quota limits | +| **AdminDeleteFileCommand** | Admin delete file (bypass ownership) | +| **AdminRevokeShareCommand** | Admin revoke violating share | +| **Admin Query Handlers** | Queries with pagination and filtering | + +> **Authorization**: All Admin endpoints require `Admin` or `SuperAdmin` role. + ## Direct Upload Architecture (Recommended) For systems with millions of users, Direct Client Upload pattern is recommended over proxy upload. @@ -722,6 +739,37 @@ erDiagram |--------|----------|-------------| | `GET` | `/api/v1/quota` | Get user storage quota | +### Admin API (Requires Role: Admin) + +#### Quota Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/quotas` | Get all users' quotas | +| `GET` | `/api/v1/admin/quotas/{userId}` | Get quota for specific user | +| `PUT` | `/api/v1/admin/quotas/{userId}` | Update quota limits | + +#### Files Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/files` | Get all files with filtering | +| `DELETE` | `/api/v1/admin/files/{id}` | Delete file for policy violation | + +#### Shares Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/shares` | Get all shares | +| `DELETE` | `/api/v1/admin/shares/{id}` | Revoke share for violation | + +#### Statistics + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/statistics` | Dashboard aggregated statistics | +| `GET` | `/api/v1/admin/statistics/users-near-limit` | Users near quota limit (>80%) | + ## Health Checks diff --git a/services/storage-service-net/docs/en/README.md b/services/storage-service-net/docs/en/README.md index f2ada9c0..dfe79b1f 100644 --- a/services/storage-service-net/docs/en/README.md +++ b/services/storage-service-net/docs/en/README.md @@ -113,6 +113,39 @@ dotnet run --project src/StorageService.API |--------|----------|-------------| | `GET` | `/api/v1/quota` | Get current user's storage quota | +### Admin API (Requires Role: Admin) + +> **Note:** These endpoints require `Admin` or `SuperAdmin` role. + +#### Quota Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/quotas` | Get all users' quotas | +| `GET` | `/api/v1/admin/quotas/{userId}` | Get quota for specific user | +| `PUT` | `/api/v1/admin/quotas/{userId}` | Update quota limits | + +#### Files Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/files` | Get all files with filtering | +| `DELETE` | `/api/v1/admin/files/{id}` | Delete file for policy violation | + +#### Shares Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/shares` | Get all shares | +| `DELETE` | `/api/v1/admin/shares/{id}` | Revoke share for violation | + +#### Statistics + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/statistics` | Dashboard aggregated statistics | +| `GET` | `/api/v1/admin/statistics/users-near-limit` | Users near quota limit (>80%) | + ## Direct Upload Example (Recommended) ### Step 1: Get Pre-signed URL diff --git a/services/storage-service-net/docs/vi/ARCHITECTURE.md b/services/storage-service-net/docs/vi/ARCHITECTURE.md index 08d0ad38..555f32b9 100644 --- a/services/storage-service-net/docs/vi/ARCHITECTURE.md +++ b/services/storage-service-net/docs/vi/ARCHITECTURE.md @@ -96,6 +96,23 @@ Entry point ứng dụng và triển khai CQRS: | **DeleteFileCommand** | Xử lý xóa file | | **Query Handlers** | Xử lý các thao tác đọc | +### 4. Admin Backoffice Layer + +Controllers và Commands cho Admin quản lý storage: + +| Component | Mục đích | +|-----------|----------| +| **AdminQuotaController** | Quản lý quota users (GET all, PUT update) | +| **AdminFilesController** | Xem/xóa files của tất cả users | +| **AdminSharesController** | Xem/revoke file shares vi phạm | +| **AdminStatisticsController** | Dashboard thống kê storage | +| **UpdateUserQuotaCommand** | Admin cập nhật quota limits | +| **AdminDeleteFileCommand** | Admin xóa file (bypass ownership) | +| **AdminRevokeShareCommand** | Admin revoke share vi phạm | +| **Admin Query Handlers** | Queries với phân trang và filter | + +> **Authorization**: Tất cả Admin endpoints yêu cầu role `Admin` hoặc `SuperAdmin`. + ## Kiến Trúc Direct Upload (Khuyến Nghị) Cho hệ thống với hàng triệu users, pattern Direct Client Upload được khuyến nghị thay vì proxy upload. @@ -756,6 +773,37 @@ erDiagram |--------|----------|-------| | `GET` | `/api/v1/quota` | Lấy quota storage của user | +### Admin API (Yêu cầu Role: Admin) + +#### Quản lý Quota + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/quotas` | Lấy quota tất cả users | +| `GET` | `/api/v1/admin/quotas/{userId}` | Lấy quota user cụ thể | +| `PUT` | `/api/v1/admin/quotas/{userId}` | Cập nhật quota limits | + +#### Quản lý Files + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/files` | Xem tất cả files | +| `DELETE` | `/api/v1/admin/files/{id}` | Xóa file vi phạm | + +#### Quản lý Shares + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/shares` | Xem tất cả shares | +| `DELETE` | `/api/v1/admin/shares/{id}` | Revoke share | + +#### Thống kê + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/statistics` | Dashboard thống kê | +| `GET` | `/api/v1/admin/statistics/users-near-limit` | Users gần hết quota | + ## Health Checks diff --git a/services/storage-service-net/docs/vi/README.md b/services/storage-service-net/docs/vi/README.md index dd552c84..8d8cd523 100644 --- a/services/storage-service-net/docs/vi/README.md +++ b/services/storage-service-net/docs/vi/README.md @@ -114,6 +114,39 @@ dotnet run --project src/StorageService.API |--------|----------|-------| | `GET` | `/api/v1/quota` | Lấy quota storage của user hiện tại | +### Admin API (Yêu cầu Role: Admin) + +> **Lưu ý:** Các endpoints này yêu cầu role `Admin` hoặc `SuperAdmin`. + +#### Quản lý Quota + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/quotas` | Lấy danh sách quota tất cả users | +| `GET` | `/api/v1/admin/quotas/{userId}` | Lấy quota của user cụ thể | +| `PUT` | `/api/v1/admin/quotas/{userId}` | Cập nhật quota limits | + +#### Quản lý Files + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/files` | Xem tất cả files với filter | +| `DELETE` | `/api/v1/admin/files/{id}` | Xóa file vi phạm | + +#### Quản lý Shares + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/shares` | Xem tất cả shares | +| `DELETE` | `/api/v1/admin/shares/{id}` | Revoke share vi phạm | + +#### Thống kê + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/statistics` | Dashboard thống kê tổng hợp | +| `GET` | `/api/v1/admin/statistics/users-near-limit` | Users gần hết quota (>80%) | + ## Ví Dụ Direct Upload (Khuyến nghị) ### Bước 1: Lấy Pre-signed URL diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/Admin/AdminQueryHandlers.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/Admin/AdminQueryHandlers.cs new file mode 100644 index 00000000..71f318e3 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/Admin/AdminQueryHandlers.cs @@ -0,0 +1,292 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using StorageService.Infrastructure.Persistence; + +namespace StorageService.API.Application.Queries.Admin; + +/// +/// EN: Handler for GetAllUsersQuotaQuery. +/// VI: Handler cho GetAllUsersQuotaQuery. +/// +public class GetAllUsersQuotaQueryHandler : IRequestHandler +{ + private readonly StorageServiceContext _context; + + public GetAllUsersQuotaQueryHandler(StorageServiceContext context) + { + _context = context; + } + + public async Task Handle(GetAllUsersQuotaQuery request, CancellationToken cancellationToken) + { + var query = _context.UserStorageQuotas.AsNoTracking(); + + // EN: Apply filters / VI: Áp dụng filters + if (!string.IsNullOrEmpty(request.QuotaTier)) + { + query = query.Where(q => q.QuotaTier == request.QuotaTier); + } + + if (request.MinUsagePercentage.HasValue) + { + query = query.Where(q => + q.MaxStorageBytes > 0 && + (double)q.UsedStorageBytes / q.MaxStorageBytes * 100 >= request.MinUsagePercentage.Value); + } + + // EN: Get total count / VI: Lấy tổng số + var totalCount = await query.CountAsync(cancellationToken); + + // EN: Apply sorting / VI: Áp dụng sắp xếp + query = request.SortBy?.ToLower() switch + { + "usedstoragebytes" => request.Descending + ? query.OrderByDescending(q => q.UsedStorageBytes) + : query.OrderBy(q => q.UsedStorageBytes), + "maxstoragebytes" => request.Descending + ? query.OrderByDescending(q => q.MaxStorageBytes) + : query.OrderBy(q => q.MaxStorageBytes), + "currentfilecount" => request.Descending + ? query.OrderByDescending(q => q.CurrentFileCount) + : query.OrderBy(q => q.CurrentFileCount), + "lastupdatedat" => request.Descending + ? query.OrderByDescending(q => q.LastUpdatedAt) + : query.OrderBy(q => q.LastUpdatedAt), + _ => query.OrderByDescending(q => q.UsedStorageBytes) // Default: highest usage first + }; + + // EN: Apply pagination / VI: Áp dụng phân trang + var skip = (request.PageNumber - 1) * request.PageSize; + var items = await query + .Skip(skip) + .Take(request.PageSize) + .Select(q => new AdminQuotaDto( + q.Id, + q.UserId, + q.MaxStorageBytes, + q.UsedStorageBytes, + q.MaxStorageBytes - q.UsedStorageBytes, + q.MaxFileCount, + q.CurrentFileCount, + q.MaxFileCount - q.CurrentFileCount, + q.MaxStorageBytes > 0 ? Math.Round((double)q.UsedStorageBytes / q.MaxStorageBytes * 100, 2) : 0, + q.QuotaTier, + q.LastUpdatedAt, + q.CreatedAt)) + .ToListAsync(cancellationToken); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize); + + return new AllUsersQuotaResult(items, totalCount, request.PageNumber, request.PageSize, totalPages); + } +} + +/// +/// EN: Handler for GetStorageStatisticsQuery. +/// VI: Handler cho GetStorageStatisticsQuery. +/// +public class GetStorageStatisticsQueryHandler : IRequestHandler +{ + private readonly StorageServiceContext _context; + + public GetStorageStatisticsQueryHandler(StorageServiceContext context) + { + _context = context; + } + + public async Task Handle(GetStorageStatisticsQuery request, CancellationToken cancellationToken) + { + // EN: Get quota statistics / VI: Lấy thống kê quota + var quotaStats = await _context.UserStorageQuotas + .AsNoTracking() + .GroupBy(_ => 1) + .Select(g => new + { + TotalUsers = g.Count(), + TotalStorageUsedBytes = g.Sum(q => q.UsedStorageBytes), + TotalStorageAllocatedBytes = g.Sum(q => q.MaxStorageBytes), + TotalFileCount = g.Sum(q => q.CurrentFileCount), + AverageUsagePercentage = g.Average(q => + q.MaxStorageBytes > 0 ? (double)q.UsedStorageBytes / q.MaxStorageBytes * 100 : 0) + }) + .FirstOrDefaultAsync(cancellationToken); + + // EN: Get users by tier / VI: Lấy users theo tier + var usersByTier = await _context.UserStorageQuotas + .AsNoTracking() + .GroupBy(q => q.QuotaTier ?? "unknown") + .Select(g => new { Tier = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.Tier, g => g.Count, cancellationToken); + + // EN: Get users near limit (>80% usage) / VI: Lấy users gần hết quota (>80%) + var usersNearLimit = await _context.UserStorageQuotas + .AsNoTracking() + .Where(q => q.MaxStorageBytes > 0 && + (double)q.UsedStorageBytes / q.MaxStorageBytes >= 0.8 && + (double)q.UsedStorageBytes / q.MaxStorageBytes < 1.0) + .CountAsync(cancellationToken); + + // EN: Get users over limit (>=100% usage) / VI: Lấy users vượt quota (>=100%) + var usersOverLimit = await _context.UserStorageQuotas + .AsNoTracking() + .Where(q => q.MaxStorageBytes > 0 && + (double)q.UsedStorageBytes / q.MaxStorageBytes >= 1.0) + .CountAsync(cancellationToken); + + return new StorageStatisticsDto( + quotaStats?.TotalUsers ?? 0, + quotaStats?.TotalStorageUsedBytes ?? 0, + quotaStats?.TotalStorageAllocatedBytes ?? 0, + quotaStats?.TotalFileCount ?? 0, + Math.Round(quotaStats?.AverageUsagePercentage ?? 0, 2), + usersByTier, + usersNearLimit, + usersOverLimit); + } +} + +/// +/// EN: Handler for AdminGetFilesQuery. +/// VI: Handler cho AdminGetFilesQuery. +/// +public class AdminGetFilesQueryHandler : IRequestHandler +{ + private readonly StorageServiceContext _context; + + public AdminGetFilesQueryHandler(StorageServiceContext context) + { + _context = context; + } + + public async Task Handle(AdminGetFilesQuery request, CancellationToken cancellationToken) + { + // EN: Include deleted files for admin / VI: Bao gồm files đã xóa cho admin + var query = _context.StorageFiles + .IgnoreQueryFilters() + .AsNoTracking(); + + // EN: Apply filters / VI: Áp dụng filters + if (!string.IsNullOrEmpty(request.UserId)) + { + query = query.Where(f => f.UserId == request.UserId); + } + + if (!string.IsNullOrEmpty(request.AccessLevel)) + { + query = query.Where(f => f.AccessLevel.ToString() == request.AccessLevel); + } + + if (!string.IsNullOrEmpty(request.ContentType)) + { + query = query.Where(f => f.ContentType.Contains(request.ContentType)); + } + + if (request.UploadedAfter.HasValue) + { + query = query.Where(f => f.UploadedAt >= request.UploadedAfter.Value); + } + + if (request.UploadedBefore.HasValue) + { + query = query.Where(f => f.UploadedAt <= request.UploadedBefore.Value); + } + + // EN: Get total count / VI: Lấy tổng số + var totalCount = await query.CountAsync(cancellationToken); + + // EN: Apply sorting / VI: Áp dụng sắp xếp + query = request.SortBy?.ToLower() switch + { + "filesizebytes" => request.Descending + ? query.OrderByDescending(f => f.FileSizeBytes) + : query.OrderBy(f => f.FileSizeBytes), + "uploadedat" => request.Descending + ? query.OrderByDescending(f => f.UploadedAt) + : query.OrderBy(f => f.UploadedAt), + "filename" => request.Descending + ? query.OrderByDescending(f => f.FileName) + : query.OrderBy(f => f.FileName), + _ => query.OrderByDescending(f => f.UploadedAt) + }; + + // EN: Apply pagination / VI: Áp dụng phân trang + var skip = (request.PageNumber - 1) * request.PageSize; + var items = await query + .Skip(skip) + .Take(request.PageSize) + .Select(f => new AdminFileDto( + f.Id, + f.UserId, + f.FileName, + f.ContentType, + f.FileSizeBytes, + f.Provider.ToString(), + f.AccessLevel.ToString(), + null, // FolderId - not tracked in StorageFile directly + f.UploadedAt, + f.LastAccessedAt, + f.IsDeleted)) + .ToListAsync(cancellationToken); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize); + + return new AdminFilesResult(items, totalCount, request.PageNumber, request.PageSize, totalPages); + } +} + +/// +/// EN: Handler for AdminGetSharesQuery. +/// VI: Handler cho AdminGetSharesQuery. +/// +public class AdminGetSharesQueryHandler : IRequestHandler +{ + private readonly StorageServiceContext _context; + + public AdminGetSharesQueryHandler(StorageServiceContext context) + { + _context = context; + } + + public async Task Handle(AdminGetSharesQuery request, CancellationToken cancellationToken) + { + var query = _context.FileShares.AsNoTracking(); + + // EN: Apply filters / VI: Áp dụng filters + if (!string.IsNullOrEmpty(request.Status)) + { + query = query.Where(s => s.Status.ToString() == request.Status); + } + + if (!string.IsNullOrEmpty(request.SharedBy)) + { + query = query.Where(s => s.SharedBy == request.SharedBy); + } + + // EN: Get total count / VI: Lấy tổng số + var totalCount = await query.CountAsync(cancellationToken); + + // EN: Apply pagination / VI: Áp dụng phân trang + var skip = (request.PageNumber - 1) * request.PageSize; + var items = await query + .OrderByDescending(s => s.CreatedAt) + .Skip(skip) + .Take(request.PageSize) + .Select(s => new AdminShareDto( + s.Id, + s.FileId, + s.SharedBy, + s.SharedWith, + s.Permission.ToString(), + s.Status.ToString(), + s.DownloadCount, + s.MaxDownloads, + s.ExpiresAt, + s.CreatedAt, + s.RevokedAt)) + .ToListAsync(cancellationToken); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize); + + return new AdminSharesResult(items, totalCount, request.PageNumber, request.PageSize, totalPages); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminFilesController.cs b/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminFilesController.cs new file mode 100644 index 00000000..814cfab3 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminFilesController.cs @@ -0,0 +1,95 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using StorageService.API.Application.Commands.Admin; +using StorageService.API.Application.Queries.Admin; +using Swashbuckle.AspNetCore.Annotations; +using System.Security.Claims; +using Asp.Versioning; + +namespace StorageService.API.Controllers.Admin; + +/// +/// EN: Admin controller for file management. +/// VI: Controller admin cho quản lý files. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/admin/files")] +[Authorize(Roles = "Admin,SuperAdmin")] +[SwaggerTag("Admin File Management - View and manage all user files")] +public class AdminFilesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public AdminFilesController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get all files with pagination. + /// VI: Lấy tất cả files với phân trang. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get all files", Description = "Get all files across all users with filtering")] + [SwaggerResponse(200, "Files retrieved successfully")] + [SwaggerResponse(403, "Forbidden - Admin role required")] + public async Task>> GetAll( + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? userId = null, + [FromQuery] string? accessLevel = null, + [FromQuery] string? contentType = null, + [FromQuery] DateTime? uploadedAfter = null, + [FromQuery] DateTime? uploadedBefore = null, + [FromQuery] string? sortBy = null, + [FromQuery] bool descending = false, + CancellationToken cancellationToken = default) + { + var query = new AdminGetFilesQuery( + pageNumber, pageSize, userId, accessLevel, contentType, + uploadedAfter, uploadedBefore, sortBy, descending); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Delete a file (admin action). + /// VI: Xóa file (hành động admin). + /// + [HttpDelete("{fileId:guid}")] + [SwaggerOperation(Summary = "Delete file", Description = "Delete a file as admin (bypasses ownership check)")] + [SwaggerResponse(200, "File deleted successfully")] + [SwaggerResponse(404, "File not found")] + public async Task>> Delete( + Guid fileId, + [FromBody] AdminDeleteRequest request, + CancellationToken cancellationToken = default) + { + var adminUserId = GetUserId(); + if (string.IsNullOrEmpty(adminUserId)) + return Unauthorized(new ApiResponse { Success = false, Error = "Admin ID not found" }); + + _logger.LogWarning("Admin {AdminId} deleting file {FileId} for reason: {Reason}", adminUserId, fileId, request.Reason); + + var command = new AdminDeleteFileCommand(fileId, adminUserId, request.Reason); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + return NotFound(new ApiResponse { Success = false, Error = result.Error }); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier); +} + +/// +/// EN: Request model for admin delete action. +/// VI: Request model cho hành động xóa bởi admin. +/// +public record AdminDeleteRequest(string Reason); diff --git a/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminQuotaController.cs b/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminQuotaController.cs new file mode 100644 index 00000000..0fb25a83 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminQuotaController.cs @@ -0,0 +1,112 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using StorageService.API.Application.Commands.Admin; +using StorageService.API.Application.Queries.Admin; +using Swashbuckle.AspNetCore.Annotations; +using System.Security.Claims; +using Asp.Versioning; + +namespace StorageService.API.Controllers.Admin; + +/// +/// EN: Admin controller for quota management. +/// VI: Controller admin cho quản lý quota. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/admin/quotas")] +[Authorize(Roles = "Admin,SuperAdmin")] +[SwaggerTag("Admin Quota Management - View and manage user storage quotas")] +public class AdminQuotaController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public AdminQuotaController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get all users' quotas with pagination. + /// VI: Lấy quota của tất cả users với phân trang. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get all quotas", Description = "Get all users' storage quotas with pagination and filtering")] + [SwaggerResponse(200, "Quotas retrieved successfully")] + [SwaggerResponse(403, "Forbidden - Admin role required")] + public async Task>> GetAll( + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? quotaTier = null, + [FromQuery] double? minUsagePercentage = null, + [FromQuery] string? sortBy = null, + [FromQuery] bool descending = false, + CancellationToken cancellationToken = default) + { + var query = new GetAllUsersQuotaQuery(pageNumber, pageSize, quotaTier, minUsagePercentage, sortBy, descending); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Get quota by user ID. + /// VI: Lấy quota theo user ID. + /// + [HttpGet("{userId}")] + [SwaggerOperation(Summary = "Get user quota", Description = "Get storage quota for a specific user")] + [SwaggerResponse(200, "Quota retrieved successfully")] + [SwaggerResponse(404, "User quota not found")] + public async Task>> GetByUserId( + string userId, + CancellationToken cancellationToken = default) + { + var query = new GetAllUsersQuotaQuery(1, 1); + var result = await _mediator.Send(query, cancellationToken); + + var quota = result.Items.FirstOrDefault(q => q.UserId == userId); + if (quota == null) + return NotFound(new ApiResponse { Success = false, Error = "Quota not found for user" }); + + return Ok(new ApiResponse { Success = true, Data = quota }); + } + + /// + /// EN: Update user quota limits. + /// VI: Cập nhật giới hạn quota của user. + /// + [HttpPut("{userId}")] + [SwaggerOperation(Summary = "Update user quota", Description = "Update storage quota limits for a user")] + [SwaggerResponse(200, "Quota updated successfully")] + [SwaggerResponse(400, "Invalid request or business rule violation")] + public async Task>> Update( + string userId, + [FromBody] UpdateQuotaRequest request, + CancellationToken cancellationToken = default) + { + var adminUserId = GetUserId(); + _logger.LogInformation("Admin {AdminId} updating quota for user {UserId}", adminUserId, userId); + + var command = new UpdateUserQuotaCommand(userId, request.MaxStorageBytes, request.MaxFileCount, request.QuotaTier); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + return BadRequest(new ApiResponse { Success = false, Error = result.Error }); + + return Ok(new ApiResponse { Success = true, Data = result.Data }); + } + + private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier); +} + +/// +/// EN: Request model for updating user quota. +/// VI: Request model để cập nhật quota user. +/// +public record UpdateQuotaRequest( + long MaxStorageBytes, + int MaxFileCount, + string? QuotaTier = null); diff --git a/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminSharesController.cs b/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminSharesController.cs new file mode 100644 index 00000000..db9f32fb --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminSharesController.cs @@ -0,0 +1,88 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using StorageService.API.Application.Commands.Admin; +using StorageService.API.Application.Queries.Admin; +using Swashbuckle.AspNetCore.Annotations; +using System.Security.Claims; +using Asp.Versioning; + +namespace StorageService.API.Controllers.Admin; + +/// +/// EN: Admin controller for file share management. +/// VI: Controller admin cho quản lý chia sẻ files. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/admin/shares")] +[Authorize(Roles = "Admin,SuperAdmin")] +[SwaggerTag("Admin Share Management - View and manage file shares")] +public class AdminSharesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public AdminSharesController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get all file shares with pagination. + /// VI: Lấy tất cả file shares với phân trang. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get all shares", Description = "Get all file shares with filtering")] + [SwaggerResponse(200, "Shares retrieved successfully")] + [SwaggerResponse(403, "Forbidden - Admin role required")] + public async Task>> GetAll( + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? status = null, + [FromQuery] string? sharedBy = null, + CancellationToken cancellationToken = default) + { + var query = new AdminGetSharesQuery(pageNumber, pageSize, status, sharedBy); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Revoke a file share (admin action). + /// VI: Thu hồi chia sẻ file (hành động admin). + /// + [HttpDelete("{shareId:guid}")] + [SwaggerOperation(Summary = "Revoke share", Description = "Revoke a file share as admin")] + [SwaggerResponse(200, "Share revoked successfully")] + [SwaggerResponse(404, "Share not found")] + public async Task>> Revoke( + Guid shareId, + [FromBody] AdminRevokeRequest request, + CancellationToken cancellationToken = default) + { + var adminUserId = GetUserId(); + if (string.IsNullOrEmpty(adminUserId)) + return Unauthorized(new ApiResponse { Success = false, Error = "Admin ID not found" }); + + _logger.LogWarning("Admin {AdminId} revoking share {ShareId} for reason: {Reason}", adminUserId, shareId, request.Reason); + + var command = new AdminRevokeShareCommand(shareId, adminUserId, request.Reason); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + return NotFound(new ApiResponse { Success = false, Error = result.Error }); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier); +} + +/// +/// EN: Request model for admin revoke action. +/// VI: Request model cho hành động thu hồi bởi admin. +/// +public record AdminRevokeRequest(string Reason); diff --git a/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminStatisticsController.cs b/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminStatisticsController.cs new file mode 100644 index 00000000..488a69ac --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/Admin/AdminStatisticsController.cs @@ -0,0 +1,68 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using StorageService.API.Application.Queries.Admin; +using Swashbuckle.AspNetCore.Annotations; +using Asp.Versioning; + +namespace StorageService.API.Controllers.Admin; + +/// +/// EN: Admin controller for storage statistics. +/// VI: Controller admin cho thống kê storage. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/admin/statistics")] +[Authorize(Roles = "Admin,SuperAdmin")] +[SwaggerTag("Admin Statistics - Dashboard statistics for storage")] +public class AdminStatisticsController : ControllerBase +{ + private readonly IMediator _mediator; + + public AdminStatisticsController(IMediator mediator) + { + _mediator = mediator; + } + + /// + /// EN: Get storage statistics. + /// VI: Lấy thống kê storage. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get statistics", Description = "Get aggregated storage statistics for dashboard")] + [SwaggerResponse(200, "Statistics retrieved successfully")] + [SwaggerResponse(403, "Forbidden - Admin role required")] + public async Task>> GetStatistics( + CancellationToken cancellationToken = default) + { + var query = new GetStorageStatisticsQuery(); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Get users near storage limit. + /// VI: Lấy users gần hết quota. + /// + [HttpGet("users-near-limit")] + [SwaggerOperation(Summary = "Get users near limit", Description = "Get users with usage >= 80%")] + [SwaggerResponse(200, "Users retrieved successfully")] + public async Task>> GetUsersNearLimit( + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var query = new GetAllUsersQuotaQuery( + pageNumber, pageSize, + null, // quotaTier + 80, // minUsagePercentage = 80% + "usedStorageBytes", + true // descending - highest usage first + ); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new ApiResponse { Success = true, Data = result }); + } +} diff --git a/services/wallet-service-net/Dockerfile b/services/wallet-service-net/Dockerfile index 8d0115ca..4e68c9a1 100644 --- a/services/wallet-service-net/Dockerfile +++ b/services/wallet-service-net/Dockerfile @@ -9,8 +9,8 @@ COPY ["src/WalletService.Domain/WalletService.Domain.csproj", "src/WalletService COPY ["src/WalletService.Infrastructure/WalletService.Infrastructure.csproj", "src/WalletService.Infrastructure/"] COPY ["Directory.Build.props", "./"] -# EN: Restore dependencies -# VI: Khôi phục dependencies +# EN: Restore dependencies for all projects +# VI: Khôi phục dependencies cho tất cả projects RUN dotnet restore "src/WalletService.API/WalletService.API.csproj" # EN: Copy all source code @@ -19,12 +19,12 @@ COPY src/ ./src/ # EN: Build the application # VI: Build ứng dụng -WORKDIR "/src/src/WalletService.API" -RUN dotnet build "WalletService.API.csproj" -c Release -o /app/build --no-restore +WORKDIR /src/src/WalletService.API +RUN dotnet build "WalletService.API.csproj" -c Release -o /app/build # Publish stage / Giai đoạn publish FROM build AS publish -RUN dotnet publish "WalletService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore +RUN dotnet publish "WalletService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false # Runtime stage / Giai đoạn runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final diff --git a/services/wallet-service-net/docker-compose.yml b/services/wallet-service-net/docker-compose.yml deleted file mode 100644 index 254ceb12..00000000 --- a/services/wallet-service-net/docker-compose.yml +++ /dev/null @@ -1,72 +0,0 @@ -version: '3.8' - -# EN: Docker Compose for local development -# VI: Docker Compose cho phát triển local - -services: - myservice-api: - build: - context: . - dockerfile: Dockerfile - container_name: myservice-api - ports: - - "5000:8080" - environment: - - ASPNETCORE_ENVIRONMENT=Development - - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres - - REDIS_URL=redis:6379 - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - networks: - - myservice-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - - postgres: - image: postgres:16-alpine - container_name: myservice-postgres - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: myservice_db - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - myservice-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - container_name: myservice-redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - networks: - - myservice-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - postgres_data: - redis_data: - -networks: - myservice-network: - driver: bridge diff --git a/services/wallet-service-net/src/WalletService.API/Properties/launchSettings.json b/services/wallet-service-net/src/WalletService.API/Properties/launchSettings.json index 6355d40b..b30b8dcd 100644 --- a/services/wallet-service-net/src/WalletService.API/Properties/launchSettings.json +++ b/services/wallet-service-net/src/WalletService.API/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "http://localhost:5000", + "applicationUrl": "http://localhost:5004", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/services/wallet-service-net/src/WalletService.API/appsettings.json b/services/wallet-service-net/src/WalletService.API/appsettings.json index 523dc0fc..c36154e8 100644 --- a/services/wallet-service-net/src/WalletService.API/appsettings.json +++ b/services/wallet-service-net/src/WalletService.API/appsettings.json @@ -30,7 +30,7 @@ ] }, "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres" + "DefaultConnection": "Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Database=wallet_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require" }, "Redis": { "ConnectionString": "localhost:6379"