From 14b64a3140a4d19a5774211cbd24fd45ac70740d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 13 Jul 2026 18:51:03 +0300 Subject: [PATCH] Implement activation checks across various commands and queries - Introduced `IRequiresActivation` interface to enforce activation requirements for multiple commands and queries, ensuring that only activated users can create, edit, or access configurations, news, and applications. - Updated the `RequireActivationBehavior` to handle activation checks uniformly, returning appropriate errors for unauthenticated or inactive users. - Enhanced error handling by adding `NotActivated` error to provide clear feedback for users attempting to access restricted features. - Updated documentation to reflect the new activation requirements and their implications on user access and functionality. --- CLAUDE.md | 8 +- .../Apps/ListAppsQuery.cs | 2 +- .../PnvPanel.Application/Auth/AuthErrors.cs | 3 + .../Behaviors/RequireActivationBehavior.cs | 32 +++++++ .../Common/Behaviors/ResultFailureFactory.cs | 20 +++++ .../Common/Behaviors/ValidationBehavior.cs | 15 +--- .../Common/Messaging/IRequiresActivation.cs | 4 + .../Configs/ConfigErrors.cs | 3 - .../Configs/Create/CreateVpnConfigCommand.cs | 2 +- .../Create/CreateVpnConfigCommandHandler.cs | 3 - .../Configs/Edit/EditVpnConfigCommand.cs | 2 +- .../GetConfigLink/GetConfigLinkQuery.cs | 2 +- .../Configs/GetMyConfigs/GetMyConfigsQuery.cs | 2 +- .../GetMySubscriptionQuery.cs | 2 +- .../ListAvailableInboundsQuery.cs | 2 +- .../Configs/Revoke/RevokeVpnConfigCommand.cs | 2 +- .../Configs/Rotate/RotateVpnConfigCommand.cs | 2 +- .../DependencyInjection.cs | 3 +- .../News/ListNewsQuery.cs | 2 +- .../RequireActivationBehaviorTests.cs | 85 +++++++++++++++++++ docs/api-design.md | 6 +- docs/architecture.md | 18 ++-- docs/domain-model.md | 8 +- 23 files changed, 183 insertions(+), 45 deletions(-) create mode 100644 backend/src/PnvPanel.Application/Common/Behaviors/RequireActivationBehavior.cs create mode 100644 backend/src/PnvPanel.Application/Common/Behaviors/ResultFailureFactory.cs create mode 100644 backend/src/PnvPanel.Application/Common/Messaging/IRequiresActivation.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 8f95b3f..6db2de0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,8 +73,12 @@ Понижение роли — грандфазеринг (лишние конфиги не отзываются, новые блокируются до входа в квоту). - **`limitIp`** выставляется автоматически по `MaxIpLimit` роли при создании клиента (`Create`/`Rotate`); панель не даёт настраивать его per-конфиг и не трогает уже созданных клиентов при смене роли/квоты. -- **Активация**: новый пользователь `IsActivated=false`, роль `user`; конфиги создаёт только - активированный. `ActivationRequest` одобряет админ на сайте или в Telegram — одними командами. +- **Активация**: новый пользователь `IsActivated=false`, роль `user`; неактивированному недоступны + конфиги (создание/просмотр/редактирование/ротация/отзыв/ссылка/подписка), новости и каталог + приложений — единая проверка `RequireActivationBehavior` по маркеру `IRequiresActivation` (не + разбросанные `if` в хендлерах). На фронте до активации доступны только дашборд (форма запроса + активации) и настройки аккаунта. `ActivationRequest` одобряет админ на сайте или в Telegram — + одними командами. - **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли (в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды. - **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`. diff --git a/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs b/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs index b529714..c0a6904 100644 --- a/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs +++ b/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs @@ -4,4 +4,4 @@ using PnvPanel.Domain.Apps; namespace PnvPanel.Application.Apps; -public sealed record ListAppsQuery : IQuery>>>; +public sealed record ListAppsQuery : IQuery>>>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Auth/AuthErrors.cs b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs index afc0a5f..4e5bf6d 100644 --- a/backend/src/PnvPanel.Application/Auth/AuthErrors.cs +++ b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs @@ -21,4 +21,7 @@ public static class AuthErrors public static readonly Error UserBlocked = Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором."); + + public static readonly Error NotActivated = + Error.Forbidden("Auth.NotActivated", "Аккаунт не активирован — обратитесь к администратору."); } diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/RequireActivationBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/RequireActivationBehavior.cs new file mode 100644 index 0000000..de0bac1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Behaviors/RequireActivationBehavior.cs @@ -0,0 +1,32 @@ +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Common.Behaviors; + +/// +/// Единая точка проверки активации для запросов, реализующих — +/// вместо разбросанных if(!profile.IsActivated) по хендлерам. Применяется только к запросам с этим +/// маркером (generic-ограничение), остальные проходят мимо. +/// +public sealed class RequireActivationBehavior(ICurrentUser currentUser, IIdentityService identityService) + : IPipelineBehavior + where TRequest : IRequiresActivation + where TResponse : Result +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return ResultFailureFactory.Create(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return ResultFailureFactory.Create(AuthErrors.Unauthorized); + + if (!profile.IsActivated) + return ResultFailureFactory.Create(AuthErrors.NotActivated); + + return await next(); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/ResultFailureFactory.cs b/backend/src/PnvPanel.Application/Common/Behaviors/ResultFailureFactory.cs new file mode 100644 index 0000000..af04d32 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Behaviors/ResultFailureFactory.cs @@ -0,0 +1,20 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Common.Behaviors; + +/// Строит Result/Result<T> failure-ответ через reflection — общий хелпер для generic pipeline behaviors. +internal static class ResultFailureFactory +{ + public static TResponse Create(Error error) where TResponse : Result + { + if (typeof(TResponse) == typeof(Result)) + return (TResponse)(object)Result.Failure(error); + + var valueType = typeof(TResponse).GetGenericArguments()[0]; + var method = typeof(Result) + .GetMethod(nameof(Result.Failure), 1, [typeof(Error)])! + .MakeGenericMethod(valueType); + + return (TResponse)method.Invoke(null, [error])!; + } +} diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs index f40d78e..0a806e4 100644 --- a/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs +++ b/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs @@ -27,19 +27,6 @@ public sealed class ValidationBehavior(IEnumerable f.ErrorMessage))); - return CreateFailure(error); - } - - private static TResponse CreateFailure(Error error) - { - if (typeof(TResponse) == typeof(Result)) - return (TResponse)(object)Result.Failure(error); - - var valueType = typeof(TResponse).GetGenericArguments()[0]; - var method = typeof(Result) - .GetMethod(nameof(Result.Failure), 1, [typeof(Error)])! - .MakeGenericMethod(valueType); - - return (TResponse)method.Invoke(null, [error])!; + return ResultFailureFactory.Create(error); } } diff --git a/backend/src/PnvPanel.Application/Common/Messaging/IRequiresActivation.cs b/backend/src/PnvPanel.Application/Common/Messaging/IRequiresActivation.cs new file mode 100644 index 0000000..fe66012 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/IRequiresActivation.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Application.Common.Messaging; + +/// Маркер: запрос доступен только активированным пользователям — проверяется RequireActivationBehavior. +public interface IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs index 8a733e3..db28c71 100644 --- a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs +++ b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs @@ -4,9 +4,6 @@ namespace PnvPanel.Application.Configs; public static class ConfigErrors { - public static readonly Error NotActivated = - Error.Forbidden("Configs.NotActivated", "Аккаунт не активирован — обратитесь к администратору."); - public static readonly Error InboundNotAvailable = Error.NotFound("Configs.InboundNotAvailable", "Инбаунд недоступен."); diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs index 47ea7f4..8a5cdc7 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs @@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Configs.Create; -public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label) : ICommand>; +public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label) : ICommand>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs index d8759a9..05eb9c6 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -20,9 +20,6 @@ public sealed class CreateVpnConfigCommandHandler( if (profile is null) return Result.Failure(AuthErrors.Unauthorized); - if (!profile.IsActivated) - return Result.Failure(ConfigErrors.NotActivated); - var inbound = await dbContext.Inbounds.AsNoTracking() .FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken); diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs index d507c36..6175a57 100644 --- a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs @@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Configs.Edit; -public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label) : ICommand>; +public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label) : ICommand>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs index b73ecc1..9ac9e21 100644 --- a/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs +++ b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs @@ -3,7 +3,7 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Configs.GetConfigLink; -public sealed record GetConfigLinkQuery(Guid ConfigId) : IQuery>; +public sealed record GetConfigLinkQuery(Guid ConfigId) : IQuery>, IRequiresActivation; /// SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса). public sealed record ConfigLinkDto(string ConnectionString, string SubscriptionToken); diff --git a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs index 7098eb6..55515fb 100644 --- a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs +++ b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs @@ -3,6 +3,6 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Configs.GetMyConfigs; -public sealed record GetMyConfigsQuery : IQuery>; +public sealed record GetMyConfigsQuery : IQuery>, IRequiresActivation; public sealed record GetMyConfigsResult(IReadOnlyList Configs, int MaxConfigs); diff --git a/backend/src/PnvPanel.Application/Configs/GetMySubscription/GetMySubscriptionQuery.cs b/backend/src/PnvPanel.Application/Configs/GetMySubscription/GetMySubscriptionQuery.cs index 59c1424..b9fd5b5 100644 --- a/backend/src/PnvPanel.Application/Configs/GetMySubscription/GetMySubscriptionQuery.cs +++ b/backend/src/PnvPanel.Application/Configs/GetMySubscription/GetMySubscriptionQuery.cs @@ -3,7 +3,7 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Configs.GetMySubscription; -public sealed record GetMySubscriptionQuery : IQuery>; +public sealed record GetMySubscriptionQuery : IQuery>, IRequiresActivation; /// SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса), см. GetConfigLinkQuery. public sealed record MySubscriptionDto(string SubscriptionToken); diff --git a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs index d531332..27e1d5d 100644 --- a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs +++ b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs @@ -4,7 +4,7 @@ using PnvPanel.Domain.Inbounds; namespace PnvPanel.Application.Configs.ListAvailableInbounds; -public sealed record ListAvailableInboundsQuery : IQuery>>; +public sealed record ListAvailableInboundsQuery : IQuery>>, IRequiresActivation; /// Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui. public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol); diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs index 28b87bf..220bed4 100644 --- a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs @@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Configs.Revoke; -public sealed record RevokeVpnConfigCommand(Guid ConfigId) : ICommand; +public sealed record RevokeVpnConfigCommand(Guid ConfigId) : ICommand, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs index 68327fb..21ee8b0 100644 --- a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs @@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Configs.Rotate; -public sealed record RotateVpnConfigCommand(Guid ConfigId) : ICommand>; +public sealed record RotateVpnConfigCommand(Guid ConfigId) : ICommand>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/DependencyInjection.cs b/backend/src/PnvPanel.Application/DependencyInjection.cs index 2c88066..5786ca5 100644 --- a/backend/src/PnvPanel.Application/DependencyInjection.cs +++ b/backend/src/PnvPanel.Application/DependencyInjection.cs @@ -22,9 +22,10 @@ public static class DependencyInjection RegisterClosedGeneric(services, assembly, typeof(IQueryHandler<,>)); RegisterClosedGeneric(services, assembly, typeof(IValidator<>)); - // Порядок важен: Logging (снаружи) -> Validation -> UnitOfWork (ближе к хендлеру). + // Порядок важен: Logging (снаружи) -> Validation -> RequireActivation -> UnitOfWork (ближе к хендлеру). services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequireActivationBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkBehavior<,>)); return services; diff --git a/backend/src/PnvPanel.Application/News/ListNewsQuery.cs b/backend/src/PnvPanel.Application/News/ListNewsQuery.cs index fda6b8c..611667f 100644 --- a/backend/src/PnvPanel.Application/News/ListNewsQuery.cs +++ b/backend/src/PnvPanel.Application/News/ListNewsQuery.cs @@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.News; -public sealed record ListNewsQuery(int Page, int PageSize) : IQuery>>; +public sealed record ListNewsQuery(int Page, int PageSize) : IQuery>>, IRequiresActivation; diff --git a/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs b/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs new file mode 100644 index 0000000..f2b2b81 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs @@ -0,0 +1,85 @@ +using NSubstitute; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Behaviors; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using Xunit; + +namespace PnvPanel.Application.Tests.Common.Behaviors; + +public class RequireActivationBehaviorTests +{ + private sealed record DummyRequest : IRequiresActivation; + + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IIdentityService _identityService = Substitute.For(); + + private RequireActivationBehavior> CreateBehavior() => new(_currentUser, _identityService); + + private static CurrentUserProfile CreateProfile(Guid userId, bool isActivated) => + new(userId, "alice", Guid.NewGuid(), "user", IsActivated: isActivated, IsBlocked: false, MaxConfigs: 3, + MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "sub-token"); + + [Fact] + public async Task Handle_WhenActivated_CallsNext() + { + var userId = Guid.NewGuid(); + _currentUser.UserId.Returns(userId); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(CreateProfile(userId, isActivated: true)); + + var result = await CreateBehavior().Handle( + new DummyRequest(), () => Task.FromResult(Result.Success("ok")), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("ok", result.Value); + } + + [Fact] + public async Task Handle_WhenNotActivated_ReturnsNotActivatedWithoutCallingNext() + { + var userId = Guid.NewGuid(); + _currentUser.UserId.Returns(userId); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(CreateProfile(userId, isActivated: false)); + var nextCalled = false; + + var result = await CreateBehavior().Handle( + new DummyRequest(), + () => + { + nextCalled = true; + return Task.FromResult(Result.Success("ok")); + }, + CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.NotActivated, result.Error); + Assert.False(nextCalled); + } + + [Fact] + public async Task Handle_WhenNoCurrentUser_ReturnsUnauthorized() + { + _currentUser.UserId.Returns((Guid?)null); + + var result = await CreateBehavior().Handle( + new DummyRequest(), () => Task.FromResult(Result.Success("ok")), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.Unauthorized, result.Error); + } + + [Fact] + public async Task Handle_WhenProfileMissing_ReturnsUnauthorized() + { + var userId = Guid.NewGuid(); + _currentUser.UserId.Returns(userId); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns((CurrentUserProfile?)null); + + var result = await CreateBehavior().Handle( + new DummyRequest(), () => Task.FromResult(Result.Success("ok")), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.Unauthorized, result.Error); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index e460fff..7b39e9f 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -79,8 +79,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из `connectionString`. -`POST /api/configs` без активации → `403` (`Configs.NotActivated`); сверх квоты роли → `409` -(`Configs.QuotaExceeded`). +Все `/api/configs/*`, `/api/news`, `/api/apps` без активации → `403` (`Auth.NotActivated`, единая +проверка `RequireActivationBehavior`); создание сверх квоты роли → `409` (`Configs.QuotaExceeded`). ## Apps — каталог приложений @@ -222,7 +222,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи | --- | -------------------------------------------------------------------- | | 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) | | 401 | Нет/просрочен/невалиден access-токен | -| 403 | Нет прав по роли, либо `Configs.NotActivated` | +| 403 | Нет прав по роли, либо `Auth.NotActivated` | | 404 | Ресурс не найден | | 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации | | 422 | Прочие управляемые ошибки, не подошедшие под коды выше | diff --git a/docs/architecture.md b/docs/architecture.md index dac2bb9..638bda2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,9 +73,11 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C [backend-conventions.md](backend-conventions.md)). - **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом DTO, без маппера (Mapster/AutoMapper). -- **Pipeline behaviors**: `ValidationBehavior`, `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция + - `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` нет — авторизация (роль, - активация) — это либо `RequireAuthorization()`/`RequireRole(...)` на эндпоинте, либо явная проверка +- **Pipeline behaviors** (порядок: Logging → Validation → RequireActivation → UnitOfWork): + `LoggingBehavior`, `ValidationBehavior`, `RequireActivationBehavior` (403 `Auth.NotActivated` для + запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений), `UnitOfWorkBehavior` + (транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет — + роль проверяется через `RequireAuthorization()`/`RequireRole(...)` на эндпоинте либо явной проверкой в начале хендлера (например, «инбаунд доступен роли пользователя»). - **Result**: явная модель успеха/ошибки (`Result`/`Result`, `Error` с `ErrorType`) вместо исключений для управляемых сценариев. @@ -131,10 +133,11 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C Пример потока «создать конфиг» (`backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs`): ``` POST /api/configs - → CreateVpnConfigCommand + → CreateVpnConfigCommand (IRequiresActivation) → ValidationBehavior (FluentValidation — формат inboundId/label) + → RequireActivationBehavior (403 Auth.NotActivated, если аккаунт не активирован) → CreateVpnConfigCommandHandler - · проверяет активацию + роль инбаунда (доменные проверки) + · проверяет роль инбаунда (доменная проверка) · SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания · пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs · IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId @@ -253,8 +256,9 @@ POST /api/configs вошедшим — `POST /api/auth/change-password`. - **AuthZ**: именованных policy нет — либо `.RequireAuthorization()` (любой вошедший) или `.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))` на группе эндпоинтов, либо - явная проверка внутри хендлера (владение конфигом — сравнение `VpnConfig.UserId` с `ICurrentUser`; - активация — `ConfigErrors.NotActivated`). + явная проверка внутри хендлера (владение конфигом — сравнение `VpnConfig.UserId` с `ICurrentUser`). + Активация — не в хендлере, а в pipeline behavior (`RequireActivationBehavior`, срабатывает на + запросах с маркером `IRequiresActivation`: конфиги, новости, каталог приложений) — `AuthErrors.NotActivated`. - **Секреты нод**: шифруются `ISecretProtector` (Data Protection) перед сохранением; в API/логи не попадают. - **CSRF**: явного анти-CSRF токена нет — все мутации API читают авторизацию только из `Authorization: Bearer` (JS должен явно прочитать access-token из памяти и подставить заголовок, diff --git a/docs/domain-model.md b/docs/domain-model.md index 30bd462..9a817d0 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -107,7 +107,10 @@ NewsPost (лента новостей; публикует трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp` всегда `null` — «не менять»). - `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения. -- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`). +- **Доступ разрешён только активированному пользователю** (`AppUser.IsActivated == true`): создание, + просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также + чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline + behavior, маркер `IRequiresActivation` на команде/запросе), а не разбросанные проверки в хендлерах. - Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`; роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже. - Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`). @@ -196,7 +199,8 @@ NewsPost (лента новостей; публикует | `TelegramLinkedAt` | `DateTimeOffset?` | Когда привязан | Инварианты: один `TelegramUserId` ↔ один аккаунт (повторная привязка требует `/unlink`); -неактивированный пользователь не может создавать конфиги; при регистрации выдаётся роль `user`. +неактивированный пользователь не имеет доступа к конфигам, новостям и каталогу приложений (см. выше); +при регистрации выдаётся роль `user`. **Блокировка** (`IsBlocked = true`) переводит все конфиги в `Disabled` (отключение клиентов в 3x-ui); разблокировка включает их обратно. У пользователя ровно одна роль.