From 8a9de0c9cb9763032c933a343a87f97b8960fa2d Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 9 Aug 2026 20:16:39 -0500 Subject: [PATCH 01/35] =?UTF-8?q?feat(api):=20resincroniza=20el=20backend?= =?UTF-8?q?=20con=20la=20plataforma=20de=20origen,=20rebrandeado=20al=20sa?= =?UTF-8?q?t=C3=A9lite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reemplaza Ums.Domain/Application/Infrastructure/Presentation/ReadModels y sus pruebas por el árbol de la plataforma de origen, aplicando el mapa de marca del satélite: Unimar.Ums -> Ums, Unimar.Shells.Net -> BeyondNetCode.Shell (nuget.org, sin feed privado), inquilino raíz UNIMAR -> BEYONDNET (mismo GUID, que es el que el arnés e2e ya esperaba), prefijo de telemetría unimar. -> evolith. Se preserva lo que es del satélite y no existe en el origen: - Branding por inquilino. La entidad hija vuelve al agregado Tenant (SetBranding, UpdateBranding, VerifyBrandingDns, FailBrandingDns, RemoveBranding), con sus cinco eventos, sus dos códigos de DomainErrors.Tenant y el catálogo DomainErrors.Branding. Se retira la migración DropTenantBrandingTable del origen —este satélite nunca soltó la tabla, que ya la crea InitialPostgresCreate— y se regenera el snapshot de EF: sin cambios pendientes de modelo y sin drop/re-create que destruya datos. - GraphQL. Vuelve HotChocolate al csproj y el cableado al bootstrapper (AddUmsGraphQl + MapGraphQlSurface, sin rate limiter porque el árbol nuevo no registra ninguno). Los resolvers pasan a static: son sin estado y el gate heredado promueve S2325 a error. - Evolith.Contracts.MasterData conserva su namespace a propósito: es el contrato de wire del productor (MMS) y MassTransit enruta por el URN del tipo (ADR-0106). Se retira lo que el diseño nuevo sustituye: Menu/SubMenu/Option (los reemplaza MenuNode), los SchemaBootstrapper de SQLite/PostgreSQL y los umsdev.db (el arranque migra con EF y el proveedor es PostgreSQL), RequestContextAccessor, CorrelationIdMiddleware, el sink y el background service de AuditTrail, RemoveBranch (lo sustituye CloseBranch, ADR-0164), RemoveTemplateItem (DeactivateItem) y PromotionStatus (modelo IGA de madurez de rol). Ums.slnx se va por obsoleto: apuntaba a libs/shell inexistentes. Se corrige un fallo que también está en el origen: faltaba Apply(TemplateAutoAssignedEvent) en ProfileDomainEventsManager, y sin él CreateProfileCommandHandler revienta al auto-asignar plantilla (3 pruebas). Validación: build 0 errores; Domain.Test 930/930; Application.Test 933/934. El fallo restante es del reloj, no del cambio: T12_AuditFields compara DateTime.UtcNow.Date contra un CreatedAt que el paquete externo BeyondNetCode.Shell.Ddd.ValueObjects estampa en hora local, así que falla en la ventana UTC 00:00-05:00 (ahora mismo UTC es 10-ago y Lima 9-ago). Co-Authored-By: Claude Opus 5 --- src/.editorconfig | 117 + src/Directory.Build.props | 20 + src/apps/ums.api/Dockerfile | 4 + ...essEnforcementPolicyCommandHandlerTests.cs | 23 + .../ApprovalRequestCommandHandlerTests.cs | 205 +- .../ApprovalRequestQueryHandlerTests.cs | 39 + .../ApprovalWorkflowCommandHandlerTests.cs | 2 +- .../UserDocumentCommandHandlerTests.cs | 12 +- .../AuditRecordCommandHandlerTests.cs | 55 +- .../AuditRecordQueryHandlerTests.cs | 32 +- .../RecordAuditCommandValidatorTests.cs | 38 + .../Graph/AppSettingVisibilityTests.cs | 69 + .../Graph/AuthGraphPayloadTests.cs | 202 + .../AuthorizationGraphBuilderServiceTests.cs | 480 ++- .../Profile/ProfileCommandHandlerTests.cs | 120 +- .../SystemSuiteQueryHandlerTests.cs | 150 +- .../AddTemplateItemCommandValidatorTests.cs | 92 + .../Template/TemplateCommandHandlerTests.cs | 159 + .../Common/Aop/AuditMetadataSanitizerTests.cs | 210 ++ .../Common/Aop/AuthorizationAspectTests.cs | 138 +- .../Common/Aop/TenantValidationAspectTests.cs | 197 + .../IntegrationEventOutboxDispatchTests.cs | 236 ++ .../TransactionalAtomicityTests.cs | 9 +- .../Common/Services/TenantScopePolicyTests.cs | 13 + .../AppConfigurationCommandHandlerTests.cs | 87 + .../AvisoDeConfiguracionEntreReplicasTests.cs | 74 + ...eParameterDefinitionCommandHandlerTests.cs | 144 + .../ConfigurationAuditServiceTests.cs | 185 + .../IGA/HeuristicRiskScoreCalculatorTests.cs | 90 + .../IGA/RolePromotionCommandHandlerTests.cs | 455 +++ .../IGA/RolePromotionQueryHandlerTests.cs | 140 + .../AuthMethodResolverSuiteSourceTests.cs | 226 ++ .../Identity/Auth/AuthMethodResolverTests.cs | 170 +- .../AuthenticateUserCommandHandlerTests.cs | 223 ++ .../Auth/ForgotPasswordCommandHandlerTests.cs | 299 ++ .../Auth/IdpChainAuthenticatorTests.cs | 338 ++ .../Auth/Oidc/HttpOidcTokenClientTests.cs | 175 + .../OidcAuthorizationRequestFactoryTests.cs | 102 + .../Auth/Oidc/OidcIdTokenValidatorTests.cs | 229 ++ .../Auth/Oidc/OidcIdpAuthAdapterTests.cs | 156 + .../Oidc/OidcProviderConfigParserTests.cs | 103 + .../Identity/Auth/Oidc/OidcTestSupport.cs | 152 + ...efreshAuthenticationCommandHandlerTests.cs | 378 ++ .../Auth/RefreshSessionCommandHandlerTests.cs | 211 ++ .../Auth/ResetPasswordCommandHandlerTests.cs | 234 ++ .../Auth/SignupUserCommandHandlerTests.cs | 4 +- .../Auth/SwitchProfileCommandHandlerTests.cs | 137 + .../SetManagementOwnerCommandHandlerTests.cs | 28 + ...erAccountPasswordMfaCommandHandlerTests.cs | 24 +- .../DelegationCommandHandlerTests.cs | 293 ++ .../DelegationQueryHandlerTests.cs | 2 +- .../FunctionalTransactionTests.cs | 140 + .../AddBranch/AddBranchCommandHandlerTests.cs | 6 +- .../Branch/BranchCommandHandlerTests.cs | 129 +- .../TenantMultiTenantIsolationTests.cs | 70 +- .../Queries/TenantQueryHandlerTests.cs | 18 +- .../CreateAccessEnforcementPolicyCommand.cs | 4 +- ...teAccessEnforcementPolicyCommandHandler.cs | 3 +- ...AccessEnforcementPolicyCommandValidator.cs | 10 +- .../DTOs/AccessEnforcementPolicyDto.cs | 4 +- ...AccessEnforcementPolicyByIdQueryHandler.cs | 3 +- ...llAccessEnforcementPoliciesQueryHandler.cs | 3 +- .../Commands/ApproveRequestCommandHandler.cs | 67 +- .../GetAllApprovalRequestsQueryHandler.cs | 2 +- .../CreateApprovalWorkflowCommandValidator.cs | 7 +- .../DTOs/ApprovalWorkflowDto.cs | 10 +- .../GetAllApprovalWorkflowsQueryHandler.cs | 5 +- .../GetApprovalWorkflowByIdQueryHandler.cs | 5 +- .../CreateDocumentTypeCommandValidator.cs | 8 +- .../UploadUserDocumentCommandValidator.cs | 7 +- .../Commands/RecordAuditCommandHandler.cs | 32 +- .../Commands/RecordAuditCommandValidator.cs | 25 + .../Queries/GetAllAuditRecordsQueryHandler.cs | 7 +- .../Queries/GetAuditRecordByIdQueryHandler.cs | 17 +- .../Graph/AuthorizationGraphBuilderService.cs | 601 ++- .../Authorization/Graph/GraphMetrics.cs | 40 + .../PreviewProfileAuthGraphCommandHandler.cs | 11 +- .../Graph/Serializers/AuthGraphPayload.cs | 241 ++ .../Commands/CreateProfileCommandHandler.cs | 54 +- .../Queries/GetAllProfilesQueryHandler.cs | 21 +- .../Queries/GetProfileByIdQueryHandler.cs | 26 +- .../Commands/AddDomainResourceCommand.cs | 4 +- .../AddDomainResourceCommandHandler.cs | 13 +- .../SystemSuite/Commands/AddMenuCommand.cs | 9 - .../Commands/AddMenuCommandHandler.cs | 40 - .../Commands/AddMenuCommandValidator.cs | 29 - .../SystemSuite/Commands/AddModuleCommand.cs | 4 +- .../Commands/AddModuleCommandHandler.cs | 13 +- .../SystemSuite/Commands/AddNodeCommand.cs | 89 + .../SystemSuite/Commands/AddOptionCommand.cs | 12 - .../Commands/AddOptionCommandHandler.cs | 43 - .../Commands/AddOptionCommandValidator.cs | 35 - .../SystemSuite/Commands/AddSubMenuCommand.cs | 10 - .../Commands/AddSubMenuCommandValidator.cs | 30 - .../Commands/DeleteSystemSuiteCommand.cs | 10 + .../DeleteSystemSuiteCommandHandler.cs | 76 + .../Commands/NodeActionCommands.cs | 106 + .../Commands/RegisterActionCommand.cs | 4 +- .../Commands/RegisterActionCommandHandler.cs | 13 +- .../SystemSuite/Commands/RemoveMenuCommand.cs | 3 - .../Commands/RemoveMenuCommandHandler.cs | 37 - ...CommandHandler.cs => RemoveNodeCommand.cs} | 27 +- .../Commands/RemoveOptionCommand.cs | 3 - .../Commands/RemoveOptionCommandHandler.cs | 39 - .../Commands/RemoveSubMenuCommand.cs | 3 - .../Commands/RemoveSubMenuCommandHandler.cs | 38 - .../Commands/SetNodeMetadataCommand.cs | 77 + .../Commands/SetNodeStatusCommand.cs | 54 + .../SystemSuite/Commands/UpdateMenuCommand.cs | 9 - .../Commands/UpdateMenuCommandValidator.cs | 16 - ...CommandHandler.cs => UpdateNodeCommand.cs} | 34 +- .../Commands/UpdateOptionCommand.cs | 12 - .../Commands/UpdateOptionCommandHandler.cs | 43 - .../Commands/UpdateOptionCommandValidator.cs | 19 - .../Commands/UpdateSubMenuCommand.cs | 10 - .../Commands/UpdateSubMenuCommandHandler.cs | 41 - .../Commands/UpdateSubMenuCommandValidator.cs | 17 - .../DTOs/AddDomainResourceResponse.cs | 4 + .../SystemSuite/DTOs/AddModuleResponse.cs | 4 + .../SystemSuite/DTOs/AddNodeResponse.cs | 4 + .../DTOs/RegisterActionResponse.cs | 4 + .../SystemSuite/DTOs/SystemSuiteDto.cs | 80 +- .../Queries/GetAllSystemSuitesQueryHandler.cs | 54 +- .../Commands/AddTemplateItemCommand.cs | 4 +- .../Commands/AddTemplateItemCommandHandler.cs | 22 +- .../AddTemplateItemCommandValidator.cs | 25 +- .../CreatePermissionTemplateCommandHandler.cs | 11 +- .../DeletePermissionTemplateCommandHandler.cs | 42 +- .../Commands/RemoveTemplateItemCommand.cs | 3 - .../RemoveTemplateItemCommandHandler.cs | 40 - .../Template/DTOs/AddTemplateItemResponse.cs | 4 + .../GetPermissionTemplateByIdQueryHandler.cs | 26 +- .../Common/Aop/AuditMetadataSanitizer.cs | 173 + .../Common/Aop/AuditTrailAspect.cs | 26 +- .../Common/Aop/AuthorizationAspect.cs | 11 +- .../Common/Aop/IAuditTrailSink.cs | 12 +- .../Common/Aop/TenantValidationAspect.cs | 50 +- .../Common/Aop/TransactionAspect.cs | 21 + .../Common/DomainEnumerationParser.cs | 16 + .../Interfaces/IFunctionalTransaction.cs | 110 + .../Interfaces/IIntegrationEventPublisher.cs | 15 + .../Interfaces/ILimitadorDePeticiones.cs | 30 + .../Interfaces/ISessionRevocationStore.cs | 35 + .../Interfaces/ITransactionLocatorFactory.cs | 16 + .../Common/Interfaces/IUnitOfWorkScope.cs | 9 + .../Notifications/NotificationTemplates.cs | 59 +- .../Common/Services/TenantScopePolicy.cs | 24 +- .../CreateAppConfigurationCommandHandler.cs | 3 +- .../Commands/DeleteAppConfigurationCommand.cs | 5 + .../DeleteAppConfigurationCommandHandler.cs | 60 + .../CreateIdpConfigurationCommandHandler.cs | 16 + .../Commands/ParameterDefinitionCommands.cs | 80 +- .../Commands/ParameterValueCommands.cs | 115 +- .../Services/ConfigurationAuditService.cs | 67 +- .../Services/ConfigurationValues.cs | 3 + .../Ums.Application/DependencyInjection.cs | 5 + .../IGA/Common/IgaHandlerGuards.cs | 57 + .../CreateRolePromotionRequestResponse.cs | 4 + .../IGA/DTOs/RoleMaturityStatusDto.cs | 19 + .../IGA/DTOs/RolePromotionMapper.cs | 42 + .../IGA/DTOs/RolePromotionRequestDto.cs | 17 + .../GetRoleMaturityStatusByUserQuery.cs | 11 + ...GetRoleMaturityStatusByUserQueryHandler.cs | 49 + .../Commands/CancelRolePromotionCommand.cs | 4 + .../CancelRolePromotionCommandHandler.cs | 61 + .../ConfirmRolePromotionEligibilityCommand.cs | 8 + ...mRolePromotionEligibilityCommandHandler.cs | 89 + .../CreateRolePromotionRequestCommand.cs | 13 + ...reateRolePromotionRequestCommandHandler.cs | 69 + .../Commands/ExecuteRolePromotionCommand.cs | 4 + .../ExecuteRolePromotionCommandHandler.cs | 90 + .../ManagerApproveRolePromotionCommand.cs | 7 + ...nagerApproveRolePromotionCommandHandler.cs | 72 + .../ManagerRejectRolePromotionCommand.cs | 4 + ...anagerRejectRolePromotionCommandHandler.cs | 68 + .../SecurityApproveRolePromotionCommand.cs | 4 + ...urityApproveRolePromotionCommandHandler.cs | 69 + .../SecurityRejectRolePromotionCommand.cs | 4 + ...curityRejectRolePromotionCommandHandler.cs | 69 + .../Commands/SubmitRolePromotionCommand.cs | 7 + .../SubmitRolePromotionCommandHandler.cs | 86 + .../Commands/VerifyRolePromotionCommand.cs | 4 + .../VerifyRolePromotionCommandHandler.cs | 66 + .../GetRolePromotionRequestByIdQuery.cs | 6 + ...GetRolePromotionRequestByIdQueryHandler.cs | 40 + .../Queries/ListRolePromotionRequestsQuery.cs | 10 + .../ListRolePromotionRequestsQueryHandler.cs | 49 + .../Services/HeuristicRiskScoreCalculator.cs | 90 + .../IGA/Services/IRiskScoreCalculator.cs | 33 + .../Auth/AuthMethodResolverService.cs | 83 +- .../Auth/Commands/AuthenticateUserCommand.cs | 16 +- .../AuthenticateUserCommandHandler.cs | 146 +- .../Auth/Commands/ForgotPasswordCommand.cs | 10 +- .../Commands/ForgotPasswordCommandHandler.cs | 134 +- .../Commands/RefreshAuthenticationCommand.cs | 25 + .../RefreshAuthenticationCommandHandler.cs | 245 ++ .../Auth/Commands/RefreshSessionCommand.cs | 25 + .../Commands/RefreshSessionCommandHandler.cs | 128 + .../Auth/Commands/ResetPasswordCommand.cs | 13 + .../Commands/ResetPasswordCommandHandler.cs | 105 + .../Commands/ResetPasswordCommandValidator.cs | 24 + .../Auth/Commands/SwitchProfileCommand.cs | 121 + .../Identity/Auth/IPasswordResetTokenStore.cs | 81 + .../Auth/IRefreshTokenPolicyProvider.cs | 12 + .../Identity/Auth/IRefreshTokenStore.cs | 79 + .../Auth/IResponseTimingNormalizer.cs | 43 + .../Identity/Auth/IdpChainAuthenticator.cs | 267 ++ .../Identity/Auth/PasswordResetToken.cs | 43 + .../Identity/Auth/RefreshTokenGenerator.cs | 20 + .../Identity/Auth/RefreshTokenHasher.cs | 19 + .../Identity/Auth/RefreshTokenPolicy.cs | 19 + .../Auth/RefreshTokenPolicyProvider.cs | 61 + .../Identity/Auth/RefreshTokenStatuses.cs | 22 + .../Branch/Commands/CloseBranchCommand.cs | 17 + .../Commands/CloseBranchCommandHandler.cs | 100 + .../Commands/CloseBranchCommandValidator.cs | 21 + .../DeactivateBranchCommandHandler.cs | 17 + .../Branch/Commands/RemoveBranchCommand.cs | 10 - .../Commands/RemoveBranchCommandValidator.cs | 15 - .../Branch/Commands/UpdateBranchCommand.cs | 8 + ...ndler.cs => UpdateBranchCommandHandler.cs} | 24 +- .../Identity/Tenant/Branch/DTOs/BranchDto.cs | 8 +- .../Branch/DTOs/BranchLifecycleEntryDto.cs | 15 + .../Tenant/Branch/DTOs/CloseBranchResponse.cs | 3 + .../Branch/DTOs/RemoveBranchResponse.cs | 3 - .../Branch/Queries/GetBranchLifecycleQuery.cs | 12 + .../Queries/GetBranchLifecycleQueryHandler.cs | 52 + .../Queries/GetBranchesByTenantIdQuery.cs | 9 +- .../GetBranchesByTenantIdQueryHandler.cs | 9 +- .../Commands/CreateTenantCommandHandler.cs | 31 +- .../SetManagementOwnerCommandHandler.cs | 18 + .../Tenant/Commands/UpdateTenantCommand.cs | 8 + .../Commands/UpdateTenantCommandHandler.cs | 68 + .../Tenant/Queries/GetAllTenantsQuery.cs | 4 +- .../Queries/GetAllTenantsQueryHandler.cs | 7 +- .../Queries/GetTenantByIdQueryHandler.cs | 16 +- .../ApproveTenantSignupCommandHandler.cs | 6 + .../Commands/AddPasswordCommandHandler.cs | 5 +- ...cordAuthenticationAttemptCommandHandler.cs | 12 +- .../Queries/GetAllUserAccountsQueryHandler.cs | 1 - ...etUserAccountMfaEnrollmentsQueryHandler.cs | 2 +- .../Commands/ApproveDelegationCommand.cs | 8 + .../ApproveDelegationCommandHandler.cs | 58 + .../ApproveDelegationCommandValidator.cs | 12 + .../Commands/RejectDelegationCommand.cs | 8 + .../RejectDelegationCommandHandler.cs | 55 + .../RejectDelegationCommandValidator.cs | 16 + .../SubmitDelegationForApprovalCommand.cs | 9 + ...bmitDelegationForApprovalCommandHandler.cs | 62 + ...itDelegationForApprovalCommandValidator.cs | 12 + .../Ums.Application/Ums.Application.csproj | 9 + .../Consumers/ApprovalsConsumerTests.cs | 207 + .../Consumers/AuditConsumerTests.cs | 160 + .../Consumers/AuthConsumerTests.cs | 83 +- .../Consumers/ConfigurationConsumerTests.cs | 349 ++ .../Consumers/IgaConsumerTests.cs | 251 ++ .../Consumers/TenantsConsumerTests.cs | 22 +- .../ContractTestWebApplicationFactory.cs | 25 +- ...uctionContractTestWebApplicationFactory.cs | 15 + .../ProviderStateEndpointGuardTests.cs | 63 + .../pacts/ums-web-app-ums-api.json | 3258 +++++++++++++--- .../AccessEnforcementPolicyTests.cs | 31 +- .../ApprovalWorkflow/ApprovalWorkflowTests.cs | 4 +- .../RequiredDocumentChecklistTests.cs | 122 + .../UserDocument/UserDocumentTests.cs | 30 +- .../Audit/AuditRecord/AuditRecordTests.cs | 4 +- .../Authorization/Profile/ProfileTests.cs | 112 + .../SeedData/AuthorizationSeedDataTests.cs | 132 +- .../MenuNode/MenuNodeMetadataEqualityTests.cs | 101 + .../PresentacionDeNavegacionTests.cs | 111 + .../SystemSuite/SystemSuiteTests.cs | 117 +- .../Template/PermissionTemplateTests.cs | 292 +- .../AppConfiguration/AppConfigurationTests.cs | 78 + .../FeatureFlag/FeatureFlagTests.cs | 3 + .../IdpConfigurationSelectorTests.cs | 176 + .../Parameter/ParameterIdEqualityTests.cs | 125 + .../Parameter/ParameterSoftDeleteTests.cs | 153 + .../Ums.Domain.Test/IGA/RiskScoreTests.cs | 37 + .../IGA/RoleMaturityStatusTests.cs | 239 ++ .../IGA/RolePromotionRequestTests.cs | 508 +++ .../Auth/IdpAuthOutcomeClassifierTests.cs | 135 + .../TenantParameter/TenantParameterTests.cs | 292 ++ .../Identity/Tenant/TenantTests.cs | 578 +-- .../TenantSignupRequestTests.cs | 115 + .../MfaEnrollment/MfaEnrollmentTests.cs | 91 + .../Identity/UserAccount/UserAccountTests.cs | 128 +- .../UserManagementDelegationTests.cs | 92 + .../Kernel/ValueObjects/ActionCodeTests.cs | 4 +- .../ValueObjects/ConfigurationKeyTests.cs | 4 +- .../ValueObjects/ConfigurationValueTests.cs | 4 +- .../Kernel/ValueObjects/CustomDomainTests.cs | 4 +- .../Kernel/ValueObjects/EmailTests.cs | 4 +- .../GenericStringValidatorTests.cs | 2 +- .../Kernel/ValueObjects/HexColorTests.cs | 4 +- .../Kernel/ValueObjects/IdValueObjectTests.cs | 29 - .../Kernel/ValueObjects/LoginTextTests.cs | 2 +- .../Kernel/ValueObjects/LogoTests.cs | 2 +- .../ValueObjects/TemplateVersionTests.cs | 26 + .../AccessEnforcementPolicy.cs | 27 +- .../AccessEnforcementPolicyProps.cs | 5 + .../ApprovalRequest/ApprovalRequest.cs | 16 +- .../ApprovalRequestDomainEventsManager.cs | 3 + .../ApprovalWorkflow/ApprovalWorkflow.cs | 7 +- .../ApprovalWorkflowDomainEventsManager.cs | 4 +- .../Events/DocumentTypeDomainEventsManager.cs | 3 + .../Approvals/RequiredDocumentChecklist.cs | 47 + .../Events/UserDocumentDomainEventsManager.cs | 3 + .../Approvals/UserDocument/UserDocument.cs | 6 + ...mplateAssignmentRuleDomainEventsManager.cs | 3 + .../Authorization/Graph/AuthorizationGraph.cs | 58 +- .../Authorization/Graph/GraphAccessState.cs | 30 + .../Authorization/Graph/GraphContext.cs | 44 +- .../Authorization/Graph/GraphMenuAccess.cs | 73 +- .../Authorization/Graph/GraphNavigation.cs | 36 + .../Graph/IAuthorizationGraphBuilder.cs | 17 + .../Events/ProfileDomainEventsManager.cs | 7 + .../Authorization/Profile/Profile.cs | 61 +- .../Authorization/Profile/ProfileProps.cs | 8 + .../Ums.Domain/Authorization/Repositories.cs | 69 + .../Role/Events/RoleDomainEventsManager.cs | 3 + .../SystemSuite/AppSetting/AppSetting.cs | 28 +- .../Events/SystemSuiteDomainEventsManager.cs | 4 + .../Authorization/SystemSuite/Menu/Menu.cs | 131 - .../SystemSuite/Menu/MenuProps.cs | 56 - .../SystemSuite/MenuNode/MenuNode.cs | 216 ++ .../SystemSuite/MenuNode/MenuNodeMetadata.cs | 84 + .../MenuNode/MenuNodePresentation.cs | 54 + .../SystemSuite/MenuNode/MenuNodeProps.cs | 95 + .../SystemSuite/MenuNode/NodeKind.cs | 13 + .../SystemSuite/Module/Module.cs | 153 +- .../SystemSuite/Module/ModuleProps.cs | 21 +- .../SystemSuite/Option/Option.cs | 50 - .../SystemSuite/Option/OptionProps.cs | 63 - .../SystemSuite/SubMenu/SubMenu.cs | 131 - .../SystemSuite/SubMenu/SubMenuProps.cs | 56 - .../Authorization/SystemSuite/SystemSuite.cs | 306 +- .../SystemSuite/SystemSuiteDependents.cs | 75 + .../SystemSuite/SystemSuitePage.cs | 24 + .../SystemSuite/SystemSuiteSummary.cs | 9 + .../PermissionTemplateDomainEventsManager.cs | 3 + .../Template/PermissionTemplate.cs | 177 +- .../AppConfiguration/AppConfiguration.cs | 33 + .../AppConfiguration/AppConfigurationCodes.cs | 11 + .../AppConfigurationDefaults.cs | 9 + .../AppConfigurationDomainEventsManager.cs | 4 + .../Events/FeatureFlagDomainEventsManager.cs | 3 + .../IdpConfigurationDomainEventsManager.cs | 3 + .../IdpConfigurationSelector.cs | 82 + .../Parameter/ParameterDefinition.cs | 50 +- .../Parameter/ParameterDefinitionProps.cs | 27 +- .../Parameter/ParameterGlobalValue.cs | 36 +- .../Parameter/ParameterTenantValue.cs | 36 +- .../Ums.Domain/Configuration/Repositories.cs | 57 +- .../Enums/BranchLifecycleEpisode.cs | 36 + .../ums.api/Ums.Domain/Enums/ConfigStatus.cs | 12 + .../Ums.Domain/Enums/PromotionStatus.cs | 14 - .../ums.api/Ums.Domain/Enums/SystemStatus.cs | 16 + .../Ums.Domain/Enums/TemplateStatus.cs | 17 + .../ums.api/Ums.Domain/Events/DomainEvents.cs | 41 +- .../Ums.Domain/Events/IIntegrationEvent.cs | 19 + .../RolePromotionExecutedIntegrationEvent.cs | 26 + .../ums.api/Ums.Domain/IGA/Repositories.cs | 39 + .../Events/RoleMaturityStatusEventsManager.cs | 18 + .../RoleMaturityStatus/RoleMaturityStatus.cs | 260 ++ .../RoleMaturityStatusProps.cs | 149 + .../RolePromotionRequestEventsManager.cs | 21 + .../RolePromotionRequest.cs | 390 ++ .../RolePromotionRequestProps.cs | 145 + .../RolePromotionStatus.cs | 22 + .../Identity/Auth/AuthAccessScope.cs | 21 +- .../Identity/Auth/IAuthMethodResolver.cs | 9 + .../Identity/Auth/IIdpChainAuthenticator.cs | 34 + .../Identity/Auth/IdpAuthOutcome.cs | 24 + .../Identity/Auth/IdpAuthOutcomeClassifier.cs | 91 + .../Ums.Domain/Identity/Repositories.cs | 29 +- .../Identity/Tenant/Branch/Branch.cs | 117 +- .../Tenant/Branch/BranchLifecycleEntry.cs | 41 + .../Identity/Tenant/Branch/BranchProps.cs | 23 + .../Events/TenantDomainEventsManager.cs | 9 +- .../Ums.Domain/Identity/Tenant/Tenant.cs | 156 +- .../TenantParameterDomainEventsManager.cs | 5 +- .../Tenant/TenantParameter/TenantParameter.cs | 51 + .../TenantParameter/TenantParameterProps.cs | 19 +- .../Ums.Domain/Identity/Tenant/TenantProps.cs | 26 + .../Events/UserAccountDomainEventsManager.cs | 3 + .../Identity/UserAccount/UserAccount.cs | 62 +- .../Identity/UserAccount/UserAccountProps.cs | 24 +- .../UserManagementDelegationEventsManager.cs | 3 + .../UserManagementDelegation.cs | 17 + .../ums.api/Ums.Domain/Kernel/DomainErrors.cs | 115 + .../Kernel/ValueObjects/EmailAddress.cs | 2 +- .../ValueObjects/EmailAddressValidator.cs | 3 +- .../Kernel/ValueObjects/RiskScore.cs | 35 + .../ValueObjects/RoleMaturityStatusId.cs | 9 + .../ValueObjects/RolePromotionRequestId.cs | 9 + .../Kernel/ValueObjects/TemplateVersion.cs | 36 +- .../Access/StringLocalizer.cs | 3 + .../Aop/AuditTrailChannelSink.cs | 13 - .../Aop/AuditTrailOutboxSink.cs | 62 + .../Aop/FactoryLoggingInterceptor.cs | 4 +- .../Aop/UmsSerilogLogger.cs | 175 +- .../NotificationRecipientStrategies.cs | 2 +- .../Graph/CsvAuthorizationGraphSerializer.cs | 71 +- .../Graph/JsonAuthorizationGraphSerializer.cs | 155 +- .../Graph/XmlAuthorizationGraphSerializer.cs | 53 +- .../Graph/YamlAuthorizationGraphSerializer.cs | 159 +- .../Configuration/CadenaDeRedis.cs | 43 + .../Configuration/ConfigurationLoader.cs | 5 +- .../Configuration/ConfigurationProvider.cs | 42 +- .../Configuration/FeatureFlagEvaluator.cs | 80 +- .../IdpResolution/IdpConfigurationResolver.cs | 38 +- .../InMemoryConfigurationCache.cs | 3 +- .../ParameterResolutionService.cs | 41 +- .../Configuration/RedisConfigurationCache.cs | 291 ++ .../Ums.Infrastructure/DependencyInjection.cs | 194 +- .../Ums.Infrastructure/GlobalUsings.cs | 2 +- .../AuditTrailPersistenceBackgroundService.cs | 84 - .../Hosting/AuditTrailPersistenceConsumer.cs | 67 + .../UserRevocationEventHandlers.cs | 40 +- .../Hosting/PersistenceRuntimeReporter.cs | 12 +- .../RolePromotionRoleAssignmentConsumer.cs | 122 + .../Identity/Auth/AuthAuditService.cs | 6 + .../Auth/IdpAuthAdapterFactorySetup.cs | 47 +- .../Identity/Auth/Oidc/HttpJwksProvider.cs | 83 + .../Identity/Auth/Oidc/HttpOidcTokenClient.cs | 112 + ...IdpConfigurationOidcProviderConfigStore.cs | 66 + .../Identity/Auth/Oidc/OidcAuthErrors.cs | 82 + .../Oidc/OidcAuthorizationRequestFactory.cs | 47 + .../Identity/Auth/Oidc/OidcBase64Url.cs | 26 + .../Auth/Oidc/OidcIdTokenValidator.cs | 239 ++ .../Identity/Auth/Oidc/OidcIdpAuthAdapter.cs | 137 + .../Identity/Auth/Oidc/OidcModels.cs | 78 + .../Identity/Auth/Oidc/OidcPkce.cs | 26 + .../Auth/Oidc/OidcProviderConfigParser.cs | 94 + .../Identity/Auth/Oidc/Ports.cs | 45 + .../MasterData/Contracts/TenantEvent.cs | 14 +- .../TenantProjectionDbContextFactory.cs | 2 + .../MassTransitIntegrationEventPublisher.cs | 24 + .../Observability/FunctionalTransaction.cs | 321 ++ .../Observability/ObservabilityConstants.cs | 100 + .../Observability/RequestContext.cs | 59 + .../TransactionLocatorFactory.cs | 87 + .../Entities/AccessEnforcementPolicyRecord.cs | 1 + ...greSqlAccessEnforcementPolicyRepository.cs | 2 + .../Audit/PostgreSqlAuditRecordRepository.cs | 28 +- ...rmissionTemplateItemRecordConfiguration.cs | 6 + .../ProfilePermissionRecordConfiguration.cs | 5 + .../ProfileRecordConfiguration.cs | 8 +- ...ystemSuiteAppSettingRecordConfiguration.cs | 4 + ...mSuiteDomainResourceRecordConfiguration.cs | 5 + .../SystemSuiteMenuRecordConfiguration.cs | 28 - .../SystemSuiteModuleRecordConfiguration.cs | 6 +- ...ystemSuiteNodeActionRecordConfiguration.cs | 19 + .../SystemSuiteNodeRecordConfiguration.cs | 53 + .../SystemSuiteOptionRecordConfiguration.cs | 24 - .../SystemSuiteSubMenuRecordConfiguration.cs | 28 - .../Entities/SystemSuiteAppSettingRecord.cs | 3 + .../Entities/SystemSuiteMenuRecord.cs | 19 - .../Entities/SystemSuiteModuleRecord.cs | 3 +- .../Entities/SystemSuiteNodeActionRecord.cs | 15 + .../Entities/SystemSuiteNodeRecord.cs | 45 + .../Entities/SystemSuiteOptionRecord.cs | 19 - .../Entities/SystemSuiteSubMenuRecord.cs | 19 - .../Exporters/ProfileCsvExporter.cs | 2 +- .../Exporters/ProfileExporterBase.cs | 32 +- .../Exporters/ProfileJsonExporter.cs | 20 +- .../Exporters/ProfileXmlExporter.cs | 8 +- .../Exporters/ProfileYamlExporter.cs | 6 +- .../PostgreSqlPermissionTemplateRepository.cs | 71 +- .../PostgreSqlProfileRepository.cs | 20 + .../Authorization/PostgreSqlRoleRepository.cs | 13 +- .../PostgreSqlSystemSuiteRepository.cs | 339 +- .../ConfigurationPersistenceConstants.cs | 18 + .../AppConfigurationRecordConfiguration.cs | 14 +- .../FeatureFlagRecordConfiguration.cs | 4 +- .../ParameterDefinitionRecordConfiguration.cs | 13 +- ...ParameterGlobalValueRecordConfiguration.cs | 9 +- ...ParameterTenantValueRecordConfiguration.cs | 8 +- .../Entities/ParameterDefinitionRecord.cs | 7 + .../PostgreSqlAppConfigurationRepository.cs | 39 +- .../PostgreSqlFeatureFlagRepository.cs | 13 + .../PostgreSqlParameterRepositories.cs | 228 +- .../Persistence/IDistributedLockProvider.cs | 2 +- .../PasswordResetTokenRecordConfiguration.cs | 25 + .../RefreshTokenRecordConfiguration.cs | 25 + ...BranchLifecycleEntryRecordConfiguration.cs | 28 + .../TenantBranchRecordConfiguration.cs | 19 + .../TenantParameterRecordConfiguration.cs | 10 + .../TenantRecordConfiguration.cs | 12 +- .../UserAccountRecordConfiguration.cs | 3 + .../Entities/PasswordResetTokenRecord.cs | 35 + .../Identity/Entities/RefreshTokenRecord.cs | 47 + .../TenantBranchLifecycleEntryRecord.cs | 33 + .../Identity/Entities/TenantBranchRecord.cs | 10 + .../Entities/TenantParameterRecord.cs | 5 + .../Identity/Entities/TenantRecord.cs | 6 + .../Identity/Entities/UserAccountRecord.cs | 4 + .../InMemoryTenantSignupRequestRepository.cs | 3 + .../Identity/PasswordResetTokenStore.cs | 126 + .../Identity/PostgreSqlTenantRepository.cs | 242 +- ...PostgreSqlTenantSignupRequestRepository.cs | 2 +- .../PostgreSqlUserAccountRepository.cs | 20 + .../Persistence/Identity/RefreshTokenStore.cs | 161 + .../InMemoryTenantParameterRepository.cs | 30 +- .../PostgreSqlTenantParameterRepository.cs | 33 +- .../RoleMaturityStatusRecordConfiguration.cs | 25 + ...RolePromotionRequestRecordConfiguration.cs | 23 + .../Iga/Entities/RoleMaturityStatusRecord.cs | 45 + .../Entities/RolePromotionRequestRecord.cs | 41 + .../Iga/IgaPersistenceConstants.cs | 10 + .../PostgreSqlRoleMaturityStatusRepository.cs | 175 + ...ostgreSqlRolePromotionRequestRepository.cs | 180 + ...MemoryAccessEnforcementPolicyRepository.cs | 11 +- .../InMemoryAppConfigurationRepository.cs | 42 +- .../InMemoryApprovalRequestRepository.cs | 11 +- .../InMemoryApprovalWorkflowRepository.cs | 11 +- .../InMemoryDocumentTypeRepository.cs | 11 +- .../InMemoryFeatureFlagRepository.cs | 7 + .../InMemoryIdpConfigurationRepository.cs | 3 + .../InMemoryNotificationRuleRepository.cs | 11 +- .../InMemoryParameterRepositories.cs | 109 +- .../InMemoryPermissionTemplateRepository.cs | 36 +- .../Persistence/InMemoryProfileRepository.cs | 19 + .../InMemoryRoleMaturityStatusRepository.cs | 67 + .../InMemoryRolePromotionRequestRepository.cs | 65 + .../Persistence/InMemoryRoleRepository.cs | 10 + .../InMemorySystemSuiteRepository.cs | 102 +- ...nMemoryTemplateAssignmentRuleRepository.cs | 3 + .../Persistence/InMemoryTenantRepository.cs | 37 +- .../InMemoryUserAccountRepository.cs | 9 + .../InMemoryUserDocumentRepository.cs | 11 +- ...emoryUserManagementDelegationRepository.cs | 3 + .../AuditAppendOnlyGuardInterceptor.cs | 89 + .../OrganizationDbContextInterceptor.cs | 118 +- ...0260716012015_AddRefreshTokens.Designer.cs | 2809 ++++++++++++++ .../20260716012015_AddRefreshTokens.cs | 71 + .../20260716030000_AddIgaTables.Designer.cs | 2964 +++++++++++++++ .../Migrations/20260716030000_AddIgaTables.cs | 127 + .../20260606225603_InitialPostgresCreate.cs | 2 + ...7033700_UpdatePostgresMassTransitOutbox.cs | 2 + .../PostgreSql/20260607040459_TestFinal.cs | 2 + .../20260607040745_UpdatePostgresFinal.cs | 2 + .../20260607041826_UpdatePostgresFinal3.cs | 2 + .../PostgreSql/20260607044419_SyncDrift.cs | 2 + .../20260607044815_AddPgCryptoExtension.cs | 2 + ...0260612204741_WidenApprovalWorkflowCode.cs | 2 + ...260612204858_PostgresRowVersionDefaults.cs | 2 + ..._AddSingleManagementOwnerIndex.Designer.cs | 2917 ++++++++++++++ ...714200354_AddSingleManagementOwnerIndex.cs | 33 + ...0715165202_AddSystemSuiteNodes.Designer.cs | 2966 +++++++++++++++ .../20260715165202_AddSystemSuiteNodes.cs | 116 + ...171139_ProjectHierarchyToNodes.Designer.cs | 2966 +++++++++++++++ .../20260715171139_ProjectHierarchyToNodes.cs | 82 + ...ystemSuiteMenusSubMenusOptions.Designer.cs | 2751 ++++++++++++++ ...938_DropSystemSuiteMenusSubMenusOptions.cs | 142 + ...717163004_AddUmsPlatformOutbox.Designer.cs | 3146 ++++++++++++++++ .../20260717163004_AddUmsPlatformOutbox.cs | 158 + ...0152552_EnableRowLevelSecurity.Designer.cs | 3146 ++++++++++++++++ .../20260720152552_EnableRowLevelSecurity.cs | 86 + ...4635_FixFeatureFlagUniqueIndex.Designer.cs | 3147 ++++++++++++++++ ...0260720154635_FixFeatureFlagUniqueIndex.cs | 45 + ...260721154054_AddAccountLockout.Designer.cs | 3155 ++++++++++++++++ .../20260721154054_AddAccountLockout.cs | 44 + ...35_AddTenantDefaultSystemSuite.Designer.cs | 3158 ++++++++++++++++ ...60722012535_AddTenantDefaultSystemSuite.cs | 31 + ...GracePeriodToEnforcementPolicy.Designer.cs | 3161 ++++++++++++++++ ...91208_AddGracePeriodToEnforcementPolicy.cs | 31 + ..._AddAuthorizationLookupIndexes.Designer.cs | 3173 ++++++++++++++++ ...801214202_AddAuthorizationLookupIndexes.cs | 63 + ..._AddAppSettingClientVisibility.Designer.cs | 3178 ++++++++++++++++ ...802002315_AddAppSettingClientVisibility.cs | 31 + ...004743_AddMenuNodePresentation.Designer.cs | 3186 ++++++++++++++++ .../20260802004743_AddMenuNodePresentation.cs | 44 + .../20260802014735_AddModuleIcon.Designer.cs | 3190 ++++++++++++++++ .../20260802014735_AddModuleIcon.cs | 31 + ...2060255_AddPasswordResetTokens.Designer.cs | 3237 ++++++++++++++++ .../20260802060255_AddPasswordResetTokens.cs | 62 + ...lsTenantComparisonTypeMismatch.Designer.cs | 3237 ++++++++++++++++ ...5513_FixRlsTenantComparisonTypeMismatch.cs | 144 + ...dParameterDefinitionSoftDelete.Designer.cs | 3249 ++++++++++++++++ ...161744_AddParameterDefinitionSoftDelete.cs | 68 + ...7_AddTenantParameterSoftDelete.Designer.cs | 3245 ++++++++++++++++ ...0804164227_AddTenantParameterSoftDelete.cs | 51 + ...ddBranchClosureAndLifecycleLog.Designer.cs | 3333 ++++++++++++++++ ...4194508_AddBranchClosureAndLifecycleLog.cs | 179 + ...rRanuraDeConfiguracionAlBorrar.Designer.cs | 3337 +++++++++++++++++ ...58_LiberarRanuraDeConfiguracionAlBorrar.cs | 136 + .../UmsPlatformDbContextModelSnapshot.cs | 903 ++++- .../Persistence/Options/PersistenceOptions.cs | 47 +- .../Options/PersistenceProvider.cs | 1 - .../PostgreSqlSchemaBootstrapper.cs | 73 - .../Reflection/ApprovalsAggregateFactory.cs | 6 + .../Reflection/AuditAggregateFactory.cs | 5 + .../AuthorizationAggregateFactory.cs | 144 +- .../ConfigurationAggregateFactory.cs | 9 +- .../Reflection/IdentityAggregateFactory.cs | 63 +- .../Reflection/IgaAggregateFactory.cs | 99 + .../Seeders/ApprovalsDevDataSeeder.cs | 56 + .../Persistence/Seeders/AuditDevDataSeeder.cs | 1 - .../Seeders/AuthorizationDevDataSeeder.cs | 1279 +++++-- .../Seeders/ConfigurationDevDataSeeder.cs | 106 +- .../Persistence/Seeders/CoreDevDataSeeder.cs | 108 + .../Seeders/IdentityDevDataSeeder.cs | 279 +- .../Persistence/Seeders/IgaDevDataSeeder.cs | 128 + .../Seeders/ParameterCatalogSeeder.cs | 147 +- .../Persistence/SqliteSchemaBootstrapper.cs | 118 - .../Persistence/UmsPlatformDbContext.cs | 201 +- .../UmsPlatformDbContextFactory.cs | 43 +- .../Persistence/UnitOfWorkScope.cs | 18 + .../LimitadorDePeticionesDistribuido.cs | 64 + .../LimitadorDePeticionesEnProceso.cs | 44 + .../Services/RequestContextAccessor.cs | 30 - .../Services/SessionRevocationStore.cs | 45 + .../Services/UserContext.cs | 15 +- .../Ums.Infrastructure.csproj | 3 +- .../NotificationRuleRestEndpointTests.cs | 8 +- .../AuditTrailOutboxSinkSanitizationTests.cs | 65 + .../AuditTrailPersistenceAntiTamperTests.cs | 375 ++ .../PermissionTemplateItemRetirementTests.cs | 278 ++ .../PermissionTemplateSoftDeleteTests.cs | 356 ++ .../Authorization/ProfileRestEndpointTests.cs | 61 + .../AppConfigurationRanuraLiberadaE2ETests.cs | 172 + .../AppConfigurationRestEndpointTests.cs | 167 + .../ConfigurationRestEndpointTests.cs | 37 + .../ConfigurationSecretExposureE2ETests.cs | 350 ++ .../ConfigurationSeedCompletenessTests.cs | 9 +- .../ParameterDefinitionSoftDeleteE2ETests.cs | 451 +++ .../E2E/AccessEnforcementPolicyE2ETests.cs | 13 +- .../E2E/IntegridadYResultPatternE2ETests.cs | 188 + .../E2E/RoleE2ETests.cs | 66 +- .../E2E/SystemSuiteE2ETests.cs | 162 +- .../E2E/SystemSuiteLogicalDeletionE2ETests.cs | 483 +++ .../E2E/TenantE2ETests.cs | 182 +- .../E2E/UserAccountE2ETests.cs | 118 +- .../GlobalUsings.cs | 2 +- .../Oidc/Fr042DbBackedRealIdpChainTests.cs | 560 +++ .../Auth/Oidc/KeycloakCollectionDefinition.cs | 11 + .../Auth/Oidc/KeycloakContainerFixture.cs | 123 + .../Auth/Oidc/KeycloakOidcRealHarnessTests.cs | 408 ++ .../PostgresKeycloakCollectionDefinition.cs | 12 + .../Auth/Oidc/PostgresKeycloakFixture.cs | 76 + .../Identity/Auth/Oidc/beyondnet-realm.json | 63 + .../Identity/AuthEndpointRoutingTests.cs | 20 + .../Identity/BranchClosureTests.cs | 344 ++ .../DelegationApprovalGateBehavioralTests.cs | 106 + .../DependencyGuardIntegrationTests.cs | 12 +- .../Identity/RefreshTokenStoreTests.cs | 400 ++ .../TenantParameterSoftDeleteTests.cs | 231 ++ .../Identity/UserAccountRestEndpointTests.cs | 5 + .../Iga/RolePromotionEffectE2ETests.cs | 218 ++ .../Iga/RolePromotionLifecycleE2ETests.cs | 489 +++ .../RolePromotionRequestRestEndpointTests.cs | 303 ++ ...olePromotionRoleAssignmentConsumerTests.cs | 106 + ...thorizationConfigurationRepositoryTests.cs | 6 +- .../Infrastructure/EntityRepositoryTests.cs | 4 +- .../Infrastructure/FakeFeatureFlagHandler.cs | 1 + ...PostgreSqlAuthorizationPersistenceTests.cs | 129 +- .../PostgreSqlContainerFixture.cs | 8 +- .../PostgreSqlUserAccountRepositoryTests.cs | 2 +- .../PostgreSqlWebApplicationFactory.cs | 87 +- .../RequestContextAccessorTests.cs | 57 - .../Infrastructure/RequestContextTests.cs | 82 + .../SqliteSchemaBootstrapperTests.cs | 162 - .../Infrastructure/TestAuthHandler.cs | 13 +- .../UmsApiServiceBootstrappersTests.cs | 31 +- .../UmsApiWebApplicationFactory.cs | 103 +- .../Infrastructure/UmsSerilogLoggerTests.cs | 50 +- .../DomainErrorStatusMapperTests.cs | 89 + ...OutboxBusUnavailabilityIntegrationTests.cs | 252 ++ .../ReliabilityIntegrationTests.cs | 97 +- .../Security/AutenticacionPorPortadorTests.cs | 284 ++ .../Security/AuthenticationFlowTests.cs | 183 +- .../CierreDeSesionPorDispositivoTests.cs | 105 + .../ClientAuthenticationBehavioralTests.cs | 90 + .../ContratoDeClientAuthenticateTests.cs | 220 ++ .../Security/LimiteDePeticionesTests.cs | 109 + .../Security/NormalizacionRedisTests.cs | 49 + .../Security/RefreshTokenPerTenantE2ETests.cs | 169 + .../Security/TenantIsolationTests.cs | 2 +- .../Ums.Presentation.IntegrationTest.csproj | 6 + .../LanguageHeaderOperationFilter.cs | 25 + .../UmsApiServiceBootstrappers.cs | 282 +- src/apps/ums.api/Ums.Presentation/Dockerfile | 47 - .../Audit/AuditRecord/AuditRecordEndpoints.cs | 5 +- .../Queries/AuditRecordQueryEndpoints.cs | 4 +- .../SystemSuite/SystemSuiteEndpoints.cs | 190 +- .../Template/PermissionTemplateEndpoints.cs | 15 +- .../AppConfigurationEndpoints.cs | 41 + .../Parameter/ParameterEndpoints.cs | 54 + .../Queries/ParameterCatalogQueryEndpoints.cs | 7 +- .../Endpoints/Identity/Auth/AuthEndpoints.cs | 569 ++- .../Identity/Auth/ClientAuthEndpoints.cs | 424 ++- .../Endpoints/Identity/Auth/JwksEndpoints.cs | 73 + .../Tenant/Queries/BranchQueryEndpoints.cs | 33 +- .../Tenant/Queries/TenantQueryEndpoints.cs | 4 +- .../Identity/Tenant/TenantBranchEndpoints.cs | 29 +- .../Identity/Tenant/TenantEndpoints.cs | 28 +- .../Queries/UserAccountQueryEndpoints.cs | 4 +- .../UserAccount/UserAccountEndpoints.cs | 25 +- .../DelegationEndpoints.cs | 41 +- .../Queries/DelegationQueryEndpoints.cs | 7 +- .../RoleMaturityStatusQueryEndpoints.cs | 37 + .../RolePromotionRequestQueryEndpoints.cs | 47 + .../RolePromotionRequestEndpoints.cs | 148 + .../Endpoints/PactProviderStateEndpoints.cs | 198 +- .../Extensions/AuthenticationExtensions.cs | 371 +- .../Extensions/BlockedOperationResponse.cs | 25 +- .../Extensions/DomainErrorStatusMapper.cs | 88 +- .../Extensions/LoggingExtensions.cs | 6 +- .../Extensions/ObservabilityExtensions.cs | 23 +- .../Extensions/PiiMaskingPolicy.cs | 3 + .../Extensions/ResultExtensions.cs | 15 +- .../ums.api/Ums.Presentation/GlobalUsings.cs | 2 +- .../AccessEnforcementPolicyQueries.cs | 4 +- .../Approvals/ApprovalRequestQueries.cs | 4 +- .../Approvals/ApprovalWorkflowQueries.cs | 4 +- .../GraphQL/Approvals/DocumentTypeQueries.cs | 4 +- .../Approvals/NotificationRuleQueries.cs | 4 +- .../GraphQL/Approvals/UserDocumentQueries.cs | 4 +- .../GraphQL/Audit/AuditRecordQueries.cs | 4 +- .../PermissionTemplateQueries.cs | 4 +- .../GraphQL/Authorization/ProfileQueries.cs | 4 +- .../GraphQL/Authorization/RoleQueries.cs | 2 +- .../Authorization/SystemSuiteQueries.cs | 4 +- .../Configuration/AppConfigurationQueries.cs | 4 +- .../Configuration/FeatureFlagQueries.cs | 4 +- .../Configuration/IdpConfigurationQueries.cs | 6 +- .../GraphQL/Identity/DelegationQueries.cs | 8 +- .../GraphQL/Identity/TenantQueries.cs | 10 +- .../GraphQL/Identity/UserAccountQueries.cs | 4 +- .../Middleware/CorrelationIdMiddleware.cs | 78 - .../Middleware/CultureMiddleware.cs | 6 +- .../Middleware/DevAuthMiddleware.cs | 52 +- .../FunctionalTransactionMiddleware.cs | 111 + .../Middleware/GlobalExceptionHandler.cs | 55 +- .../Middleware/IdempotencyMiddleware.cs | 56 +- .../LimiteDePeticionesMiddleware.cs | 126 + .../Middleware/SessionTrackingMiddleware.cs | 20 +- .../Middleware/TokenRevocationMiddleware.cs | 26 +- .../Observability/ActivityTraceEnricher.cs | 28 + src/apps/ums.api/Ums.Presentation/Program.cs | 29 +- .../Services/JwtTokenService.cs | 139 +- .../Services/MaterialDeFirma.cs | 178 + .../Ums.Presentation/Ums.Presentation.csproj | 10 +- .../appsettings.Development.json | 7 +- .../appsettings.Production.json | 7 +- .../Ums.Presentation/appsettings.UAT.json | 6 +- .../ums.api/Ums.Presentation/appsettings.json | 2 +- src/apps/ums.api/Ums.Presentation/umsdev.db | Bin 1716224 -> 0 bytes .../ums.api/Ums.Presentation/umsdev.db.backup | Bin 1372160 -> 0 bytes .../umsdev.db.pre-fs12-cleanup-20260604 | Bin 1486848 -> 0 bytes .../20260607025649_InitReadModels.Designer.cs | 120 + .../20260607025649_InitReadModels.cs | 83 + .../ReadModelDbContextModelSnapshot.cs | 117 + .../Models/PermissionTemplateItemReadModel.cs | 13 + .../Models/PermissionTemplateReadModel.cs | 16 + .../PermissionTemplateProjectionHandler.cs | 78 + .../Ums.ReadModels/ReadModelDbContext.cs | 43 + .../ReadModelDbContextFactory.cs | 19 + .../Ums.ReadModels/Ums.ReadModels.csproj | 22 + src/apps/ums.api/Ums.sln | 31 +- src/apps/ums.api/Ums.slnx | 12 - 762 files changed, 116542 insertions(+), 6655 deletions(-) create mode 100644 src/.editorconfig create mode 100644 src/Directory.Build.props create mode 100644 src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/RecordAuditCommandValidatorTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AppSettingVisibilityTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthGraphPayloadTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Authorization/Template/AddTemplateItemCommandValidatorTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Common/Aop/AuditMetadataSanitizerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Common/Aop/TenantValidationAspectTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Common/Reliability/IntegrationEventOutboxDispatchTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Configuration/AvisoDeConfiguracionEntreReplicasTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Configuration/Parameter/DeleteParameterDefinitionCommandHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Configuration/Services/ConfigurationAuditServiceTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/IGA/HeuristicRiskScoreCalculatorTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionCommandHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionQueryHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverSuiteSourceTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthenticateUserCommandHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/ForgotPasswordCommandHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/IdpChainAuthenticatorTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/HttpOidcTokenClientTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcAuthorizationRequestFactoryTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdTokenValidatorTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdpAuthAdapterTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcProviderConfigParserTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcTestSupport.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshAuthenticationCommandHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshSessionCommandHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/ResetPasswordCommandHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Identity/Auth/SwitchProfileCommandHandlerTests.cs create mode 100644 src/apps/ums.api/Ums.Application.Test/Observability/FunctionalTransactionTests.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/Graph/GraphMetrics.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/Graph/Serializers/AuthGraphPayload.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandHandler.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandValidator.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddNodeCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandHandler.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandValidator.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandValidator.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/NodeActionCommands.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommandHandler.cs rename src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/{UpdateMenuCommandHandler.cs => RemoveNodeCommand.cs} (59%) delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommandHandler.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeMetadataCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeStatusCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandValidator.cs rename src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/{AddSubMenuCommandHandler.cs => UpdateNodeCommand.cs} (55%) delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandHandler.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandValidator.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandHandler.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandValidator.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddDomainResourceResponse.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddModuleResponse.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddNodeResponse.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/RegisterActionResponse.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Authorization/Template/DTOs/AddTemplateItemResponse.cs create mode 100644 src/apps/ums.api/Ums.Application/Common/Aop/AuditMetadataSanitizer.cs create mode 100644 src/apps/ums.api/Ums.Application/Common/Interfaces/IFunctionalTransaction.cs create mode 100644 src/apps/ums.api/Ums.Application/Common/Interfaces/IIntegrationEventPublisher.cs create mode 100644 src/apps/ums.api/Ums.Application/Common/Interfaces/ILimitadorDePeticiones.cs create mode 100644 src/apps/ums.api/Ums.Application/Common/Interfaces/ISessionRevocationStore.cs create mode 100644 src/apps/ums.api/Ums.Application/Common/Interfaces/ITransactionLocatorFactory.cs create mode 100644 src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/Common/IgaHandlerGuards.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/DTOs/CreateRolePromotionRequestResponse.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/DTOs/RoleMaturityStatusDto.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionMapper.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionRequestDto.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQuery.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQueryHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQuery.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQueryHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQuery.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQueryHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/Services/HeuristicRiskScoreCalculator.cs create mode 100644 src/apps/ums.api/Ums.Application/IGA/Services/IRiskScoreCalculator.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandValidator.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/Commands/SwitchProfileCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/IPasswordResetTokenStore.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenPolicyProvider.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenStore.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/IResponseTimingNormalizer.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/IdpChainAuthenticator.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/PasswordResetToken.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenGenerator.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenHasher.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicy.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicyProvider.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenStatuses.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandValidator.cs delete mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommand.cs delete mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandValidator.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommand.cs rename src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/{RemoveBranchCommandHandler.cs => UpdateBranchCommandHandler.cs} (62%) create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchLifecycleEntryDto.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/CloseBranchResponse.cs delete mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/RemoveBranchResponse.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQuery.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQueryHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandValidator.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandValidator.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommand.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandHandler.cs create mode 100644 src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandValidator.cs create mode 100644 src/apps/ums.api/Ums.ContractTest/Consumers/ApprovalsConsumerTests.cs create mode 100644 src/apps/ums.api/Ums.ContractTest/Consumers/AuditConsumerTests.cs create mode 100644 src/apps/ums.api/Ums.ContractTest/Consumers/ConfigurationConsumerTests.cs create mode 100644 src/apps/ums.api/Ums.ContractTest/Consumers/IgaConsumerTests.cs create mode 100644 src/apps/ums.api/Ums.ContractTest/Infrastructure/ProductionContractTestWebApplicationFactory.cs create mode 100644 src/apps/ums.api/Ums.ContractTest/Provider/ProviderStateEndpointGuardTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Approvals/RequiredDocumentChecklistTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/MenuNode/MenuNodeMetadataEqualityTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/PresentacionDeNavegacionTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Configuration/IdpConfiguration/IdpConfigurationSelectorTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterIdEqualityTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterSoftDeleteTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/IGA/RiskScoreTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/IGA/RoleMaturityStatusTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/IGA/RolePromotionRequestTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Identity/Auth/IdpAuthOutcomeClassifierTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantParameter/TenantParameterTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Identity/TenantSignupRequest/TenantSignupRequestTests.cs create mode 100644 src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/MfaEnrollment/MfaEnrollmentTests.cs create mode 100644 src/apps/ums.api/Ums.Domain/Approvals/RequiredDocumentChecklist.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphAccessState.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphNavigation.cs delete mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/Menu.cs delete mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/MenuProps.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNode.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeMetadata.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodePresentation.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeProps.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/NodeKind.cs delete mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Option/Option.cs delete mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Option/OptionProps.cs delete mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/SubMenu/SubMenu.cs delete mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/SubMenu/SubMenuProps.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/SystemSuiteDependents.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/SystemSuitePage.cs create mode 100644 src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/SystemSuiteSummary.cs create mode 100644 src/apps/ums.api/Ums.Domain/Configuration/IdpConfiguration/IdpConfigurationSelector.cs create mode 100644 src/apps/ums.api/Ums.Domain/Enums/BranchLifecycleEpisode.cs delete mode 100644 src/apps/ums.api/Ums.Domain/Enums/PromotionStatus.cs create mode 100644 src/apps/ums.api/Ums.Domain/Events/IIntegrationEvent.cs create mode 100644 src/apps/ums.api/Ums.Domain/Events/RolePromotionExecutedIntegrationEvent.cs create mode 100644 src/apps/ums.api/Ums.Domain/IGA/Repositories.cs create mode 100644 src/apps/ums.api/Ums.Domain/IGA/RoleMaturityStatus/Events/RoleMaturityStatusEventsManager.cs create mode 100644 src/apps/ums.api/Ums.Domain/IGA/RoleMaturityStatus/RoleMaturityStatus.cs create mode 100644 src/apps/ums.api/Ums.Domain/IGA/RoleMaturityStatus/RoleMaturityStatusProps.cs create mode 100644 src/apps/ums.api/Ums.Domain/IGA/RolePromotionRequest/Events/RolePromotionRequestEventsManager.cs create mode 100644 src/apps/ums.api/Ums.Domain/IGA/RolePromotionRequest/RolePromotionRequest.cs create mode 100644 src/apps/ums.api/Ums.Domain/IGA/RolePromotionRequest/RolePromotionRequestProps.cs create mode 100644 src/apps/ums.api/Ums.Domain/IGA/RolePromotionRequest/RolePromotionStatus.cs create mode 100644 src/apps/ums.api/Ums.Domain/Identity/Auth/IIdpChainAuthenticator.cs create mode 100644 src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcome.cs create mode 100644 src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcomeClassifier.cs create mode 100644 src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchLifecycleEntry.cs create mode 100644 src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RiskScore.cs create mode 100644 src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RoleMaturityStatusId.cs create mode 100644 src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RolePromotionRequestId.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailChannelSink.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailOutboxSink.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Configuration/CadenaDeRedis.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Configuration/RedisConfigurationCache.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceBackgroundService.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceConsumer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Hosting/RolePromotionRoleAssignmentConsumer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpJwksProvider.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpOidcTokenClient.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/IdpConfigurationOidcProviderConfigStore.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthErrors.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthorizationRequestFactory.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcBase64Url.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdTokenValidator.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdpAuthAdapter.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcModels.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcPkce.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcProviderConfigParser.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/Ports.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Messaging/MassTransitIntegrationEventPublisher.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Observability/FunctionalTransaction.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Observability/ObservabilityConstants.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Observability/RequestContext.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Observability/TransactionLocatorFactory.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteMenuRecordConfiguration.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeActionRecordConfiguration.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeRecordConfiguration.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteOptionRecordConfiguration.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteSubMenuRecordConfiguration.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteMenuRecord.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeActionRecord.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeRecord.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteOptionRecord.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteSubMenuRecord.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/PasswordResetTokenRecordConfiguration.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/RefreshTokenRecordConfiguration.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchLifecycleEntryRecordConfiguration.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/PasswordResetTokenRecord.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/RefreshTokenRecord.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchLifecycleEntryRecord.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PasswordResetTokenStore.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/RefreshTokenStore.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RoleMaturityStatusRecordConfiguration.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RolePromotionRequestRecordConfiguration.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RoleMaturityStatusRecord.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RolePromotionRequestRecord.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/IgaPersistenceConstants.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRoleMaturityStatusRepository.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRolePromotionRequestRepository.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleMaturityStatusRepository.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRolePromotionRequestRepository.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/AuditAppendOnlyGuardInterceptor.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.Designer.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/PostgreSqlSchemaBootstrapper.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IgaAggregateFactory.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IgaDevDataSeeder.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/SqliteSchemaBootstrapper.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesDistribuido.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesEnProceso.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Services/RequestContextAccessor.cs create mode 100644 src/apps/ums.api/Ums.Infrastructure/Services/SessionRevocationStore.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailOutboxSinkSanitizationTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailPersistenceAntiTamperTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateItemRetirementTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateSoftDeleteTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRanuraLiberadaE2ETests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSecretExposureE2ETests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ParameterDefinitionSoftDeleteE2ETests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/IntegridadYResultPatternE2ETests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteLogicalDeletionE2ETests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/Fr042DbBackedRealIdpChainTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakCollectionDefinition.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakContainerFixture.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakOidcRealHarnessTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakCollectionDefinition.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakFixture.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/beyondnet-realm.json create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/BranchClosureTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DelegationApprovalGateBehavioralTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/RefreshTokenStoreTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantParameterSoftDeleteTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionEffectE2ETests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionLifecycleE2ETests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRequestRestEndpointTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRoleAssignmentConsumerTests.cs delete mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextAccessorTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextTests.cs delete mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/SqliteSchemaBootstrapperTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Presentation/DomainErrorStatusMapperTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/OutboxBusUnavailabilityIntegrationTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AutenticacionPorPortadorTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/CierreDeSesionPorDispositivoTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ClientAuthenticationBehavioralTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ContratoDeClientAuthenticateTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/LimiteDePeticionesTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/NormalizacionRedisTests.cs create mode 100644 src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/RefreshTokenPerTenantE2ETests.cs create mode 100644 src/apps/ums.api/Ums.Presentation/Bootstrapping/LanguageHeaderOperationFilter.cs delete mode 100644 src/apps/ums.api/Ums.Presentation/Dockerfile create mode 100644 src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/JwksEndpoints.cs create mode 100644 src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RoleMaturityStatus/Queries/RoleMaturityStatusQueryEndpoints.cs create mode 100644 src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/Queries/RolePromotionRequestQueryEndpoints.cs create mode 100644 src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/RolePromotionRequestEndpoints.cs delete mode 100644 src/apps/ums.api/Ums.Presentation/Middleware/CorrelationIdMiddleware.cs create mode 100644 src/apps/ums.api/Ums.Presentation/Middleware/FunctionalTransactionMiddleware.cs create mode 100644 src/apps/ums.api/Ums.Presentation/Middleware/LimiteDePeticionesMiddleware.cs create mode 100644 src/apps/ums.api/Ums.Presentation/Observability/ActivityTraceEnricher.cs create mode 100644 src/apps/ums.api/Ums.Presentation/Services/MaterialDeFirma.cs delete mode 100644 src/apps/ums.api/Ums.Presentation/umsdev.db delete mode 100644 src/apps/ums.api/Ums.Presentation/umsdev.db.backup delete mode 100644 src/apps/ums.api/Ums.Presentation/umsdev.db.pre-fs12-cleanup-20260604 create mode 100644 src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.Designer.cs create mode 100644 src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs create mode 100644 src/apps/ums.api/Ums.ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs create mode 100644 src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateItemReadModel.cs create mode 100644 src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateReadModel.cs create mode 100644 src/apps/ums.api/Ums.ReadModels/Projections/PermissionTemplateProjectionHandler.cs create mode 100644 src/apps/ums.api/Ums.ReadModels/ReadModelDbContext.cs create mode 100644 src/apps/ums.api/Ums.ReadModels/ReadModelDbContextFactory.cs create mode 100644 src/apps/ums.api/Ums.ReadModels/Ums.ReadModels.csproj delete mode 100644 src/apps/ums.api/Ums.slnx diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 00000000..cd219ef5 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,117 @@ +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{cs,csx}] +indent_size = 4 +tab_width = 4 + +# Organizar usings: primero System, luego el resto +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Preferir var cuando el tipo es evidente +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Expresiones compactas (expression-bodied) +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = false:suggestion + +# Null-checking moderno +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion + +# Modificadores de acceso explícitos +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning + +# Llaves en bloques de control +csharp_prefer_braces = when_multiline:suggestion + +# Nuevas líneas +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +# Espaciado +csharp_space_after_cast = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after + +# Convención de nombres: campos privados con prefijo _ +dotnet_naming_rule.private_fields_underscore.severity = suggestion +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = camel_case_underscore_style + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Constantes en PascalCase +dotnet_naming_rule.constants_pascal_case.severity = suggestion +dotnet_naming_rule.constants_pascal_case.symbols = constants +dotnet_naming_rule.constants_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.{xml,csproj,props,targets}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +dotnet_diagnostic.S1481.severity = error # variables locales sin usar +dotnet_diagnostic.S1172.severity = error # parámetros de método sin usar +dotnet_diagnostic.S2325.severity = error # miembros que pueden ser static +dotnet_diagnostic.S1144.severity = error # miembros privados sin usar +dotnet_diagnostic.S1186.severity = error # métodos vacíos +dotnet_diagnostic.S125.severity = error # código comentado +dotnet_diagnostic.S3358.severity = error # ternarios anidados +dotnet_diagnostic.S927.severity = error # nombres de parámetros coincidentes +dotnet_diagnostic.S4144.severity = error # implementaciones idénticas +dotnet_diagnostic.S6562.severity = error # DateTimeKind explícito al construir DateTime +dotnet_diagnostic.S3973.severity = error # llaves/indentación en bloques condicionales +dotnet_diagnostic.S6608.severity = error # indexación [0]/[^1] en lugar de First()/Last() +dotnet_diagnostic.S3400.severity = error # constante en lugar de método que devuelve un literal +dotnet_diagnostic.S6966.severity = error # await de la variante *Async (RunAsync/CancelAsync) +dotnet_diagnostic.S6444.severity = error # timeout explícito al construir Regex +dotnet_diagnostic.S6580.severity = error # IFormatProvider explícito al parsear fecha/hora +dotnet_diagnostic.S4487.severity = error # campos privados asignados pero nunca leídos +dotnet_diagnostic.S3928.severity = error # paramName válido en ArgumentException/ArgumentNullException +dotnet_diagnostic.S3903.severity = error # tipos en un namespace con nombre, no en el global +dotnet_diagnostic.S3458.severity = error # sin cláusulas 'case'/'default' vacías redundantes +dotnet_diagnostic.S3260.severity = error # clases privadas no derivadas marcadas como 'sealed' +dotnet_diagnostic.S4136.severity = error # sobrecargas de método adyacentes +dotnet_diagnostic.S2068.severity = error # credenciales hardcodeadas (excepciones: supresión justificada) +dotnet_diagnostic.S2077.severity = error # consultas parametrizadas, no formato de cadena (excep.: supresión justif.) +dotnet_diagnostic.S3011.severity = error # bypass de accesibilidad por reflexión: sancionado y suprimido con cita a ADR-UMS-099; un uso NUEVO sin suprimir rompe el build + +# S1135 (comentarios TODO) NO se gatea, y no es un olvido. +# El analizador casa «TODO» SIN distinguir mayúsculas, así que dispara sobre la palabra +# española «todo» en prosa corriente: 11 de los 17 hallazgos de la Tranche 6 eran comentarios +# como «y todo su subárbol» o «única en todo el sistema». Elevarlo a error obligaría a +# contorsionar el castellano para satisfacer a un analizador anglocéntrico, lo que choca de +# frente con SD-08 (documentación exclusivamente en español). El analizador Roslyn no expone +# configuración de la lista de marcadores, así que se queda como aviso no bloqueante y los +# TODO reales se gobiernan por convención: TODO(D-016) y TODO(G-NNN) trazan a DECISIONS.md +# y GAPS.md. Un TODO sin identificador de registro es la deuda invisible que SD-07 prohíbe. diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 00000000..0668e47a --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/apps/ums.api/Dockerfile b/src/apps/ums.api/Dockerfile index 60762559..bcd27160 100644 --- a/src/apps/ums.api/Dockerfile +++ b/src/apps/ums.api/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # ────────────────────────────────────────────────────────────────────────── # Evolith UMS API (.NET 10) — multi-stage build. # @@ -6,6 +7,9 @@ # so the context must be `src/`, not apps/ums.api. # docker build -f apps/ums.api/Dockerfile -t evolith-ums-api:local src/ # +# NuGet: the BeyondNetCode.Shell.* shells resolve from nuget.org (see NuGet.Config). +# This satellite needs no private feed nor build-time credentials. +# # Entry point: apps/ums.api/Ums.Presentation. Kestrel on :8080 (aspnet default). # ────────────────────────────────────────────────────────────────────────── FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs index cc085648..a6d9b1f8 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs @@ -231,5 +231,28 @@ public async Task UpdateAction_WithInvalidAction_ReturnsFailure() Assert.Contains("Invalid action", result.Error, StringComparison.OrdinalIgnoreCase); } + // Irreversibilidad (G-051): el handler debe propagar el rechazo del dominio cuando se + // intenta actualizar una politica ya desactivada (estado terminal) y NO debe persistir. + // No debilitar: si este test empieza a esperar exito, la invariante de irreversibilidad + // se habra roto de nuevo. + [Fact] + public async Task UpdateAction_WhenPolicyInactive_ReturnsFailureAndDoesNotPersist() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var policy = MakePolicy(); + policy.Deactivate(ActorId.Create("user-001")); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(policy); + + var cmd = new UpdateAccessEnforcementActionCommand(policy.Props.Id.GetValue(), "RestrictProfile"); + var handler = new UpdateAccessEnforcementActionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.PolicyInactiveCannotUpdate, result.Error); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestCommandHandlerTests.cs index 4d12eeca..e3772938 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestCommandHandlerTests.cs @@ -17,6 +17,7 @@ namespace Ums.Application.Test.Approvals.ApprovalRequest; using Ums.Domain.Kernel; using Ums.Domain.Kernel.ValueObjects; using ApprovalWorkflowAggregate = Ums.Domain.Approvals.ApprovalWorkflow.ApprovalWorkflow; +using UserDocumentAggregate = Ums.Domain.Approvals.UserDocument.UserDocument; using Moq; using Xunit; using System; @@ -28,6 +29,7 @@ public class ApprovalRequestCommandHandlerTests private readonly Mock _repo = new(); private readonly Mock _profileRepo = new(); private readonly Mock _workflowRepo = new(); + private readonly Mock _userDocumentRepo = new(); private readonly Mock _creationPolicyResolver = new(); private readonly Mock _userAccountRepo = new(); private readonly Mock _tenantRepo = new(); @@ -58,16 +60,30 @@ public ApprovalRequestCommandHandlerTests() _unitOfWorkScope.Setup(u => u.BeginAsync(It.IsAny())).ReturnsAsync(_transactionScope.Object); _transactionScope.Setup(t => t.CommitAsync(It.IsAny())).Returns(Task.CompletedTask); _transactionScope.Setup(t => t.RollbackAsync(It.IsAny())).Returns(Task.CompletedTask); + // G-117: el fake ejecuta la operación (como la ExecutionStrategy real hace begin+commit); sin + // esto Moq devolvería una tarea vacía y los saves del bloque transaccional no correrían. + _unitOfWorkScope + .Setup(u => u.ExecuteInTransactionAsync(It.IsAny>(), It.IsAny())) + .Returns((Func op, CancellationToken ct) => op(ct)); _ctx.Setup(u => u.UserId).Returns("user-001"); + + // Por defecto el checklist de documentos requeridos se satisface: workflow sin documentos + // obligatorios y sin documentos del usuario. Los tests de G-051 F4 sobrescriben esto. + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflow()); + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); } - private static ApprovalRequest MakeApprovalRequest(ProfileId? profileId = null) => + // G-119 (SoD): el creador es "requester-001", distinto del aprobador ("user-001", _ctx.UserId), + // para que el happy-path de approve no viole la segregación de deberes. + private static ApprovalRequest MakeApprovalRequest(ProfileId? profileId = null, string createdBy = "requester-001") => ApprovalRequest.Create( ApprovalWorkflowId.Load(Guid.NewGuid()), UserId.Load(Guid.NewGuid()), profileId, ValidSystemId, null, ValidRoleId, null, - ActorId.Create("user-001")).Value; + ActorId.Create(createdBy)).Value; private static ApprovalWorkflowAggregate MakeWorkflow(bool requiresApproval = true, UserCategory? category = null) => ApprovalWorkflowAggregate.Create( @@ -105,7 +121,7 @@ private static UserAccount MakeInternalUser() return user; } - private Domain.Identity.Tenant.Tenant MakeTenant() => + private static Domain.Identity.Tenant.Tenant MakeTenant() => Domain.Identity.Tenant.Tenant.Create( Code.Create("CORP"), Name.Create("Corp Inc"), OrganizationType.INTERNAL, ActorId.Create("sys"), @@ -115,7 +131,7 @@ private CreateApprovalRequestCommandHandler CreateHandler() => new(_repo.Object, _workflowRepo.Object, _creationPolicyResolver.Object, _userAccountRepo.Object, _ctx.Object); private ApproveRequestCommandHandler CreateApproveHandler() => - new(_repo.Object, _profileRepo.Object, _userAccountRepo.Object, _tenantRepo.Object, _delegationRepo.Object, _tenantScopePolicy.Object, _unitOfWorkScope.Object, _notifications.Object, _roleRepo.Object, _ctx.Object); + new(_repo.Object, _profileRepo.Object, _workflowRepo.Object, _userDocumentRepo.Object, _userAccountRepo.Object, _tenantRepo.Object, _delegationRepo.Object, _tenantScopePolicy.Object, _unitOfWorkScope.Object, _notifications.Object, _roleRepo.Object, _ctx.Object); // G-160: por defecto el repo de roles resuelve un rol válido, para que las aprobaciones happy-path // no fallen por la nueva guarda de existencia del rol concedido. @@ -126,6 +142,39 @@ private static RoleAggregate MakeRole() => Code.Create("ROLE_TEST"), Name.Create("Rol de Prueba"), Description.Create("rol"), null, 0, 0, ActorId.Create("sys")).Value; + private static ApprovalWorkflowAggregate MakeWorkflowRequiring(DocumentTypeId documentTypeId, bool isMandatory = true) + { + var workflow = MakeWorkflow(); + workflow.AddRequiredDocument(documentTypeId, isMandatory, ActorId.Create("user-001")); + return workflow; + } + + private static UserDocumentAggregate MakeValidDocument(DocumentTypeId documentTypeId) + { + var document = UserDocumentAggregate.Upload( + UserId.Load(Guid.NewGuid()), + documentTypeId, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + DocumentCriticity.High, + TextValueObject.Create("/storage/doc.pdf"), + "checksum-001", + ActorId.Create("user-001")).Value; + document.Validate(ActorId.Create("user-001")); + return document; + } + + private static UserDocumentAggregate MakePendingDocument(DocumentTypeId documentTypeId) => + UserDocumentAggregate.Upload( + UserId.Load(Guid.NewGuid()), + documentTypeId, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + DocumentCriticity.High, + TextValueObject.Create("/storage/doc.pdf"), + "checksum-002", + ActorId.Create("user-001")).Value; + private RejectRequestCommandHandler CreateRejectHandler() => new(_repo.Object, _userAccountRepo.Object, _tenantRepo.Object, _notifications.Object, _ctx.Object); @@ -328,6 +377,30 @@ public async Task Approve_WithValidCommand_ReturnsSuccess() _profileUow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); } + // G-119 (SoD): el creador de la solicitud NO puede aprobarla (self-approval prohibido). + [Fact] + public async Task Approve_WhenApproverIsCreator_ReturnsFailure_SelfApproval() + { + // createdBy == aprobador (_ctx.UserId="user-001") → debe rechazarse. + var req = MakeApprovalRequest(createdBy: "user-001"); + var user = MakeExternalUser(); + var tenant = MakeTenant(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _tenantRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.SelfApprovalNotAllowed, result.Error); + Assert.Equal(ApprovalStatus.Pending, req.Status); + } + [Fact] public async Task Approve_WithValidCommand_SendsApprovalNotificationToApplicant() { @@ -440,6 +513,130 @@ public async Task Approve_WithDelegatedBranchManagerScope_ReturnsSuccess() Assert.NotNull(req.TargetProfileId); } + // ---- G-051 F4: exigencia cross-agregado del checklist de documentos requeridos ---- + + [Fact] + public async Task Approve_WhenMandatoryDocumentMissing_ReturnsFailureAndKeepsPending() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflowRequiring(docType)); + // El usuario no tiene ningun documento: checklist obligatorio incompleto. + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + Assert.Equal(ApprovalStatus.Pending, req.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Approve_WhenMandatoryDocumentValid_ReturnsSuccess() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + var tenant = MakeTenant(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _tenantRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflowRequiring(docType)); + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { MakeValidDocument(docType) }); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ApprovalStatus.Approved, req.Status); + } + + [Fact] + public async Task Approve_WhenMandatoryDocumentNotYetValid_ReturnsFailure() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflowRequiring(docType)); + // Documento subido pero aun en PENDING_REVIEW: no cuenta como cumplimiento. + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { MakePendingDocument(docType) }); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + Assert.Equal(ApprovalStatus.Pending, req.Status); + } + + [Fact] + public async Task Approve_WhenNonMandatoryDocumentMissing_ReturnsSuccess() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + var tenant = MakeTenant(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _tenantRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + // Documento requerido pero NO obligatorio: su ausencia no bloquea la aprobacion. + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflowRequiring(docType, isMandatory: false)); + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ApprovalStatus.Approved, req.Status); + } + + [Fact] + public async Task Approve_WhenWorkflowNotResolvable_FailsClosed() + { + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ApprovalWorkflowAggregate?)null); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + Assert.Equal(ApprovalStatus.Pending, req.Status); + } + #endregion // ========================================================================= diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestQueryHandlerTests.cs index 42a3e3f7..2ee81d5b 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestQueryHandlerTests.cs @@ -38,6 +38,14 @@ private static ApprovalRequest MakeApprovalRequest(ApprovalStatus status) return req; } + private static ApprovalRequest MakeApprovalRequestForUser(Guid targetUserId) + => ApprovalRequest.Create( + ApprovalWorkflowId.Load(Guid.NewGuid()), + UserId.Load(targetUserId), + ProfileId.Load(Guid.NewGuid()), + ValidSystemId, null, ValidRoleId, null, + ActorId.Create("user-001")).Value; + // ========================================================================= #region GetApprovalRequestByIdQueryHandler // ========================================================================= @@ -155,6 +163,37 @@ public async Task GetAll_WithTenantFilter_ReturnsTenantItems() _repo.Verify(r => r.GetByTenantIdAsync(tenantId, It.IsAny()), Times.Once); } + [Fact] + public async Task GetAll_WithUserIdFilter_ReturnsOnlyTargetUserRequests() + { + // G-159: el filtro userId (usuario objetivo) debe aplicarse; antes se ignoraba. + var targetUser = Guid.NewGuid(); + var mine = MakeApprovalRequestForUser(targetUser); + var otherA = MakeApprovalRequestForUser(Guid.NewGuid()); + var otherB = MakeApprovalRequestForUser(Guid.NewGuid()); + var list = new List { otherA, mine, otherB }; + + _repo.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(list); + + var query = new GetAllApprovalRequestsQuery( + TenantId: null, + UserId: targetUser, + Status: "all", + Search: null, + SortBy: null, + SortOrder: null, + Page: 1, + PageSize: 10); + + var handler = new GetAllApprovalRequestsQueryHandler(_repo.Object); + var result = await handler.Handle(query, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.TotalItems); + Assert.Equal(targetUser, result.Value.Items[0].TargetUserId); + } + [Fact] public async Task GetAll_WithStatusFilter_FiltersStatus() { diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowCommandHandlerTests.cs index 554971b5..a92e5d2f 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowCommandHandlerTests.cs @@ -153,7 +153,7 @@ public async Task RemoveRequiredDocument_WithValidCommand_ReturnsSuccess() var secondDocTypeId = Guid.NewGuid(); workflow.AddRequiredDocument(DocumentTypeId.Load(firstDocTypeId), true, ActorId.Create("user-001")); workflow.AddRequiredDocument(DocumentTypeId.Load(secondDocTypeId), true, ActorId.Create("user-001")); - var docId = workflow.RequiredDocuments.First().Id.GetValue(); + var docId = workflow.RequiredDocuments.First().GetId().GetValue(); _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(workflow); diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/UserDocument/UserDocumentCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/UserDocument/UserDocumentCommandHandlerTests.cs index 19adbeab..573dbe95 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/UserDocument/UserDocumentCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/UserDocument/UserDocumentCommandHandlerTests.cs @@ -45,7 +45,7 @@ private static UserDocument MakeUserDocument() => "MD5-12345", ActorId.Create("user-001")).Value; - private UserAccount MakeOwner() + private static UserAccount MakeOwner() { var user = UserAccount.Create( Domain.Kernel.ValueObjects.TenantId.Load(TenantId), @@ -292,7 +292,8 @@ public async Task Reject_WhenNotPendingReview_ReturnsFailure() public async Task ReUpload_WithValidCommand_ReturnsSuccess() { var doc = MakeUserDocument(); - doc.Expire(ActorId.Create("user-001")); + doc.Validate(ActorId.Create("user-001")); // PendingReview → Valid + doc.Expire(ActorId.Create("user-001")); // Valid → Expired (INV-UD3) _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doc); var result = await CreateReUploadHandler().Handle( @@ -358,7 +359,8 @@ public async Task ReUpload_WhenUnauthenticated_ReturnsFailure() public async Task ReUpload_WhenExpirationBeforeIssueDate_ReturnsFailure() { var doc = MakeUserDocument(); - doc.Expire(ActorId.Create("user-001")); + doc.Validate(ActorId.Create("user-001")); // PendingReview → Valid + doc.Expire(ActorId.Create("user-001")); // Valid → Expired (INV-UD3) _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doc); var result = await CreateReUploadHandler().Handle( @@ -380,6 +382,7 @@ public async Task ReUpload_WhenExpirationBeforeIssueDate_ReturnsFailure() public async Task Expire_WithValidCommand_ReturnsSuccess() { var doc = MakeUserDocument(); + doc.Validate(ActorId.Create("user-001")); // PendingReview → Valid (Expire requires Valid, INV-UD3) _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doc); var result = await CreateExpireHandler().Handle( @@ -395,7 +398,8 @@ public async Task Expire_WithValidCommand_ReturnsSuccess() public async Task Expire_WhenAlreadyExpired_ReturnsFailure() { var doc = MakeUserDocument(); - doc.Expire(ActorId.Create("sys")); + doc.Validate(ActorId.Create("sys")); // PendingReview → Valid + doc.Expire(ActorId.Create("sys")); // Valid → Expired _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doc); var result = await CreateExpireHandler().Handle( diff --git a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordCommandHandlerTests.cs index 49e8b34d..7f6f5ffe 100644 --- a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordCommandHandlerTests.cs @@ -17,11 +17,18 @@ public class AuditRecordCommandHandlerTests { private readonly Mock _repo = new(); private readonly Mock _uow = new(); + private readonly Mock _userContext = new(); public AuditRecordCommandHandlerTests() { _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + // G-040 (SEGURIDAD): el actor y el inquilino se derivan del contexto + // autenticado, no del cuerpo de la petición. + _userContext.SetupGet(c => c.IsAuthenticated).Returns(true); + _userContext.SetupGet(c => c.UserId).Returns(Guid.NewGuid().ToString()); + _userContext.SetupGet(c => c.TenantId).Returns(Guid.NewGuid().ToString()); } [Fact] @@ -38,7 +45,7 @@ public async Task Record_WithValidCommand_ReturnsSuccess() RootTenantId: Guid.NewGuid(), Metadata: "{}"); - var handler = new RecordAuditCommandHandler(_repo.Object); + var handler = new RecordAuditCommandHandler(_repo.Object, _userContext.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess); @@ -47,11 +54,49 @@ public async Task Record_WithValidCommand_ReturnsSuccess() _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny(), It.IsAny()), Times.Once); } + // G-040 (FR-072): una traza registrada con metadata que contiene un secreto NO debe persistir el + // secreto en claro. La traza es append-only e inmutable (G-081): lo que se apende no se puede borrar. + [Fact] + public async Task Record_WithSecretInMetadata_DoesNotPersistSecretInClear() + { + AuditRecord? appended = null; + _repo.Setup(r => r.AppendAsync(It.IsAny(), It.IsAny())) + .Callback((record, _) => appended = record) + .Returns(Task.CompletedTask); + + var cmd = new RecordAuditCommand( + WhoActed: Guid.NewGuid(), + SubjectType: "User", + WhatChanged: "Registro con secretos", + EventType: "UserUpdated", + AuditResult: "Success", + AffectedEntityId: Guid.NewGuid(), + AffectedEntityType: "UserAccount", + RootTenantId: Guid.NewGuid(), + Metadata: "{\"password\":\"hunter2\",\"apiKey\":\"sk-live-secret\",\"handler\":\"legit\"}"); + + var handler = new RecordAuditCommandHandler(_repo.Object, _userContext.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(appended); + Assert.NotNull(appended!.Metadata); + // El secreto en claro no se persiste; la clave se conserva redactada. + Assert.DoesNotContain("hunter2", appended.Metadata); + Assert.DoesNotContain("sk-live-secret", appended.Metadata); + Assert.Contains("[REDACTED]", appended.Metadata); + // La metadata legítima permanece intacta. + Assert.Contains("legit", appended.Metadata); + } + [Fact] - public async Task Record_WhenWhoActedIsEmpty_ReturnsFailure() + public async Task Record_WhenUserIsNotAuthenticated_ReturnsFailure() { + _userContext.SetupGet(c => c.IsAuthenticated).Returns(false); + _userContext.SetupGet(c => c.UserId).Returns((string?)null); + var cmd = new RecordAuditCommand( - WhoActed: Guid.Empty, + WhoActed: Guid.NewGuid(), SubjectType: "User", WhatChanged: "Updated user name", EventType: "UserUpdated", @@ -61,7 +106,7 @@ public async Task Record_WhenWhoActedIsEmpty_ReturnsFailure() RootTenantId: Guid.NewGuid(), Metadata: "{}"); - var handler = new RecordAuditCommandHandler(_repo.Object); + var handler = new RecordAuditCommandHandler(_repo.Object, _userContext.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -81,7 +126,7 @@ public async Task Record_WhenWhatChangedIsEmpty_ReturnsFailure() RootTenantId: Guid.NewGuid(), Metadata: "{}"); - var handler = new RecordAuditCommandHandler(_repo.Object); + var handler = new RecordAuditCommandHandler(_repo.Object, _userContext.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); diff --git a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordQueryHandlerTests.cs index 45075033..4242a6a0 100644 --- a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordQueryHandlerTests.cs @@ -42,8 +42,12 @@ public async Task GetById_WhenFound_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(recordId, It.IsAny())) .ReturnsAsync(record); + // G-040 (SEGURIDAD): un admin interno puede leer registros de cualquier inquilino. + var adminCtx = new Mock(); + adminCtx.Setup(t => t.IsInternalAdmin).Returns(true); + var query = new GetAuditRecordByIdQuery(recordId); - var handler = new GetAuditRecordByIdQueryHandler(_repo.Object); + var handler = new GetAuditRecordByIdQueryHandler(_repo.Object, adminCtx.Object); var result = await handler.Handle(query, CancellationToken.None); Assert.True(result.IsSuccess); @@ -166,5 +170,31 @@ public async Task GetAll_WithEventTypeFilter_ReturnsEventTypeItems() _repo.Verify(r => r.QueryByEventTypeAsync(eventType, tenantId, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + // G-113 (regresión): internal-admin SIN request.TenantId debe consultar con su PROPIA + // OrganizationId, NO con Guid.Empty (que filtraba RootTenantId==empty y devolvía 0 pese a + // existir 129 registros en el despliegue). No debilitar: probar el efecto de mostrar el bug. + [Fact] + public async Task GetAll_InternalAdminWithoutTenantId_UsesOwnOrganizationId_NotEmpty() + { + var orgId = Guid.NewGuid(); + _repo.Setup(r => r.QueryByEventTypeAsync("*", orgId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { MakeAuditRecord() }); + + var query = new GetAllAuditRecordsQuery( + TenantId: null, ActorId: null, EntityId: null, EntityType: null, + EventType: null, From: null, To: null, Page: 1, PageSize: 10); + + var adminCtx = new Mock(); + adminCtx.Setup(t => t.IsInternalAdmin).Returns(true); + adminCtx.Setup(t => t.OrganizationId).Returns((Guid?)orgId); + var handler = new GetAllAuditRecordsQueryHandler(_repo.Object, adminCtx.Object); + var result = await handler.Handle(query, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.TotalItems); + _repo.Verify(r => r.QueryByEventTypeAsync("*", orgId, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _repo.Verify(r => r.QueryByEventTypeAsync("*", Guid.Empty, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/RecordAuditCommandValidatorTests.cs b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/RecordAuditCommandValidatorTests.cs new file mode 100644 index 00000000..911854c2 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/RecordAuditCommandValidatorTests.cs @@ -0,0 +1,38 @@ +namespace Ums.Application.Test.Audit.AuditRecord; + +using Ums.Application.Audit.AuditRecord.Commands; +using Xunit; + +// G-040: el Metadata de auditoría, si viene, debe estar acotado (≤4000) y ser JSON bien formado. +public sealed class RecordAuditCommandValidatorTests +{ + private readonly RecordAuditCommandValidator _validator = new(); + + private static RecordAuditCommand WithMetadata(string? metadata) => new( + WhoActed: Guid.NewGuid(), + SubjectType: "User", + WhatChanged: "cambio", + EventType: "User.Updated", + AuditResult: "Success", + AffectedEntityId: Guid.NewGuid(), + AffectedEntityType: "User", + RootTenantId: Guid.NewGuid(), + Metadata: metadata); + + private bool MetadataHasError(string? metadata) + => _validator.Validate(WithMetadata(metadata)) + .Errors.Exists(e => e.PropertyName == nameof(RecordAuditCommand.Metadata)); + + [Fact] + public void NullMetadata_IsAccepted() => Assert.False(MetadataHasError(null)); + + [Fact] + public void WellFormedJson_IsAccepted() => Assert.False(MetadataHasError("{\"key\":\"value\",\"n\":1}")); + + [Fact] + public void MalformedJson_IsRejected() => Assert.True(MetadataHasError("{not valid json")); + + [Fact] + public void OversizedMetadata_IsRejected() + => Assert.True(MetadataHasError("\"" + new string('a', 4001) + "\"")); // JSON válido pero >4000 +} diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AppSettingVisibilityTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AppSettingVisibilityTests.cs new file mode 100644 index 00000000..7982931a --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AppSettingVisibilityTests.cs @@ -0,0 +1,69 @@ +namespace Ums.Application.Test.Authorization.Graph; + +using Xunit; +using FluentAssertions; +using Ums.Domain.Authorization.SystemSuite.AppSetting; +using Ums.Domain.Configuration; +using Ums.Domain.Kernel; +using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; + +/// +/// La marca de exposición de los ajustes del sistema (G-178). +/// +/// `AppSetting` es una bolsa clave/valor sin tipo: junto al color de la marca puede haber una +/// cadena de conexión o el secreto de una integración. Estas pruebas fijan que la exposición se +/// decide ajuste a ajuste y que el default no publica — si alguien invierte ese default, aquí +/// se entera antes de que un secreto salga en el cable. +/// +public class AppSettingVisibilityTests +{ + private static SystemSuiteAggregate Suite() + => SystemSuiteAggregate.Create( + TenantId.Load(Guid.NewGuid()), + Code.Create("SDLC"), + Name.Create("Tablero"), + Description.Create("Tablero de gobierno"), + ActorId.Create("system")).Value; + + [Fact] + public void Un_ajuste_no_es_visible_salvo_que_se_diga() + { + var ajuste = AppSetting.Create( + ConfigurationKey.Create("OPS_CONNECTION_STRING"), + ConfigurationValue.Create("Host=db;Password=secreto"), + ConfigurationScope.Global).Value; + + ajuste.IsClientVisible.Should().BeFalse( + "el default debe ser no publicar: la bolsa contiene también ajustes operativos"); + } + + [Fact] + public void La_marca_viaja_al_agregado() + { + var suite = Suite(); + var actor = ActorId.Create("system"); + + suite.AddAppSetting(ConfigurationKey.Create("BRAND_LOGO_URL"), ConfigurationValue.Create("/logo.svg"), + ConfigurationScope.Global, actor, isClientVisible: true); + suite.AddAppSetting(ConfigurationKey.Create("OPS_INTERVAL"), ConfigurationValue.Create("30"), + ConfigurationScope.Global, actor); + + suite.AppSettings.Single(a => a.Key.GetValue() == "BRAND_LOGO_URL").IsClientVisible.Should().BeTrue(); + suite.AppSettings.Single(a => a.Key.GetValue() == "OPS_INTERVAL").IsClientVisible.Should().BeFalse(); + } + + [Fact] + public void Cambiar_el_valor_no_cambia_la_visibilidad() + { + var suite = Suite(); + var actor = ActorId.Create("system"); + + suite.AddAppSetting(ConfigurationKey.Create("OPS_INTERVAL"), ConfigurationValue.Create("30"), + ConfigurationScope.Global, actor); + suite.UpdateAppSetting(ConfigurationKey.Create("OPS_INTERVAL"), ConfigurationValue.Create("60"), actor); + + // Editar un valor no es decidir publicarlo: si esto se rompiera, cualquier edición + // rutinaria podría sacar al cable un ajuste operativo. + suite.AppSettings.Single(a => a.Key.GetValue() == "OPS_INTERVAL").IsClientVisible.Should().BeFalse(); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthGraphPayloadTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthGraphPayloadTests.cs new file mode 100644 index 00000000..7db11f7f --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthGraphPayloadTests.cs @@ -0,0 +1,202 @@ +namespace Ums.Application.Test.Authorization.Graph; + +using System.Text.Json; +using Xunit; +using FluentAssertions; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Domain.Authorization.Graph; + +/// +/// Ata la forma del grafo al contrato publicado en +/// src/libs/sdk/contracts/auth-graph.schema.json. +/// +/// Existe por G-167: el contrato declaraba `id` obligatorios, nombres bajo +/// `name`/`label` y envoltorios `module`/`resource` que NINGÚN endpoint emitía, +/// y nadie lo notó porque ninguna prueba comparaba ambas cosas. Estas pruebas +/// fallan si alguien vuelve a cambiar la proyección sin actualizar el contrato +/// —o al revés—, que es exactamente el fallo que dejó a los satélites leyendo +/// `undefined` en cada nombre. +/// +public class AuthGraphPayloadTests +{ + private static AuthorizationGraph Grafo() + { + var suiteId = Guid.NewGuid(); + var opcion = new GraphNavigationNode(Guid.NewGuid(), "STOCK_VIEW", "Ver Stock", "Option", 1, + null, "/inv/stock-view", + [new GraphNodeAction("VIEW", AccessEffect.Allow, PermissionSource.Template)], []); + var sub = new GraphNavigationNode(Guid.NewGuid(), "STOCK_OPS", "Operaciones", "SubMenu", 1, null, null, [], [opcion]); + var menu = new GraphNavigationNode(Guid.NewGuid(), "STOCK", "Stock", "Menu", 1, "package", null, [], [sub]); + var modulo = new GraphMenuModule(Guid.NewGuid(), "INV", "Inventario", 1, "Active", "package", [menu]); + + var recurso = new GraphDomainPermission( + Guid.NewGuid(), "Aggregate", "PURCHASE_ORDER", "Orden de Compra", + Guid.NewGuid(), null, + new[] { new GraphDomainAction(Guid.NewGuid(), "VIEW", "Ver", AccessEffect.Allow, PermissionSource.Template) }); + + var contexto = new GraphContext( + new GraphUser(Guid.NewGuid(), "ana@beyondnet.com.pe", "ana", "Ana Torres", "Active"), + new GraphTenant(Guid.NewGuid(), "BEYONDNET", "BeyondNet S.A.C.", "Active", false), + new GraphSystemSuite(suiteId, "WMS", "Almacén", "Active"), + new GraphRole(Guid.NewGuid(), "OPERARIO", "Operario de Almacén", 2, null), + new GraphProfile(Guid.NewGuid(), "OrgWide", true), + Branch: null); + + return AuthorizationGraph.Build( + contexto, + new GraphAuthentication("Local", null, false, DateTime.UtcNow, DateTime.UtcNow.AddHours(1)), + new[] { new GraphAction(Guid.NewGuid(), "VIEW", "Ver") }, + new[] { modulo }, + new[] { recurso }, + new[] { new GraphFeatureFlag("WMS_BULK_EXPORT", suiteId, false, null) }, + new GraphEffectiveConfig(60, 5, 12, true, new[] { "Totp" }, 3600000, false), + new[] { "stock_view.view" }, + DateTime.UtcNow, + settings: new Dictionary> + { + ["brand"] = new Dictionary { ["logo_url"] = "/logo.svg" }, + ["ui"] = new Dictionary { ["home_route"] = "/" }, + }); + } + + private static JsonElement Json(GraphSerializationOptions? opts = null) => + JsonSerializer.SerializeToElement(AuthGraphPayload.Build(Grafo(), opts)); + + [Fact] + public void Nombres_viajan_como_value_no_como_name_ni_label() + { + var g = Json(); + + g.GetProperty("context").GetProperty("user").GetProperty("value").GetString() + .Should().Be("Ana Torres"); + g.GetProperty("context").GetProperty("tenant").GetProperty("value").GetString() + .Should().Be("BeyondNet S.A.C."); + + var modulo = g.GetProperty("menuAccess")[0]; + modulo.GetProperty("value").GetString().Should().Be("Inventario"); + modulo.TryGetProperty("name", out _).Should().BeFalse(); + + var opcion = modulo.GetProperty("nodes")[0].GetProperty("children")[0].GetProperty("children")[0]; + opcion.GetProperty("value").GetString().Should().Be("Ver Stock"); + opcion.TryGetProperty("label", out _).Should().BeFalse(); + } + + [Fact] + public void Sin_metadatos_tecnicos_no_viaja_ningun_id() + { + var g = Json(); + + g.GetProperty("context").GetProperty("user").TryGetProperty("id", out _).Should().BeFalse(); + g.GetProperty("menuAccess")[0].TryGetProperty("id", out _).Should().BeFalse(); + g.GetProperty("domainPermissions")[0].TryGetProperty("resourceId", out _).Should().BeFalse(); + g.GetProperty("featureFlags")[0].TryGetProperty("systemSuiteId", out _).Should().BeFalse(); + } + + [Fact] + public void Con_metadatos_tecnicos_los_ids_aparecen() + { + var g = Json(new GraphSerializationOptions(IncludeTechnicalMetadata: true)); + + g.GetProperty("context").GetProperty("user").TryGetProperty("id", out _).Should().BeTrue(); + g.GetProperty("menuAccess")[0].TryGetProperty("id", out _).Should().BeTrue(); + g.GetProperty("domainPermissions")[0].TryGetProperty("resourceId", out _).Should().BeTrue(); + } + + [Fact] + public void MenuAccess_y_domainPermissions_son_listas_planas() + { + var g = Json(); + + var modulo = g.GetProperty("menuAccess")[0]; + modulo.TryGetProperty("module", out _).Should().BeFalse("el módulo no va envuelto"); + modulo.GetProperty("code").GetString().Should().Be("INV"); + + var recurso = g.GetProperty("domainPermissions")[0]; + recurso.TryGetProperty("resource", out _).Should().BeFalse("el recurso no va envuelto"); + recurso.GetProperty("resourceCode").GetString().Should().Be("PURCHASE_ORDER"); + recurso.GetProperty("resourceType").GetString().Should().Be("Aggregate"); + recurso.GetProperty("actions")[0].GetProperty("actionCode").GetString().Should().Be("VIEW"); + } + + [Fact] + public void SortOrder_viaja_en_los_tres_niveles_de_navegacion() + { + var modulo = Json().GetProperty("menuAccess")[0]; + + modulo.GetProperty("sortOrder").GetInt32().Should().Be(1); + var menu = modulo.GetProperty("nodes")[0]; + menu.GetProperty("sortOrder").GetInt32().Should().Be(1); + menu.GetProperty("children")[0].GetProperty("sortOrder").GetInt32().Should().Be(1); + } + + [Fact] + public void EffectiveConfig_publica_los_metodos_mfa_admitidos() + { + Json().GetProperty("effectiveConfig").GetProperty("mfaAllowedMethods") + .EnumerateArray().Select(x => x.GetString()).Should().Equal("Totp"); + } + + [Fact] + public void Efecto_y_origen_viajan_como_cadena_no_como_ordinal() + { + var accion = Json().GetProperty("menuAccess")[0] + .GetProperty("nodes")[0].GetProperty("children")[0] + .GetProperty("children")[0].GetProperty("actions")[0]; + + accion.GetProperty("effect").GetString().Should().Be("Allow"); + accion.GetProperty("source").GetString().Should().Be("Template"); + } + + [Fact] + public void Los_nulos_del_grafo_viajan_explicitos() + { + var g = Json(); + + g.GetProperty("context").GetProperty("branch").ValueKind.Should().Be(JsonValueKind.Null); + g.GetProperty("authentication").GetProperty("provider").ValueKind.Should().Be(JsonValueKind.Null); + g.GetProperty("featureFlags")[0].GetProperty("matchedCriteriaType").ValueKind + .Should().Be(JsonValueKind.Null); + } + + [Fact] + public void El_icono_y_la_ruta_viajan_en_el_nodo() + { + var menu = Json().GetProperty("menuAccess")[0].GetProperty("nodes")[0]; + + menu.GetProperty("icon").GetString().Should().Be("package"); + // Un menú agrupa: no navega a ninguna parte. + menu.GetProperty("route").ValueKind.Should().Be(JsonValueKind.Null); + + var opcion = menu.GetProperty("children")[0].GetProperty("children")[0]; + opcion.GetProperty("route").GetString().Should().Be("/inv/stock-view"); + // Las claves se emiten aunque estén vacías: el cliente no debe distinguir «sin icono» + // de «clave ausente». + opcion.GetProperty("icon").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public void El_icono_del_modulo_viaja_en_el_modulo() + { + // Sin esto el cliente sabe cómo se llama el módulo pero no con qué pintarlo, y acaba + // resolviendo el icono por código: la tabla estática que el grafo vino a eliminar (G-182). + Json().GetProperty("menuAccess")[0].GetProperty("icon").GetString().Should().Be("package"); + } + + [Fact] + public void Los_ajustes_visibles_viajan_agrupados_por_espacio_de_nombres() + { + var settings = Json().GetProperty("settings"); + + settings.GetProperty("brand").GetProperty("logo_url").GetString().Should().Be("/logo.svg"); + settings.GetProperty("ui").GetProperty("home_route").GetString().Should().Be("/"); + } + + [Fact] + public void Las_claves_de_primer_nivel_son_las_del_contrato() + { + Json().EnumerateObject().Select(p => p.Name).Should().Equal( + "schemaVersion", "onboardingPending", "accessState", "context", "authentication", + "actions", "profiles", "menuAccess", "domainPermissions", "featureFlags", + "effectiveConfig", "settings", "scopes", "generatedAt", "validUntil"); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthorizationGraphBuilderServiceTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthorizationGraphBuilderServiceTests.cs index 93baa8c4..3fb3cd9a 100644 --- a/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthorizationGraphBuilderServiceTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthorizationGraphBuilderServiceTests.cs @@ -1,12 +1,18 @@ namespace Ums.Application.Test.Authorization.Graph; +#pragma warning disable S125 + +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Template.Commands; +using Ums.Application.Common.Interfaces; using Ums.Application.Configuration.Services; using Ums.Domain.Authorization; using Ums.Domain.Authorization.Graph; using Ums.Domain.Authorization.Profile; +using Ums.Domain.Authorization.SystemSuite.DomainResource; using Ums.Domain.Authorization.Template; using Ums.Domain.Configuration; using Ums.Domain.Configuration.FeatureFlag; @@ -27,7 +33,6 @@ public class AuthorizationGraphBuilderServiceTests private readonly Mock _profileRepo = new(); private readonly Mock _roleRepo = new(); private readonly Mock _suiteRepo = new(); - private readonly Mock _templateRepo = new(); private readonly Mock _tenantRepo = new(); private readonly Mock _flagRepo = new(); private readonly Mock _flagEvaluator = new(); @@ -35,8 +40,9 @@ public class AuthorizationGraphBuilderServiceTests private AuthorizationGraphBuilderService CreateSut() => new( _profileRepo.Object, _roleRepo.Object, _suiteRepo.Object, - _templateRepo.Object, _tenantRepo.Object, _flagRepo.Object, - _flagEvaluator.Object, _configProvider.Object); + _tenantRepo.Object, _flagRepo.Object, + _flagEvaluator.Object, _configProvider.Object, + NullLogger.Instance); private static readonly Guid TenantGuid = Guid.NewGuid(); private static readonly Guid UserGuid = Guid.NewGuid(); @@ -50,11 +56,23 @@ public AuthorizationGraphBuilderServiceTests() _configProvider.Setup(c => c.GetValue(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((string _, Guid? __, string? defaultValue) => defaultValue ?? string.Empty); - _flagRepo.Setup(r => r.GetBySystemSuiteIdAsync(It.IsAny(), It.IsAny())) + // El constructor carga SIEMPRE los perfiles del usuario para el bloque `profiles`, también + // en la vía que recibe un perfil explícito. Sin este stub, el mock devuelve null. + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + // Bloque `profiles` (G-177): sin estos dos stubs el constructor recibe null de los mocks. + // Devuelven vacío a propósito — estas pruebas verifican la resolución de permisos, no el + // selector de perfiles, que tiene las suyas. + _roleRepo.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + _suiteRepo.Setup(r => r.GetSummariesByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + _flagRepo.Setup(r => r.GetBySystemSuiteIdForEvaluationAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); - _templateRepo.Setup(r => r.GetByTenantIdAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new List()); } // ── Error paths ──────────────────────────────────────────────────────────── @@ -72,23 +90,33 @@ public async Task BuildAsync_TenantNotFound_ReturnsFailure() } [Fact] - public async Task BuildAsync_NoActiveProfileForUser_ReturnsFailure() + public async Task BuildAsync_NoActiveProfileForUser_ReturnsLobbyGraph() { + // G-043: un usuario autenticado y aprobado pero SIN perfil activo ya no falla el login; + // recibe un GRAFO LOBBY (OnboardingPending=true, sin suite/rol/perfil, sin menús) para que + // el cliente muestre el onboarding en vez de un error opaco. SetupValidTenant(); - _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); var result = await CreateSut().BuildAsync(MakeUser(), TenantGuid, AuthMethod.Local()); - Assert.True(result.IsFailure); - Assert.Contains("profile", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.True(result.IsSuccess); + Assert.True(result.Value.OnboardingPending); + Assert.Null(result.Value.Context.SystemSuite); + Assert.Null(result.Value.Context.Role); + Assert.Null(result.Value.Context.Profile); + Assert.Empty(result.Value.MenuAccess); + // El contexto de usuario e inquilino sí es real. + Assert.NotNull(result.Value.Context.User); + Assert.Equal(TenantGuid, result.Value.Context.Tenant.Id); } [Fact] public async Task BuildAsync_RoleNotFound_ReturnsFailure() { SetupValidTenant(); - _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { MakeProfile() }); _roleRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((RoleAggregate?)null); @@ -149,7 +177,7 @@ public async Task BuildAsync_FeatureFlags_EvaluatedWithUserContext() flag.Activate(ActorId.Create("test")); flag.DomainEvents.MarkChangesAsCommitted(); - _flagRepo.Setup(r => r.GetBySystemSuiteIdAsync(It.IsAny(), It.IsAny())) + _flagRepo.Setup(r => r.GetBySystemSuiteIdForEvaluationAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { flag }); _flagEvaluator.Setup(e => e.Evaluate(It.IsAny(), It.IsAny())) .Returns(new FlagEvaluationResult(true, null, "no criteria")); @@ -233,7 +261,7 @@ public async Task BuildForProfileAsync_UsesRequestedProfile() _roleRepo.Setup(r => r.GetByIdAsync(requestedRoleId, It.IsAny())) .ReturnsAsync(role); - var suite = BuildMinimalSuite(suiteId); + var suite = BuildMinimalSuite(); _suiteRepo.Setup(r => r.GetByIdAsync(suiteId, It.IsAny())) .ReturnsAsync(suite); @@ -259,6 +287,337 @@ public async Task BuildAsync_OrgWide_NoBranchInContext() Assert.Equal("OrgWide", result.Value.Context.Profile.Scope); } + // ── Adversariales: deny-wins multi-fuente / fail-closed / scaffolding G-016 / ciclo (G-085) ── + // + // El mapa efectivo (AuthorizationGraphBuilderService.BuildPermissionMap) compone varias fuentes + // — perfil + plantilla + override — para el mismo (TargetId, ActionId). Estas pruebas ejercen la + // resolución REAL: deny-wins gana siempre e independiente del orden; Override gana sobre Template + // sólo para Allow; la ausencia de entrada resuelve NotGranted (fail-closed, ADR-UMS-088/G-039); una + // plantilla Published que nunca se asignó NO contribuye al mapa (scaffolding reservado, G-016); y + // el constructor proyecta el ParentRoleId inmediato sin recorrer la ascendencia (ciclo-seguro). + // + // El efecto resuelto se observa en DomainPermissions (BuildDomainPermissions): se registra un + // DomainResource "USERS" (Aggregate) y una Action "VIEW", y los permisos de perfil se materializan + // vía AssignTemplate contra ese (TargetId=USERS.Id, ActionId=VIEW.Id). + + [Fact] + public async Task BuildForProfileAsync_DenyAndAllowFromTwoTemplates_ResolvesDeny() + { + // Dos plantillas Published para el mismo (target, action): una Allow y otra Deny. + // Ambas materializan ProfilePermission distintos ⇒ dos fuentes en conflicto ⇒ gana Deny. + var scenario = SetupProfileScenario(); + var allow = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + var deny = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: false, isDenied: true); + + Assert.True(scenario.Profile.AssignTemplate(allow, ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(deny, ActorId.Create("test")).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + var action = ResolveDomainAction(result.Value, "USERS", "VIEW"); + Assert.Equal(AccessEffect.Deny, action.Effect); + Assert.Equal(PermissionSource.Template, action.Source); + } + + [Fact] + public async Task BuildForProfileAsync_DenyWins_IsOrderIndependent() + { + // Mismo conflicto que arriba pero asignando primero la Deny y luego la Allow: + // deny-wins no depende del orden de iteración de profile.Permissions. + var scenario = SetupProfileScenario(); + var deny = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: false, isDenied: true); + var allow = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + + Assert.True(scenario.Profile.AssignTemplate(deny, ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(allow, ActorId.Create("test")).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.Equal(AccessEffect.Deny, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + } + + [Fact] + public async Task BuildForProfileAsync_OverrideAllow_WinsOverTemplateAllow_SourceIsOverride() + { + // Dos fuentes Allow para el mismo (target, action): una Template y una Override. + // Override gana sobre Template para Allow ⇒ el efecto sigue Allow pero la fuente es Override. + var scenario = SetupProfileScenario(); + var t1 = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + var t2 = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + + Assert.True(scenario.Profile.AssignTemplate(t1, ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(t2, ActorId.Create("test")).IsSuccess); + + // Convierte la última entrada materializada (fuente Template) en un Override Allow. FindPermission + // resuelve por la identidad de entidad viva (ProfilePermission.Id), no por Props.Id: en un agregado + // en memoria ambas difieren (Props.Id sólo se alinea tras rehidratar desde BD, ver SystemSuite.FindModule). + var overridden = scenario.Profile.Permissions.Last(); + Assert.True(scenario.Profile.OverridePermissionAllow(overridden.GetId(), ActorId.Create("test")).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + var action = ResolveDomainAction(result.Value, "USERS", "VIEW"); + Assert.Equal(AccessEffect.Allow, action.Effect); + Assert.Equal(PermissionSource.Override, action.Source); + } + + [Fact] + public async Task BuildForProfileAsync_Deny_WinsOverTemplateAllowAndOverrideAllow() + { + // Tres fuentes para el mismo (target, action): Template Allow + Override Allow + Template Deny. + // deny-wins se evalúa ANTES que "Override gana sobre Template", así que Deny gana sobre ambos. + var scenario = SetupProfileScenario(); + var templateAllow = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + var willOverride = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + var templateDeny = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: false, isDenied: true); + + Assert.True(scenario.Profile.AssignTemplate(templateAllow, ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(willOverride, ActorId.Create("test")).IsSuccess); + var overridden = scenario.Profile.Permissions.Last(); + Assert.True(scenario.Profile.OverridePermissionAllow(overridden.GetId(), ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(templateDeny, ActorId.Create("test")).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.Equal(AccessEffect.Deny, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + } + + [Fact] + public async Task BuildForProfileAsync_NoPermissionEntry_ResolvesNotGranted_FailClosed() + { + // Fail-closed (G-039, ADR-UMS-088): sin ProfilePermission para (target, action), el efecto es + // NotGranted, NO Allow. Nota adversarial: AccessEffect.Allow es el valor 0 del enum, así que + // un default de diccionario dejaría Allow (fail-open). Esta prueba fija la denegación implícita. + var scenario = SetupProfileScenario(); // sin AssignTemplate: perfil sin permisos materializados + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + var action = ResolveDomainAction(result.Value, "USERS", "VIEW"); + Assert.Equal(AccessEffect.NotGranted, action.Effect); + Assert.Empty(result.Value.Scopes); + } + + [Fact] + public async Task BuildForProfileAsync_PublishedTemplateNeverAssigned_DoesNotContributeToEffectiveMap() + { + // El mapa efectivo deriva SÓLO de los ProfilePermission materializados: un perfil sin + // AssignTemplate sale con permisos vacíos aunque exista una plantilla Published que le + // aplicaría. Desde G-174 el constructor ni siquiera consulta las plantillas —antes las + // cargaba todas y descartaba el resultado—, así que esta prueba fija que la plantilla + // no contribuye por ninguna vía, ni directa ni accidental. + var scenario = SetupProfileScenario(); + + // Plantilla Published para el MISMO rol+inquilino: el candidato más plausible a colarse. + MakePublishedTemplate( + scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false, + roleId: scenario.Role.GetId().GetValue()); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + // …y aun así el permiso efectivo es NotGranted y no hay scopes: la plantilla no contribuye. + Assert.Equal(AccessEffect.NotGranted, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + Assert.Empty(result.Value.Scopes); + } + + [Fact] + public async Task BuildForProfileAsync_ItemRetiradoDeLaPlantilla_NoConcede() + { + // ADR-0164 — la comprobación que decide si el borrado lógico es seguro o es una brecha. + // + // Al dejar de borrar físicamente el ítem, la fila permanece en la plantilla. Esta prueba + // recorre la cadena entera —ítem retirado → AssignTemplate → permMap → grafo resuelto— y + // fija que la retirada NO concede. Con `Profile.AssignTemplate` leyendo `Items` en vez de + // `ActiveItems`, aquí saldría Allow: sería el permiso vivo de una concesión que el operador + // ve apagada en la plantilla. + var scenario = SetupProfileScenario(); + var actor = ActorId.Create("test"); + + var template = PermissionTemplate.Create( + TenantId.Load(TenantGuid), RoleId.Load(Guid.NewGuid()), SystemSuiteId.Load(Guid.NewGuid()), actor).Value; + + // Dos concesiones: la que se retira sobre (USERS, VIEW) —la observable— y otra cualquiera que + // sostiene la publicación, porque una plantilla sin ítems vigentes ya no se puede publicar. + Assert.True(template.AddItem( + ExclusiveArcTarget.Aggregate, IdValueObject.Load(scenario.ResourceId), ActionId.Load(scenario.ActionId), + isAllowed: true, isDenied: false, actor).IsSuccess); + Assert.True(template.AddItem( + ExclusiveArcTarget.Aggregate, IdValueObject.Create(), ActionId.Load(Guid.NewGuid()), + isAllowed: true, isDenied: false, actor).IsSuccess); + + var retirado = template.Items.Single(i => i.TargetId.GetValue() == scenario.ResourceId); + Assert.True(template.DeactivateItem(retirado.GetId(), actor).IsSuccess); + Assert.True(template.Publish(actor).IsSuccess); + template.DomainEvents.MarkChangesAsCommitted(); + + Assert.True(scenario.Profile.AssignTemplate(template, actor).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.Equal(AccessEffect.NotGranted, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + Assert.DoesNotContain("users.view", result.Value.Scopes); + + // Y la fila sigue en la plantilla: retirar no es borrar. + Assert.Equal(2, template.Items.Count); + } + + [Fact] + public async Task BuildForProfileAsync_ItemReactivadoTrasRetirarlo_VuelveAConceder() + { + // Contraprueba obligatoria de la anterior: si el ítem retirado no concediera por cualquier + // otra razón —una plantilla que nunca se asignó, un recurso que no resuelve— la prueba de + // arriba se satisfaría por accidente. Reactivar tiene que devolver el permiso. + var scenario = SetupProfileScenario(); + var actor = ActorId.Create("test"); + + var template = PermissionTemplate.Create( + TenantId.Load(TenantGuid), RoleId.Load(Guid.NewGuid()), SystemSuiteId.Load(Guid.NewGuid()), actor).Value; + + Assert.True(template.AddItem( + ExclusiveArcTarget.Aggregate, IdValueObject.Load(scenario.ResourceId), ActionId.Load(scenario.ActionId), + isAllowed: true, isDenied: false, actor).IsSuccess); + + var itemId = template.Items.Single().GetId(); + Assert.True(template.DeactivateItem(itemId, actor).IsSuccess); + Assert.True(template.ActivateItem(itemId, actor).IsSuccess); + Assert.True(template.Publish(actor).IsSuccess); + template.DomainEvents.MarkChangesAsCommitted(); + + Assert.True(scenario.Profile.AssignTemplate(template, actor).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.Equal(AccessEffect.Allow, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + } + + /// + /// G-192 — recorrido completo de una concesión sobre un OBJETO DE DOMINIO por la vía de la API: + /// el validador la admite, el manejador la da de alta en la plantilla, AssignTemplate la + /// materializa en el perfil y el constructor la proyecta en domainPermissions. + /// + /// Es la prueba que faltaba: el tramo del grafo ya estaba cubierto, pero nadie fijaba que el + /// alta por comando llegase hasta él. Mientras el validador copió a mano los cuatro destinos de + /// navegación, este camino devolvía 400 y `domainPermissions` salía vacío en todo perfil + /// provisionado por API, aunque la suite hubiese declarado su catálogo de objetos de dominio. + /// + [Theory] + [InlineData("Aggregate")] + [InlineData("Entity")] + public async Task BuildForProfileAsync_ConcesionSobreObjetoDeDominioAltaPorComando_LlegaAlGrafo(string targetType) + { + var scenario = SetupProfileScenario(); + var actor = ActorId.Create("test"); + + var template = PermissionTemplate.Create( + TenantId.Load(TenantGuid), RoleId.Load(scenario.Role.GetId().GetValue()), + SystemSuiteId.Load(Guid.NewGuid()), actor).Value; + + var comando = new AddTemplateItemCommand( + TemplateId: template.GetId().GetValue(), + TargetType: targetType, + TargetId: scenario.ResourceId, + ActionId: scenario.ActionId, + IsAllowed: true, + IsDenied: false); + + // 1) La puerta de la API: el validador de FluentValidation es quien devolvía el 400. + Assert.True(new AddTemplateItemCommandValidator().Validate(comando).IsValid); + + // 2) El manejador: resuelve el destino contra el enumerado del dominio y lo añade. + var templateRepo = new Mock(); + var uow = new Mock(); + var userCtx = new Mock(); + templateRepo.Setup(r => r.GetByIdAsync(template.GetId().GetValue(), It.IsAny())) + .ReturnsAsync(template); + templateRepo.Setup(r => r.UnitOfWork).Returns(uow.Object); + uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + userCtx.Setup(u => u.UserId).Returns("test"); + + var alta = await new AddTemplateItemCommandHandler(templateRepo.Object, userCtx.Object) + .Handle(comando, CancellationToken.None); + + Assert.True(alta.IsSuccess, alta.IsFailure ? alta.Error : string.Empty); + Assert.Equal(targetType, template.Items.Single().TargetType.Name); + + // 3) Publicación y materialización en el perfil. + Assert.True(template.Publish(actor).IsSuccess); + template.DomainEvents.MarkChangesAsCommitted(); + Assert.True(scenario.Profile.AssignTemplate(template, actor).IsSuccess); + + // 4) Proyección: el objeto de dominio viaja en el grafo con su acción concedida. + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + var recurso = Assert.Single(result.Value.DomainPermissions, p => p.ResourceCode == "USERS"); + Assert.Equal(AccessEffect.Allow, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + Assert.Equal(scenario.ResourceId, recurso.ResourceId); + } + + [Fact] + public async Task BuildForProfileAsync_CyclicRoleHierarchy_ProjectsImmediateParentWithoutTraversing() + { + // Ciclo en el grafo efectivo, más allá del ciclo de roles ya cubierto en + // RoleCommandHandlerTests.Update_WhenParentIsDescendant_RejectsCycle (que lo RECHAZA al persistir). + // Aquí el dato ya contiene un ciclo R1 → R2 → R1; el constructor del grafo lo RESUELVE proyectando + // sólo el ParentRoleId inmediato como escalar (AuthorizationGraphBuilderService, nodo Context.Role) + // y NUNCA recorre la ascendencia — por eso una jerarquía cíclica no puede colgar la construcción. + SetupValidTenant(); + + var suiteId = Guid.NewGuid(); + var r1Key = Guid.NewGuid(); + var r2Key = Guid.NewGuid(); + + var suite = BuildMinimalSuite(); + _suiteRepo.Setup(r => r.GetByIdAsync(suiteId, It.IsAny())).ReturnsAsync(suite); + + var r1 = RoleAggregate.Create( + TenantId.Load(TenantGuid), SystemSuiteId.Load(suiteId), + Code.Create("R1"), Name.Create("Role 1"), Description.Create(""), + RoleId.Load(r2Key), 1, 1, ActorId.Create("test")).Value; + r1.DomainEvents.MarkChangesAsCommitted(); + + var r2 = RoleAggregate.Create( + TenantId.Load(TenantGuid), SystemSuiteId.Load(suiteId), + Code.Create("R2"), Name.Create("Role 2"), Description.Create(""), + RoleId.Load(r1Key), 1, 1, ActorId.Create("test")).Value; + r2.DomainEvents.MarkChangesAsCommitted(); + + _roleRepo.Setup(r => r.GetByIdAsync(r1Key, It.IsAny())).ReturnsAsync(r1); + _roleRepo.Setup(r => r.GetByIdAsync(r2Key, It.IsAny())).ReturnsAsync(r2); + + var profile = ProfileAggregate.Create( + TenantId.Load(TenantGuid), UserId.Load(UserGuid), + RoleId.Load(r1Key), null, ActorId.Create("test")).Value; + profile.DomainEvents.MarkChangesAsCommitted(); + _profileRepo.Setup(r => r.GetByIdAsync(profile.GetId().GetValue(), It.IsAny())) + .ReturnsAsync(profile); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Value.Context.Role); + Assert.Equal(r2Key, result.Value.Context.Role!.ParentRoleId); // padre inmediato proyectado como escalar + // El constructor no resolvió el padre ⇒ no existe recorrido de ascendencia que pueda ciclar. + _roleRepo.Verify(r => r.GetByIdAsync(r2Key, It.IsAny()), Times.Never); + } + // ── Helpers ──────────────────────────────────────────────────────────────── private static UserAccountAggregate MakeUser() @@ -314,7 +673,7 @@ private void SetupFullChain() TenantId.Load(TenantGuid), UserId.Load(UserGuid), RoleId.Load(roleId), null, ActorId.Create("test")).Value; profile.DomainEvents.MarkChangesAsCommitted(); - _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { profile }); var role = RoleAggregate.Create( @@ -329,12 +688,12 @@ private void SetupFullChain() _roleRepo.Setup(r => r.GetByIdAsync(roleId, It.IsAny())) .ReturnsAsync(role); - var suite = BuildMinimalSuite(suiteId); + var suite = BuildMinimalSuite(); _suiteRepo.Setup(r => r.GetByIdAsync(suiteId, It.IsAny())) .ReturnsAsync(suite); } - private static SystemSuiteAggregate BuildMinimalSuite(Guid suiteId) + private static SystemSuiteAggregate BuildMinimalSuite() { var actor = ActorId.Create("test"); var suite = SystemSuiteAggregate.Create( @@ -349,4 +708,93 @@ private static SystemSuiteAggregate BuildMinimalSuite(Guid suiteId) suite.DomainEvents.MarkChangesAsCommitted(); return suite; } + + // ── Helpers adversariales (G-085) ──────────────────────────────────────────── + + /// + /// Prepara la cadena Perfil → Rol → Suite para BuildForProfileAsync con una suite que + /// contiene un DomainResource "USERS" (Aggregate) y una Action "VIEW", de modo que el + /// efecto resuelto para (USERS.Id, VIEW.Id) sea observable en DomainPermissions. + /// + private (ProfileAggregate Profile, RoleAggregate Role, Guid ResourceId, Guid ActionId) SetupProfileScenario() + { + SetupValidTenant(); + + var suiteId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + + var suite = BuildSuiteWithDomainResource(out var resourceId, out var actionId); + _suiteRepo.Setup(r => r.GetByIdAsync(suiteId, It.IsAny())).ReturnsAsync(suite); + + var role = RoleAggregate.Create( + TenantId.Load(TenantGuid), SystemSuiteId.Load(suiteId), + Code.Create("ADMIN"), Name.Create("Administrator"), Description.Create(""), + null, 0, 1, ActorId.Create("test")).Value; + role.DomainEvents.MarkChangesAsCommitted(); + _roleRepo.Setup(r => r.GetByIdAsync(roleId, It.IsAny())).ReturnsAsync(role); + + var profile = ProfileAggregate.Create( + TenantId.Load(TenantGuid), UserId.Load(UserGuid), + RoleId.Load(roleId), null, ActorId.Create("test")).Value; + profile.DomainEvents.MarkChangesAsCommitted(); + _profileRepo.Setup(r => r.GetByIdAsync(profile.GetId().GetValue(), It.IsAny())) + .ReturnsAsync(profile); + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { profile }); + + return (profile, role, resourceId, actionId); + } + + private static SystemSuiteAggregate BuildSuiteWithDomainResource(out Guid resourceId, out Guid actionId) + { + var actor = ActorId.Create("test"); + var suite = SystemSuiteAggregate.Create( + TenantId.Load(Guid.NewGuid()), + Code.Create("CORE"), Name.Create("Core System"), Description.Create(""), + actor).Value; + + actionId = suite.RegisterAction(ActionCode.Create("VIEW"), Name.Create("View Records"), actor).Value; + resourceId = suite.AddDomainResource( + null, null, DomainResourceType.Aggregate, + Code.Create("USERS"), Name.Create("Users"), Description.Create(""), actor).Value; + suite.DomainEvents.MarkChangesAsCommitted(); + return suite; + } + + /// + /// Crea y publica una plantilla del inquilino de prueba con un único item para + /// (Aggregate, resourceId, actionId) con el efecto indicado. El RoleId es libre por + /// defecto (AssignTemplate no lo valida); se puede fijar para que el filtro del paso 5 + /// del constructor la seleccione (prueba de scaffolding G-016). + /// + private static PermissionTemplate MakePublishedTemplate( + Guid resourceId, Guid actionId, bool isAllowed, bool isDenied, Guid? roleId = null) + { + var actor = ActorId.Create("test"); + var template = PermissionTemplate.Create( + TenantId.Load(TenantGuid), + RoleId.Load(roleId ?? Guid.NewGuid()), + SystemSuiteId.Load(Guid.NewGuid()), + actor).Value; + + Assert.True(template.AddItem( + ExclusiveArcTarget.Aggregate, IdValueObject.Load(resourceId), ActionId.Load(actionId), + isAllowed, isDenied, actor).IsSuccess); + Assert.True(template.Publish(actor).IsSuccess); + template.DomainEvents.MarkChangesAsCommitted(); + return template; + } + + /// + /// Resolución efectiva de un par recurso-acción. Desde el contrato v2.0.0 las filas + /// `NotGranted` NO viajan —la ausencia es la denegación—, así que «no encontrado» y + /// «NotGranted» son lo mismo y este ayudante los unifica. + /// + private static GraphDomainAction ResolveDomainAction( + AuthorizationGraph graph, string resourceCode, string actionCode) + => graph.DomainPermissions + .SingleOrDefault(p => p.ResourceCode == resourceCode) + ?.Actions.SingleOrDefault(a => a.ActionCode == actionCode) + ?? new GraphDomainAction(Guid.Empty, actionCode, actionCode, + AccessEffect.NotGranted, PermissionSource.Template); } diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Profile/ProfileCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Profile/ProfileCommandHandlerTests.cs index f4f6cde0..cb6f88c7 100644 --- a/src/apps/ums.api/Ums.Application.Test/Authorization/Profile/ProfileCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Profile/ProfileCommandHandlerTests.cs @@ -93,6 +93,43 @@ private void SetupValidProfileReferences(Guid tenantId, Guid userId, Guid roleId #region CreateProfileCommandHandler // ========================================================================= + /// + /// G-215 — un perfil por (usuario, rol, sucursal) activo. + /// + /// Sin esta guarda, cada llamada con los mismos datos creaba otro perfil. Un + /// aprovisionamiento reejecutado dejaba al usuario con dos perfiles del mismo rol —uno con + /// concesiones y otro vacío, porque la plantilla se asigna a uno solo— y el selector se los + /// ofrecía indistinguibles. Entrar por el vacío es entrar sin permisos, con todos los HTTP en + /// 2xx. + /// + [Fact] + public async Task Create_CuandoYaExisteUnPerfilDelMismoRol_Falla() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + SetupNoMatchingRules(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + + _userAccountRepo.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(MakeUser(tenantId, userId)); + _roleRepo.Setup(r => r.GetByIdAsync(roleId, It.IsAny())) + .ReturnsAsync(MakeRole(tenantId, roleId)); + + var yaExiste = Profile.Create( + TenantId.Load(tenantId), UserId.Load(userId), RoleId.Load(roleId), null, + ActorId.Create("user-001")).Value; + _repo.Setup(r => r.GetActiveByUserAndTenantAsync(userId, tenantId, It.IsAny())) + .ReturnsAsync([yaExiste]); + + var result = await MakeCreateHandler().Handle( + new CreateProfileCommand(tenantId, userId, roleId, BranchId: null), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("profile_already_exists_for_role", result.Error); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task Create_WithValidCommand_ReturnsSuccess() { @@ -401,13 +438,17 @@ public async Task Create_WhenMatchingRuleExists_AutoAssignsTemplate() [Fact] public async Task Create_WhenMatchingRuleAndTemplate_ProfileReceivesPermissions() { + // G-043: materialización de permisos. La plantilla publicada auto-asignada debe dejar + // permisos en el agregado Profile ANTES de persistir (un único Save). Antes esto se hacía + // en un segundo Save cuyo fallo se tragaba (permissionCount=0). Ahora se captura el perfil + // en AddAsync y se verifica que trae permisos. var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var roleId = Guid.NewGuid(); SetupValidProfileReferences(tenantId, userId, roleId); var capturedProfile = (Profile?)null; - _repo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + _repo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) .Callback((p, _) => capturedProfile = p) .Returns(Task.CompletedTask); @@ -431,8 +472,70 @@ public async Task Create_WhenMatchingRuleAndTemplate_ProfileReceivesPermissions( var result = await MakeCreateHandler().Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess); - // capturedProfile may be null if TenantId != comparison fails (value equality issue) - // but result should still be success (auto-assign failure is swallowed gracefully) + Assert.NotNull(capturedProfile); + Assert.NotEmpty(capturedProfile!.Permissions); + } + + [Fact] + public async Task Create_WhenAutoAssignedTemplateIsUnpublished_ReturnsFailureWithCode() + { + // G-043 (desenmascarar): si la plantilla referida por la regla NO está publicada, + // AssignTemplate falla. Antes ese fallo se tragaba y el perfil quedaba sin permisos. + // Ahora el comando falla con un código estable y NO persiste el perfil. + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + SetupValidProfileReferences(tenantId, userId, roleId); + + var draftTemplate = Ums.Domain.Authorization.Template.PermissionTemplate.Create( + TenantId.Load(tenantId), RoleId.Load(roleId), + SystemSuiteId.Load(Guid.NewGuid()), ActorId.Create("admin")).Value; + draftTemplate.AddItem(ExclusiveArcTarget.SystemSuite, IdValueObject.Create(), + ActionId.Load(Guid.NewGuid()), true, false, ActorId.Create("admin")); + // NOTA: NO se publica → AssignTemplate debe rechazarla. + + var rule = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule.Create( + TenantId.Load(tenantId), TemplateId.Load(draftTemplate.Props.Id.GetValue()), + RoleId.Load(roleId), 10, ActorId.Create("admin")).Value; + + _ruleRepo.Setup(r => r.GetActiveByTenantAndRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { rule }); + _templateRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(draftTemplate); + + var cmd = new CreateProfileCommand(tenantId, userId, roleId, null); + var result = await MakeCreateHandler().Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("PROFILE_TEMPLATE_ASSIGN_FAILED", result.Error); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_WhenRuleReferencesMissingTemplate_ReturnsFailureWithCode() + { + // G-043 (desenmascarar): una regla activa que apunta a una plantilla inexistente es una + // inconsistencia de configuración que ahora aflora en vez de tragarse (permissionCount=0). + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + SetupValidProfileReferences(tenantId, userId, roleId); + + var rule = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule.Create( + TenantId.Load(tenantId), TemplateId.Load(Guid.NewGuid()), + RoleId.Load(roleId), 10, ActorId.Create("admin")).Value; + + _ruleRepo.Setup(r => r.GetActiveByTenantAndRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { rule }); + _templateRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Ums.Domain.Authorization.Template.PermissionTemplate?)null); + + var cmd = new CreateProfileCommand(tenantId, userId, roleId, null); + var result = await MakeCreateHandler().Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("PROFILE_TEMPLATE_MISSING", result.Error); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -443,13 +546,22 @@ public async Task Create_WhenMultipleRulesMatch_OnlyQueriesTemplateOnce() var roleId = Guid.NewGuid(); SetupValidProfileReferences(tenantId, userId, roleId); + var template = Ums.Domain.Authorization.Template.PermissionTemplate.Create( + TenantId.Load(tenantId), RoleId.Load(roleId), + SystemSuiteId.Load(Guid.NewGuid()), ActorId.Create("admin")).Value; + template.AddItem(ExclusiveArcTarget.SystemSuite, IdValueObject.Create(), + ActionId.Load(Guid.NewGuid()), true, false, ActorId.Create("admin")); + template.Publish(ActorId.Create("admin")); + var highPriorityRule = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule.Create( - TenantId.Load(tenantId), TemplateId.Load(Guid.NewGuid()), RoleId.Load(roleId), 100, ActorId.Create("admin")).Value; + TenantId.Load(tenantId), TemplateId.Load(template.Props.Id.GetValue()), RoleId.Load(roleId), 100, ActorId.Create("admin")).Value; var lowPriorityRule = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule.Create( TenantId.Load(tenantId), TemplateId.Load(Guid.NewGuid()), RoleId.Load(roleId), 10, ActorId.Create("admin")).Value; _ruleRepo.Setup(r => r.GetActiveByTenantAndRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { highPriorityRule, lowPriorityRule }); + _templateRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(template); var cmd = new CreateProfileCommand(tenantId, userId, roleId, null); var result = await MakeCreateHandler().Handle(cmd, CancellationToken.None); diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/SystemSuite/SystemSuiteQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/SystemSuite/SystemSuiteQueryHandlerTests.cs index 19c21322..d6e15fd1 100644 --- a/src/apps/ums.api/Ums.Application.Test/Authorization/SystemSuite/SystemSuiteQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/SystemSuite/SystemSuiteQueryHandlerTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Application.Test.Authorization.SystemSuite; using Ums.Application.Authorization.SystemSuite.Queries; @@ -10,6 +11,7 @@ namespace Ums.Application.Test.Authorization.SystemSuite; using Xunit; using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -80,122 +82,102 @@ public async Task GetById_WhenNotFound_ReturnsFailure() #region GetAllSystemSuitesQueryHandler // ========================================================================= - [Fact] - public async Task GetAll_WithoutTenantFilter_ReturnsAllItems() + // ── Paginación en la base (G-179) ──────────────────────────────────────── + // + // El filtrado, el orden y el recorte se resolvían en memoria sobre TODAS las suites del + // inquilino, con su árbol completo. Ahora los resuelve la base y el manejador solo traduce + // los criterios de la petición: eso es lo que estas pruebas verifican. La corrección del + // filtro SQL en sí es responsabilidad del repositorio, y se cubre contra una base real en + // las pruebas de integración. + + /// Prepara el repositorio para devolver una página con las suites indicadas. + private void ConPagina(int total, params SystemSuite[] suites) { - var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); - var s2 = MakeSystemSuite("SUITE-02", "Suite Beta", "Maintenance"); - var list = new List { s1, s2 }; + var ids = suites.Select(x => x.GetId().GetValue()).ToList(); - _repo.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(list); + _repo.Setup(r => r.GetPageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new SystemSuitePage(ids, total)); + _repo.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(suites.ToList()); + } - var query = new GetAllSystemSuitesQuery( - TenantId: null, - Criteria: "name", - Status: "all", - Search: null, - SortBy: null, - SortOrder: null, - Page: 1, - PageSize: 10); + private static GetAllSystemSuitesQuery Consulta( + Guid? tenantId = null, string criteria = "name", string status = "all", + string? search = null, string? sortBy = null, string? sortOrder = null, + int page = 1, int pageSize = 10) + => new(Page: page, PageSize: pageSize, Search: search, Criteria: criteria, + Status: status, SortBy: sortBy ?? "name", SortOrder: sortOrder ?? "asc", + TenantId: tenantId); + [Fact] + public async Task GetAll_DevuelveLaPaginaQueResolvioLaBase() + { + var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); + var s2 = MakeSystemSuite("SUITE-02", "Suite Beta", "Maintenance"); + ConPagina(total: 37, s1, s2); // 37 en total, 2 en esta página _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns((Guid?)null); var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); - var result = await handler.Handle(query, CancellationToken.None); + var result = await handler.Handle(Consulta(pageSize: 2), CancellationToken.None); Assert.True(result.IsSuccess); - Assert.Equal(2, result.Value.TotalItems); + // El total es el de la BASE, no el de los elementos traídos: eso es lo que se rompía al + // paginar en memoria sobre una lista ya recortada. + Assert.Equal(37, result.Value.TotalItems); + Assert.Equal(19, result.Value.TotalPages); Assert.Equal(2, result.Value.Items.Count); } [Fact] - public async Task GetAll_WithTenantFilter_ReturnsTenantItems() + public async Task GetAll_AcotaLaConsultaAlInquilinoResuelto() { var tenantId = Guid.NewGuid(); - var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); - var list = new List { s1 }; - - _repo.Setup(r => r.GetByTenantIdAsync(tenantId, It.IsAny())) - .ReturnsAsync(list); - - var query = new GetAllSystemSuitesQuery( - TenantId: tenantId, - Criteria: "name", - Status: "all", - Search: null, - SortBy: null, - SortOrder: null, - Page: 1, - PageSize: 10); - + ConPagina(total: 1, MakeSystemSuite("SUITE-01", "Suite Alpha", "Active")); _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns(tenantId); + var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); - var result = await handler.Handle(query, CancellationToken.None); + await handler.Handle(Consulta(tenantId: tenantId), CancellationToken.None); - Assert.True(result.IsSuccess); - Assert.Equal(1, result.Value.TotalItems); - _repo.Verify(r => r.GetByTenantIdAsync(tenantId, It.IsAny()), Times.Once); + _repo.Verify(r => r.GetPageAsync( + It.Is(q => q.TenantId == tenantId), It.IsAny()), Times.Once); } [Fact] - public async Task GetAll_WithStatusFilter_FiltersStatus() + public async Task GetAll_ConEstadoTodos_NoFiltraPorEstado() { - var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); - var s2 = MakeSystemSuite("SUITE-02", "Suite Beta", "Maintenance"); - var list = new List { s1, s2 }; - - _repo.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(list); - - var query = new GetAllSystemSuitesQuery( - TenantId: null, - Criteria: "name", - Status: "Maintenance", - Search: null, - SortBy: null, - SortOrder: null, - Page: 1, - PageSize: 10); - + ConPagina(total: 0); _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns((Guid?)null); + var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); - var result = await handler.Handle(query, CancellationToken.None); + await handler.Handle(Consulta(status: "all"), CancellationToken.None); - Assert.True(result.IsSuccess); - Assert.Equal(1, result.Value.TotalItems); - Assert.Equal("Maintenance", result.Value.Items[0].Status); + // «all» no es un estado: viaja como ausencia de filtro, no como el literal "all". + _repo.Verify(r => r.GetPageAsync( + It.Is(q => q.Status == null), It.IsAny()), Times.Once); } [Fact] - public async Task GetAll_WithSearch_FiltersSearch() + public async Task GetAll_TrasladaEstadoBusquedaYOrdenALaBase() { - var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); - var s2 = MakeSystemSuite("SUITE-02", "Suite Beta", "Active"); - var list = new List { s1, s2 }; - - _repo.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(list); - - var query = new GetAllSystemSuitesQuery( - TenantId: null, - Criteria: "code", - Status: "all", - Search: "SUITE-02", - SortBy: null, - SortOrder: null, - Page: 1, - PageSize: 10); - + ConPagina(total: 0); _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns((Guid?)null); - var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); - var result = await handler.Handle(query, CancellationToken.None); - Assert.True(result.IsSuccess); - Assert.Equal(1, result.Value.TotalItems); - Assert.Equal("SUITE-02", result.Value.Items[0].Code); + var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); + await handler.Handle( + Consulta(criteria: "code", status: "Maintenance", search: "SUITE-02", sortBy: "code", sortOrder: "desc"), + CancellationToken.None); + + _repo.Verify(r => r.GetPageAsync( + It.Is(q => + q.Status == "Maintenance" && + q.SearchField == "code" && + q.Search == "SUITE-02" && + q.SortBy == "code" && + q.Descending), + It.IsAny()), Times.Once); } #endregion } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Template/AddTemplateItemCommandValidatorTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Template/AddTemplateItemCommandValidatorTests.cs new file mode 100644 index 00000000..58b53de8 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Template/AddTemplateItemCommandValidatorTests.cs @@ -0,0 +1,92 @@ +namespace Ums.Application.Test.Authorization.Template; + +using Ums.Application.Authorization.Template.Commands; +using Ums.Domain.Enums; +using BeyondNetCode.Shell.Ddd; +using Xunit; +using System; +using System.Linq; + +/// +/// G-192 — el validador del alta de ítems de plantilla NO puede tener su propia idea de qué +/// destinos existen. La verdad la fija : el agregado +/// PermissionTemplate.AddItem acepta cualquiera de sus valores, Profile.AssignTemplate +/// los copia al permiso del perfil y BuildDomainPermissions proyecta los que apuntan a un +/// recurso de dominio. La copia a mano que vivía aquí negaba Aggregate y Entity, y con +/// ello dejaba sin camino de alta a toda concesión sobre objetos de dominio. +/// +public class AddTemplateItemCommandValidatorTests +{ + private readonly AddTemplateItemCommandValidator _validator = new(); + + private static AddTemplateItemCommand Comando(string targetType) => new( + TemplateId: Guid.NewGuid(), + TargetType: targetType, + TargetId: Guid.NewGuid(), + ActionId: Guid.NewGuid(), + IsAllowed: true, + IsDenied: false); + + public static TheoryData DestinosDelDominio() + { + var data = new TheoryData(); + foreach (var nombre in DomainEnumeration.GetAll().Select(t => t.Name)) + { + data.Add(nombre); + } + + return data; + } + + [Theory] + [MemberData(nameof(DestinosDelDominio))] + public void Validate_AdmiteTodoDestinoDeclaradoPorElDominio(string targetType) + { + var resultado = _validator.Validate(Comando(targetType)); + + Assert.True(resultado.IsValid, $"El destino '{targetType}' lo declara el dominio y el validador lo rechazó: {resultado}"); + } + + [Theory] + [InlineData("Aggregate")] + [InlineData("Entity")] + public void Validate_AdmiteLosObjetosDeDominio(string targetType) + { + // Caso concreto de G-192: son los dos destinos que la lista copiada había dejado fuera. + Assert.True(_validator.Validate(Comando(targetType)).IsValid); + } + + [Theory] + [InlineData("aggregate")] + [InlineData(" Entity ")] + public void Validate_NoDistingueMayusculasNiEspacios_IgualQueElManejador(string targetType) + { + // El validador y el manejador deben resolver el destino con el MISMO criterio: si el + // validador fuese más laxo, el alta pasaría el 400 y moriría después con un error opaco. + Assert.True(_validator.Validate(Comando(targetType)).IsValid); + Assert.NotNull(Ums.Application.Common.DomainEnumerationParser.FromName(targetType)); + } + + [Theory] + [InlineData("DomainResource")] // no es un destino del arco: el recurso se apunta como Aggregate o Entity + [InlineData("Page")] + [InlineData("")] + public void Validate_RechazaDestinosQueElDominioNoDeclara(string targetType) + { + Assert.False(_validator.Validate(Comando(targetType)).IsValid); + } + + [Fact] + public void Mensaje_EnumeraLosDestinosQueElDominioDeclara() + { + // El mensaje se DERIVA del enumerado: si mañana el dominio incorpora un destino, el texto + // lo refleja solo. Antes recitaba cuatro nombres fijos que ya no eran la verdad. + var resultado = _validator.Validate(Comando("NoExiste")); + + var mensaje = string.Join(" ", resultado.Errors.Select(e => e.ErrorMessage)); + foreach (var nombre in DomainEnumeration.GetAll().Select(t => t.Name)) + { + Assert.Contains(nombre, mensaje, StringComparison.Ordinal); + } + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Template/TemplateCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Template/TemplateCommandHandlerTests.cs index 47b6bd97..ff81018a 100644 --- a/src/apps/ums.api/Ums.Application.Test/Authorization/Template/TemplateCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Template/TemplateCommandHandlerTests.cs @@ -16,6 +16,7 @@ namespace Ums.Application.Test.Authorization.Template; public class TemplateCommandHandlerTests { private readonly Mock _repo = new(); + private readonly Mock _profiles = new(); private readonly Mock _uow = new(); private readonly Mock _ctx = new(); private readonly Mock _scopePolicy = new(); @@ -24,9 +25,14 @@ public TemplateCommandHandlerTests() { _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + _uow.Setup(u => u.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); _ctx.Setup(u => u.UserId).Returns("user-001"); _scopePolicy.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success()); + // G-140: por defecto no hay plantillas previas para la terna → el alta usa la versión inicial. + _repo.Setup(r => r.GetByTenantRoleSuiteAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); } private static PermissionTemplate MakeTemplate() @@ -76,6 +82,60 @@ public async Task Create_WithValidCommand_ReturnsSuccess() _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); } + [Fact] + public async Task Create_WhenNoExistingTemplate_AssignsInitialVersion() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + PermissionTemplate? captured = null; + _repo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) + .Callback((t, _) => captured = t) + .Returns(Task.CompletedTask); + + var cmd = new CreatePermissionTemplateCommand( + TenantId: Guid.NewGuid(), + RoleId: Guid.NewGuid(), + SystemSuiteId: Guid.NewGuid()); + + var handler = new CreatePermissionTemplateCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(captured); + Assert.Equal("0.1.0", captured!.Version.GetValue()); + } + + [Fact] + public async Task Create_WhenRoleAlreadyTemplated_AssignsNextVersion() + { + // G-140: existe ya una plantilla v0.1.0 para la terna → el alta debe generar v0.2.0 + // en lugar de colisionar con el índice único. + _ctx.Setup(u => u.UserId).Returns("user-001"); + + var tenantId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + var suiteId = Guid.NewGuid(); + + var existing = PermissionTemplate.Create( + TenantId.Load(tenantId), RoleId.Load(roleId), SystemSuiteId.Load(suiteId), + ActorId.Create("user-001")).Value; + + _repo.Setup(r => r.GetByTenantRoleSuiteAsync(tenantId, roleId, suiteId, It.IsAny())) + .ReturnsAsync(new[] { existing }); + + PermissionTemplate? captured = null; + _repo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) + .Callback((t, _) => captured = t) + .Returns(Task.CompletedTask); + + var cmd = new CreatePermissionTemplateCommand(tenantId, roleId, suiteId); + var handler = new CreatePermissionTemplateCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(captured); + Assert.Equal("0.2.0", captured!.Version.GetValue()); + } + [Fact] public async Task Create_WhenUnauthenticated_ReturnsFailure() { @@ -184,4 +244,103 @@ public async Task Publish_WhenAlreadyPublished_ReturnsFailure() } #endregion + + // ========================================================================= + #region DeletePermissionTemplateCommandHandler (borrado LÓGICO) + // ========================================================================= + + /// + /// Política del propietario: solo existe borrado lógico. El handler ya NO llama a un DELETE + /// físico —antes DeleteAsync hacía Remove y la fila desaparecía—; ahora transiciona + /// el agregado al estado terminal y lo persiste con UpdateAsync. + /// + [Fact] + public async Task Delete_ConPlantillaSinPerfilesVivos_MarcaEstadoTerminalYPersisteConUpdate() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var template = MakeTemplate(); // queda en Draft + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(template); + _profiles.Setup(p => p.CountActiveByTemplateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + + var cmd = new DeletePermissionTemplateCommand(template.Props.Id.GetValue()); + var handler = new DeletePermissionTemplateCommandHandler(_repo.Object, _profiles.Object, _ctx.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(TemplateStatus.Deleted, template.Status); + _repo.Verify(r => r.UpdateAsync(template, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_ConPerfilesVivos_DevuelveErrorDeOperacionBloqueada() + { + // Guardia de cascada → el error viaja codificado como BlockedOperationError, que la capa de + // presentación traduce a 409 con las dependencias que bloquean. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var template = MakeTemplate(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(template); + _profiles.Setup(p => p.CountActiveByTemplateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(3); + + var cmd = new DeletePermissionTemplateCommand(template.Props.Id.GetValue()); + var handler = new DeletePermissionTemplateCommandHandler(_repo.Object, _profiles.Object, _ctx.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.True(BlockedOperationError.TryDecode(result.Error, out var code, out var deps)); + Assert.Equal(DomainErrors.Authorization.TemplateHasActiveProfiles, code); + Assert.Equal(3, deps.Single().Count); + Assert.Equal("Profile", deps.Single().EntityType); + // Nada se persiste y el agregado sigue vivo: el bloqueo es previo a cualquier escritura. + Assert.NotEqual(TemplateStatus.Deleted, template.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_CuandoLaReferenciaYaNoEstaViva_SiPermiteEliminar() + { + // Contrapartida de la prueba anterior: los perfiles ya desactivados no cuentan como referencia + // (CountActiveByTemplateAsync solo cuenta los activos), así que el borrado procede. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var template = MakeTemplate(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(template); + _profiles.SetupSequence(p => p.CountActiveByTemplateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1) // primer intento: perfil vivo + .ReturnsAsync(0); // tras desactivarlo: ya no bloquea + + var cmd = new DeletePermissionTemplateCommand(template.Props.Id.GetValue()); + var handler = new DeletePermissionTemplateCommandHandler(_repo.Object, _profiles.Object, _ctx.Object); + + var bloqueado = await handler.Handle(cmd, CancellationToken.None); + Assert.True(bloqueado.IsFailure); + + var permitido = await handler.Handle(cmd, CancellationToken.None); + Assert.True(permitido.IsSuccess); + Assert.Equal(TemplateStatus.Deleted, template.Status); + } + + [Fact] + public async Task Delete_CuandoYaEstaEliminada_DevuelveNoEncontrada() + { + // Las lecturas ocultan lo eliminado: el segundo DELETE no encuentra nada → 404, no un 500 ni + // un 204 mentiroso. Se modela con el repositorio devolviendo null, que es lo que hace el store + // real al filtrar por estado. + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((PermissionTemplate?)null); + + var cmd = new DeletePermissionTemplateCommand(Guid.NewGuid()); + var handler = new DeletePermissionTemplateCommandHandler(_repo.Object, _profiles.Object, _ctx.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuditMetadataSanitizerTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuditMetadataSanitizerTests.cs new file mode 100644 index 00000000..29446e23 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuditMetadataSanitizerTests.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using FluentAssertions; +using Ums.Application.Common.Aop; +using Xunit; + +namespace Ums.Application.Test.Common.Aop; + +/// +/// G-040 (residual #5, FR-072): el saneador de metadata de auditoría redacta los valores de las +/// claves sensibles (hash/PIN/llave/token/secreto/credencial) antes de persistir en una traza +/// append-only e inmutable (G-081), conservando la clave y sin tocar los datos legítimos. +/// +public sealed class AuditMetadataSanitizerTests +{ + private const string Redacted = AuditMetadataSanitizer.RedactionPlaceholder; + + // ── Nula / vacía / en blanco → sin fallo, se devuelve tal cual ── + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void NullOrBlankMetadata_IsReturnedUnchanged(string? metadata) + { + AuditMetadataSanitizer.Sanitize(metadata).Should().Be(metadata); + } + + // ── Clave sensible → valor redactado (conservando la clave) ── + [Theory] + [InlineData("password")] + [InlineData("Password")] + [InlineData("PasswordHash")] + [InlineData("pwd")] + [InlineData("userPwd")] + [InlineData("hash")] + [InlineData("passwordHash")] + [InlineData("secret")] + [InlineData("clientSecret")] + [InlineData("token")] + [InlineData("accessToken")] + [InlineData("refreshToken")] + [InlineData("Authorization")] + [InlineData("apiKey")] + [InlineData("api_key")] + [InlineData("apikey")] + [InlineData("privateKey")] + [InlineData("signingKey")] + [InlineData("pin")] + [InlineData("userPin")] + [InlineData("credential")] + [InlineData("credentials")] + [InlineData("authHeader")] + public void SensitiveKey_ValueIsRedacted_KeyPreserved(string sensitiveKey) + { + var input = JsonSerializer.Serialize(new Dictionary + { + [sensitiveKey] = "el-secreto-en-claro", + }); + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + doc.RootElement.TryGetProperty(sensitiveKey, out var value).Should().BeTrue("la clave se conserva para trazabilidad"); + value.GetString().Should().Be(Redacted); + result.Should().NotContain("el-secreto-en-claro"); + } + + // ── Clave normal → intacta (no se redacta ni se pierde) ── + [Theory] + [InlineData("handler")] + [InlineData("Method")] + [InlineData("requestType")] + [InlineData("sessionTrackingId")] + [InlineData("correlationId")] + [InlineData("traceId")] + [InlineData("spanId")] + [InlineData("userName")] + [InlineData("shippingCost")] // contiene «pin» como subcadena, pero no como token → no se redacta + [InlineData("monkey")] // contiene «key» como subcadena, pero no como token → no se redacta + [InlineData("keyboardLayout")] // «key» pegado a más letras → no es token «key» + public void NormalKey_ValueIsPreserved(string normalKey) + { + var input = JsonSerializer.Serialize(new Dictionary + { + [normalKey] = "valor-legitimo", + }); + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + doc.RootElement.GetProperty(normalKey).GetString().Should().Be("valor-legitimo"); + result.Should().NotContain(Redacted); + } + + // ── Mezcla: solo lo sensible se redacta; lo legítimo permanece ── + [Fact] + public void MixedMetadata_RedactsOnlySensitiveValues() + { + var input = JsonSerializer.Serialize(new + { + Handler = "CreateUserHandler", + SessionTrackingId = "sess-123", + Password = "hunter2", + ApiKey = "sk-live-abcdef", + }); + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + var root = doc.RootElement; + root.GetProperty("Handler").GetString().Should().Be("CreateUserHandler"); + root.GetProperty("SessionTrackingId").GetString().Should().Be("sess-123"); + root.GetProperty("Password").GetString().Should().Be(Redacted); + root.GetProperty("ApiKey").GetString().Should().Be(Redacted); + result.Should().NotContain("hunter2").And.NotContain("sk-live-abcdef"); + } + + // ── Clave sensible en objeto anidado → se redacta en profundidad ── + [Fact] + public void NestedSensitiveKey_IsRedacted() + { + var input = "{\"outer\":{\"innerToken\":\"leak-me\",\"safe\":\"keep-me\"}}"; + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + var outer = doc.RootElement.GetProperty("outer"); + outer.GetProperty("innerToken").GetString().Should().Be(Redacted); + outer.GetProperty("safe").GetString().Should().Be("keep-me"); + result.Should().NotContain("leak-me"); + } + + // ── Subárbol bajo clave sensible → se redacta entero, sin descender ── + [Fact] + public void SensitiveKeyOverObject_RedactsWholeSubtree() + { + var input = "{\"credentials\":{\"user\":\"admin\",\"pwd\":\"1234\"}}"; + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + doc.RootElement.GetProperty("credentials").ValueKind.Should().Be(JsonValueKind.String); + doc.RootElement.GetProperty("credentials").GetString().Should().Be(Redacted); + result.Should().NotContain("admin").And.NotContain("1234"); + } + + // ── Arreglo de objetos con claves sensibles → cada elemento se sanea ── + [Fact] + public void ArrayOfObjects_SanitizesEachElement() + { + var input = "{\"items\":[{\"name\":\"a\",\"secret\":\"s1\"},{\"name\":\"b\",\"secret\":\"s2\"}]}"; + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + var items = doc.RootElement.GetProperty("items"); + items[0].GetProperty("name").GetString().Should().Be("a"); + items[0].GetProperty("secret").GetString().Should().Be(Redacted); + items[1].GetProperty("secret").GetString().Should().Be(Redacted); + result.Should().NotContain("s1").And.NotContain("s2"); + } + + // ── JSON vacío o sin claves sensibles → equivalente al de entrada ── + [Fact] + public void EmptyJsonObject_IsReturnedAsEmptyObject() + { + var result = AuditMetadataSanitizer.Sanitize("{}"); + using var doc = JsonDocument.Parse(result!); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Object); + doc.RootElement.EnumerateObject().Should().BeEmpty(); + } + + // ── No-JSON → sin fallo, devuelto tal cual (ambas vías reales garantizan JSON válido) ── + [Fact] + public void NonJsonMetadata_IsReturnedUnchanged() + { + const string notJson = "esto no es json"; + AuditMetadataSanitizer.Sanitize(notJson).Should().Be(notJson); + } + + // ── Literal JSON null → sin fallo ── + [Fact] + public void JsonNullLiteral_IsReturnedUnchanged() + { + AuditMetadataSanitizer.Sanitize("null").Should().Be("null"); + } + + // ── IsSensitiveKey: contrato explícito de la lista de patrones ── + [Theory] + [InlineData("password", true)] + [InlineData("PASSWORD", true)] + [InlineData("passwordHash", true)] + [InlineData("pwd", true)] + [InlineData("api_key", true)] + [InlineData("apiKey", true)] + [InlineData("bearerToken", true)] + [InlineData("pin", true)] + [InlineData("key", true)] + [InlineData("cred", true)] + [InlineData("authorization", true)] + [InlineData("handler", false)] + [InlineData("correlationId", false)] + [InlineData("shipping", false)] + [InlineData("monkey", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsSensitiveKey_MatchesPatternList(string? key, bool expected) + { + AuditMetadataSanitizer.IsSensitiveKey(key).Should().Be(expected); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuthorizationAspectTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuthorizationAspectTests.cs index 22f74063..710ffa8d 100644 --- a/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuthorizationAspectTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuthorizationAspectTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1186 using System; using System.Reflection; using BeyondNetCode.Shell.Aop; @@ -26,6 +27,10 @@ public AuthorizationAspectTests() _joinPointMock.Setup(j => j.MethodInfo).Returns(targetType.GetMethod(nameof(CreateUserCommandHandler.Handle))!); } + // --------------------------------------------------------------------- + // Camino permitido (cobertura preexistente) + // --------------------------------------------------------------------- + [Fact] public void Apply_WhenNoAttribute_Proceeds() { @@ -50,20 +55,147 @@ public void Apply_WithAttributeAndPermission_Proceeds() // Act _sut.Apply(_joinPointMock.Object); - + // Assert _joinPointMock.Verify(j => j.Proceed(), Times.Once); } + + // --------------------------------------------------------------------- + // Ruta de DENEGACIÓN (G-080). El aspecto es el punto de control de + // acceso en runtime: si falta el permiso requerido debe DENEGAR + // lanzando UnauthorizedAccessException y NO invocar Proceed(). + // Evidencia del mecanismo real de negativa: + // AuthorizationAspect.cs:68-72 → throw new UnauthorizedAccessException(...) + // --------------------------------------------------------------------- + + [Fact] + public void Apply_WithAttributeAndMissingPermission_DeniesAndDoesNotProceed() + { + // Arrange + // CreateUserCommandHandler lleva [AuthorizationAspect]; el aspecto + // infiere el permiso "user:create" por convención de nombre. + // El contexto NO concede ese permiso (HasPermission → false por defecto). + _userContextMock.Setup(u => u.HasPermission("user:create")).Returns(false); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert: deniega con UnauthorizedAccessException y no procede al handler. + act.Should().Throw() + .WithMessage("*user:create*"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + [Fact] + public void Apply_WithGrantForUnrelatedPermission_DeniesRequiredAction() + { + // Arrange + // El modelo de permisos (UserContext.HasPermission) es allow-list puro + // (grant-only, default-deny): no existe precedencia "deny-wins" porque no + // hay lista de denegación explícita. La propiedad de seguridad equivalente + // y verificable aquí es que poseer un permiso NO relacionado ("user:read") + // jamás autoriza una acción distinta ("user:create"): no hay fuga de + // privilegios entre permisos. + _userContextMock.Setup(u => u.HasPermission("user:read")).Returns(true); + _userContextMock.Setup(u => u.HasPermission("user:create")).Returns(false); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert + act.Should().Throw() + .WithMessage("*user:create*"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + [Fact] + public void Apply_WithExplicitResourceActionAndMissingPermission_Denies() + { + // Arrange + // Handler con permiso EXPLÍCITO [AuthorizationAspect("branch", "delete")] + // (ejercita la rama de códigos explícitos de AuthorizationAspect.cs:41-42, + // distinta de la inferencia por convención). Simula además el aislamiento + // de scope: el contexto posee un permiso de OTRO recurso/rama ("user:create") + // pero eso no autoriza "branch:delete". + var targetType = typeof(CloseBranchCommandHandler); + _joinPointMock.Setup(j => j.TargetType).Returns(targetType); + _joinPointMock.Setup(j => j.MethodInfo).Returns(targetType.GetMethod(nameof(CloseBranchCommandHandler.Handle))!); + + _userContextMock.Setup(u => u.HasPermission("user:create")).Returns(true); // permiso de otro recurso/rama + _userContextMock.Setup(u => u.HasPermission("branch:delete")).Returns(false); // el requerido: no concedido + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert + act.Should().Throw() + .WithMessage("*branch:delete*"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + // --------------------------------------------------------------------- + // DEFECTO DE SEGURIDAD (fail-open) — GUARDA DE REGRESIÓN (G-098, CORREGIDO). + // + // Si un handler lleva [AuthorizationAspect] (intención explícita de + // protegerlo) pero el aspecto NO puede resolver el permiso —el nombre no + // casa la convención Create/Update/Delete/Get/List y no se pasaron + // ResourceCode/ActionCode— la implementación DEBE caer en el `else` de + // AuthorizationAspect y DENEGAR (fail-closed): un método marcado como + // protegido no puede ejecutarse sin control de acceso. + // + // Antes de G-098 el aspecto ejecutaba Proceed() SIN comprobar permiso + // (fail-open), una vía real de escalada de privilegios. Esta prueba fija el + // comportamiento SEGURO (debe DENEGAR y NO proceder) y actúa como testigo de + // no-regresión. NO debe "arreglarse" debilitando la aserción. + // --------------------------------------------------------------------- + + [Fact] + public void Apply_WithAttributeButUninferrablePermission_ShouldDenyButFailsOpen() + { + // Arrange + // ApproveBranchCommandHandler: lleva [AuthorizationAspect] sin códigos y + // el verbo "Approve" no casa la convención → el permiso queda indeterminado. + var targetType = typeof(ApproveBranchCommandHandler); + _joinPointMock.Setup(j => j.TargetType).Returns(targetType); + _joinPointMock.Setup(j => j.MethodInfo).Returns(targetType.GetMethod(nameof(ApproveBranchCommandHandler.Handle))!); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert (comportamiento SEGURO exigido: DENIEGA fail-closed y NO procede) + act.Should().Throw( + "un handler explícitamente protegido cuyo permiso no puede resolverse DEBE denegarse (fail-closed)"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } } // Clases simuladas para el test [AuthorizationAspect] -public class CreateUserCommandHandler +public class CreateUserCommandHandler +{ + public void Handle() { } +} + +public class UnprotectedCommandHandler { public void Handle() { } } -public class UnprotectedCommandHandler +// Permiso explícito (recurso/acción declarados en el atributo). Es un DOBLE local del aspecto, no +// el manejador real; se renombra a «Close» para no dejar vivo el vocabulario del borrado físico que +// ADR-0164 retiró, aunque el permiso siga llamándose "branch:delete" (el verbo HTTP es DELETE). +[AuthorizationAspect("branch", "delete")] +public class CloseBranchCommandHandler { public void Handle() { } } + +// Protegido pero con permiso indeterminable (verbo fuera de convención, +// sin ResourceCode/ActionCode): dispara la rama fail-open del aspecto. +[AuthorizationAspect] +public class ApproveBranchCommandHandler +{ + public void Handle() { } +} + +#pragma warning restore S1186 diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Aop/TenantValidationAspectTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Aop/TenantValidationAspectTests.cs new file mode 100644 index 00000000..ee54cee4 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Common/Aop/TenantValidationAspectTests.cs @@ -0,0 +1,197 @@ +#pragma warning disable S1186 +using System; +using BeyondNetCode.Shell.Aop; +using FluentAssertions; +using Moq; +using Ums.Application.Common.Aop; +using Ums.Application.Common.Interfaces; +using Xunit; + +namespace Ums.Application.Test.Common.Aop; + +public class TenantValidationAspectTests +{ + private readonly Mock _userContextMock; + private readonly TenantValidationAspect _sut; + private readonly Mock _joinPointMock; + + public TenantValidationAspectTests() + { + _userContextMock = new Mock(); + _sut = new TenantValidationAspect(_userContextMock.Object); + _joinPointMock = new Mock(); + } + + private void SetHandler(Type targetType, string methodName) + { + _joinPointMock.Setup(j => j.TargetType).Returns(targetType); + _joinPointMock.Setup(j => j.MethodInfo).Returns(targetType.GetMethod(methodName)!); + } + + // --------------------------------------------------------------------- + // Camino sin opt-in: el handler NO lleva [TenantValidationAspect] → el + // aspecto no valida y procede (el atributo se resuelve por MethodInfo). + // --------------------------------------------------------------------- + + [Fact] + public void Apply_WhenNoAttribute_Proceeds() + { + // Arrange + SetHandler(typeof(UnscopedHandler), nameof(UnscopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "T1" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T2"); // aunque difiera, sin atributo no se valida + + // Act + _sut.Apply(_joinPointMock.Object); + + // Assert + _joinPointMock.Verify(j => j.Proceed(), Times.Once); + } + + // --------------------------------------------------------------------- + // Flujo legítimo SIN inquilino (G-102): actor global / pre-inquilino + // (signup, creación de inquilino, admin de plataforma). El usuario carece + // de TenantId → no hay frontera que cruzar → PROCEDE a propósito. + // Esta prueba fija la excepción legítima: endurecer el aspecto NO debe + // romper los flujos sin inquilino (regla de dominio, no fail-open). + // --------------------------------------------------------------------- + + [Fact] + public void Apply_TenantlessCaller_Proceeds() + { + // Arrange: atributo presente, pero el usuario no está vinculado a un inquilino. + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "T1" } }); + _userContextMock.Setup(u => u.TenantId).Returns((string?)null); + + // Act + _sut.Apply(_joinPointMock.Object); + + // Assert + _joinPointMock.Verify(j => j.Proceed(), Times.Once); + } + + // --------------------------------------------------------------------- + // Camino permitido: usuario e inquilino de la petición coinciden. + // --------------------------------------------------------------------- + + [Fact] + public void Apply_MatchingTenant_Proceeds() + { + // Arrange + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "T1" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T1"); + + // Act + _sut.Apply(_joinPointMock.Object); + + // Assert + _joinPointMock.Verify(j => j.Proceed(), Times.Once); + } + + // --------------------------------------------------------------------- + // Ruta de DENEGACIÓN por cruce de inquilino (comportamiento existente): + // usuario de un inquilino apunta a una petición de OTRO inquilino → DENIEGA. + // --------------------------------------------------------------------- + + [Fact] + public void Apply_CrossTenant_DeniesAndDoesNotProceed() + { + // Arrange + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "T2" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T1"); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert + act.Should().Throw(); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + // --------------------------------------------------------------------- + // DEFECTO DE SEGURIDAD (soft fail-open) — GUARDA DE REGRESIÓN (G-102). + // + // Un handler VINCULADO a un inquilino (usuario con TenantId) marcado con + // [TenantValidationAspect] (intención explícita de validar inquilino) cuya + // petición NO permite determinar el inquilino objetivo (TenantId nulo/vacío, + // o el tipo de la petición carece de la propiedad TenantId) DEBE DENEGAR + // (fail-closed): el inquilino no puede confirmarse y dejar pasar sería un + // cruce indebido de inquilinos. + // + // Antes de G-102 el aspecto solo denegaba cuando AMBOS inquilinos estaban + // presentes y diferían; si el inquilino de la petición no podía determinarse, + // ejecutaba Proceed() SIN validar (soft fail-open). Esta prueba fija el + // comportamiento SEGURO (DEBE DENEGAR y NO proceder) y actúa como testigo de + // no-regresión. NO debe "arreglarse" debilitando la aserción (p. ej. esperando + // Proceed): eso reintroduciría el fail-open. + // --------------------------------------------------------------------- + + [Fact] + public void Apply_TenantBoundCallerButRequestTenantEmpty_DeniesFailClosed() + { + // Arrange: usuario con inquilino "T1"; la petición tiene TenantId vacío + // (inquilino objetivo indeterminado). + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T1"); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert (comportamiento SEGURO exigido: DENIEGA fail-closed y NO procede) + act.Should().Throw( + "un handler tenant-scoped cuyo inquilino objetivo no puede confirmarse DEBE denegarse (fail-closed)"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + [Fact] + public void Apply_TenantBoundCallerButRequestHasNoTenantIdProperty_DeniesFailClosed() + { + // Arrange: usuario con inquilino "T1"; el tipo de la petición NO expone + // la propiedad TenantId → el inquilino objetivo no puede determinarse. + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new NoTenantRequest { Name = "x" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T1"); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert (fail-closed: no hay inquilino que confirmar → DENIEGA y NO procede) + act.Should().Throw( + "si la petición no expone TenantId, el inquilino no puede confirmarse y DEBE denegarse (fail-closed)"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } +} + +// --------------------------------------------------------------------------- +// Clases simuladas. GetAttribute (AbstractAspect) resuelve el atributo por +// MethodInfo → [TenantValidationAspect] se coloca en el MÉTODO Handle. +// --------------------------------------------------------------------------- + +public class TenantScopedRequest +{ + public string? TenantId { get; set; } +} + +public class NoTenantRequest +{ + public string Name { get; set; } = string.Empty; +} + +// Marcado como tenant-scoped: dispara la validación de inquilino del aspecto. +public class TenantScopedHandler +{ + [TenantValidationAspect] + public void Handle(TenantScopedRequest request) { } +} + +// Sin marcar: el aspecto no valida y procede. +public class UnscopedHandler +{ + public void Handle(TenantScopedRequest request) { } +} + +#pragma warning restore S1186 diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Reliability/IntegrationEventOutboxDispatchTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/IntegrationEventOutboxDispatchTests.cs new file mode 100644 index 00000000..a5db323d --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/IntegrationEventOutboxDispatchTests.cs @@ -0,0 +1,236 @@ +namespace Ums.Application.Test.Common.Reliability; + +using Ums.Application.Common.Interfaces; +using Ums.Application.IGA.RolePromotion.Commands; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using Ums.Domain.Kernel.ValueObjects; +using Xunit; + +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Prueba EXPLÍCITA del despacho POST-commit de eventos de INTEGRACIÓN por el Transactional Outbox +/// (D-016 / ADR-0098 D4/D7). Complementa la cobertura indirecta de +/// (atomicidad del change set) y de +/// FunctionalTransactionTests (desenlace de la transacción funcional): aquí se fija, de forma +/// verificable, el invariante «no se publica un evento de integración antes del commit del agregado». +/// +/// Mecanismo real que se modela (ver UmsPlatformDbContext + MassTransitIntegrationEventPublisher +/// con UseBusOutbox()): ExecuteRolePromotionCommandHandler publica el evento por el puerto +/// ANTES de SaveEntitiesAsync. Bajo kind/prod ese publish +/// solo ESTACIONA el mensaje en las tablas del outbox dentro del mismo change set del agregado; el +/// servicio de entrega lo despacha al consumidor DESPUÉS del commit. Si la transacción del agregado hace +/// rollback, la fila del outbox se revierte con el resto del change set y el evento NUNCA se entrega. +/// +/// Limitación del harness (documentada, no oculta): las pruebas de aplicación usan dobles en memoria y +/// no ejercitan el bus-outbox EF real ni el bróker, así que el timing físico de la entrega no +/// es observable aquí. Se cubre el invariante al nivel donde SÍ es determinista: un doble del outbox +/// () que estaciona en el publish y solo entrega al confirmar la +/// transacción del agregado (o descarta al hacer rollback). La entrega física post-commit sobre el +/// bus-outbox real se valida en la suite de integración con Testcontainers. +/// +public sealed class IntegrationEventOutboxDispatchTests +{ + private readonly Mock _repo = new(); + private readonly Mock _scope = new(); + private readonly Mock _uow = new(); + private readonly Mock _ctx = new(); + + private readonly Guid _tenant = Guid.NewGuid(); + private readonly Guid _target = Guid.NewGuid(); + private readonly Guid _requester = Guid.NewGuid(); + private readonly Guid _approver = Guid.NewGuid(); + private readonly Guid _executor = Guid.NewGuid(); + private readonly Guid _currentRole = Guid.NewGuid(); + private readonly Guid _targetRole = Guid.NewGuid(); + + public IntegrationEventOutboxDispatchTests() + { + _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + // Alcance de inquilino no restrictivo por defecto (administrador interno). + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + } + + // ========================================================================= + // Doble del Transactional Outbox + // ========================================================================= + + /// + /// Doble de prueba que reproduce la semántica del Transactional Outbox de MassTransit + /// (UseBusOutbox()): solo ESTACIONA el evento dentro de la + /// transacción del agregado; la entrega al consumidor ocurre en + /// (post-commit). Un revierte lo estacionado — nunca se entrega. + /// El registra el orden para verificar que el publish precede al commit. + /// + private sealed class TransactionalOutboxSpy(IList timeline) : IIntegrationEventPublisher + { + private readonly List _staged = []; + private readonly List _delivered = []; + + /// Eventos ya ENTREGADOS al consumidor/bróker (post-commit). + public IReadOnlyList Delivered => _delivered; + + /// Eventos aún estacionados en el outbox (pendientes de commit). + public int StagedCount => _staged.Count; + + /// Nº de eventos ya entregados en el instante del último publish (debe ser 0: nada pre-commit). + public int DeliveredCountAtLastPublish { get; private set; } = -1; + + public Task PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + // El outbox NO entrega aquí: estaciona el mensaje en el change set del agregado. + DeliveredCountAtLastPublish = _delivered.Count; + timeline.Add("estacionar"); + _staged.Add(integrationEvent); + return Task.CompletedTask; + } + + /// La transacción del agregado CONFIRMÓ: el servicio de entrega despacha lo estacionado. + public void CommitTransaction() + { + timeline.Add("commit"); + _delivered.AddRange(_staged); + _staged.Clear(); + } + + /// La transacción del agregado hizo ROLLBACK: las filas del outbox se revierten. + public void RollbackTransaction() + { + timeline.Add("rollback"); + _staged.Clear(); + } + } + + // ========================================================================= + // O01 — Éxito: el evento se estaciona en el outbox y se entrega SOLO tras el commit + // ========================================================================= + + /// O01: en el camino feliz, ExecuteRolePromotionCommandHandler publica el evento de + /// integración (estacionado) ANTES de SaveEntitiesAsync, y la entrega al consumidor ocurre + /// únicamente al confirmar la transacción del agregado. En el instante del publish —dentro de la + /// tx— no se había entregado nada: no hay despacho a mitad de la transacción del agregado. + [Fact] + public async Task El_evento_de_integracion_se_estaciona_y_se_entrega_solo_tras_el_commit() + { + var timeline = new List(); + var outbox = new TransactionalOutboxSpy(timeline); + + ActingAs(_executor); + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + // El commit de la transacción del agregado (SaveEntitiesAsync) confirma el change set —agregado + // + fila del outbox— y habilita la entrega POST-commit. + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .Callback(() => outbox.CommitTransaction()) + .ReturnsAsync(true); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, outbox); + var result = await handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Executed", req.Status.Name); + + // 1) El evento se ESTACIONA antes del commit y se ENTREGA en el commit (nunca a mitad de la tx). + Assert.Equal(["estacionar", "commit"], timeline); + // 2) En el instante del publish (dentro de la tx) NADA se había entregado aún. + Assert.Equal(0, outbox.DeliveredCountAtLastPublish); + // 3) Tras el commit, el evento se entregó exactamente una vez y el outbox quedó vacío. + Assert.Single(outbox.Delivered); + Assert.IsType(outbox.Delivered[0]); + Assert.Equal(0, outbox.StagedCount); + } + + // ========================================================================= + // O02 — Rollback: si el commit del agregado falla, el evento NO se entrega + // ========================================================================= + + /// O02: si SaveEntitiesAsync falla (la transacción del agregado hace rollback), el + /// evento —ya estacionado en el outbox dentro de la tx— se revierte con el resto del change set y + /// NUNCA llega al consumidor. Es el invariante clave: no hay evento de integración sin commit del + /// agregado. + [Fact] + public async Task Si_el_commit_del_agregado_falla_el_evento_de_integracion_no_se_entrega() + { + var timeline = new List(); + var outbox = new TransactionalOutboxSpy(timeline); + + ActingAs(_executor); + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + // El commit falla → rollback: las filas del outbox se revierten con el change set del agregado + // (atomicidad del outbox transaccional, ADR-0098 D4/D7). + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .Callback(() => outbox.RollbackTransaction()) + .ThrowsAsync(new InvalidOperationException("Fallo de infraestructura al confirmar la transacción del agregado.")); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, outbox); + + await Assert.ThrowsAsync(() => + handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None)); + + // El evento se estacionó dentro de la tx, pero al fallar el commit NUNCA se entrega. + Assert.Equal(["estacionar", "rollback"], timeline); + Assert.Empty(outbox.Delivered); + Assert.Equal(0, outbox.StagedCount); + } + + // ========================================================================= + // O03 — Rechazo de dominio: nada se estaciona ni se entrega, ni se intenta commit + // ========================================================================= + + /// O03: cuando la operación del agregado se rechaza antes de publicar (guarda SoD de + /// ADR-UMS-096: el ejecutor coincide con el aprobador), el handler retorna temprano — no se estaciona + /// evento alguno en el outbox, no se entrega nada y no se intenta el commit. Confirma que el evento + /// de integración depende de una transición de estado válida del agregado. + [Fact] + public async Task Si_la_operacion_del_agregado_se_rechaza_no_se_estaciona_ni_se_entrega_ningun_evento() + { + var timeline = new List(); + var outbox = new TransactionalOutboxSpy(timeline); + + // El ejecutor coincide con el aprobador → viola SoD (INV-RPR3 endurecida, ADR-UMS-096). + ActingAs(_approver); + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, outbox); + var result = await handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Approved", req.Status.Name); // no transiciona + + // Ni se estaciona ni se entrega evento alguno, y no se intenta commit. + Assert.Empty(timeline); + Assert.Empty(outbox.Delivered); + Assert.Equal(0, outbox.StagedCount); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private void ActingAs(Guid userId) => _ctx.Setup(c => c.UserId).Returns(userId.ToString()); + + private RolePromotionRequestAggregate NewDraft() => + RolePromotionRequestAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + UserId.Load(_requester), + RoleId.Load(_currentRole), + RoleId.Load(_targetRole), + ActorId.Create(_requester.ToString())).Value; + + private RolePromotionRequestAggregate InApproved(int riskScore = 30) + { + var req = NewDraft(); + req.Submit(RiskScore.Create(riskScore).Value, ActorId.Create(_requester.ToString())); + req.ConfirmEligibility(true, ActorId.Create("sistema")); + req.ManagerApprove(UserId.Load(_approver), ActorId.Create(_approver.ToString())); + return req; + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs index 989371c0..878a3044 100644 --- a/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs @@ -1,5 +1,7 @@ namespace Ums.Application.Test.Common.Reliability; +#pragma warning disable S125 + using Ums.Application.Common.Interfaces; using Ums.Application.Configuration.AppConfiguration.Commands; using Ums.Application.Configuration.Services; @@ -394,8 +396,11 @@ public async Task T12_AuditFields_OnCreate_CarryActorFromUserContext() var audit = capturedAggregate!.Props.Audit.GetValue(); Assert.Equal("audited-actor-007", audit.CreatedBy); - // AuditValueObject.Create uses DateTime.Today.ToUniversalTime() (midnight UTC) - Assert.Equal(DateTime.Today.ToUniversalTime().Date, audit.CreatedAt.Date); + // El audit se estampa con DateTime.UtcNow: comparar contra la fecha UTC, no la + // local. (Antes se comparaba con DateTime.Today.ToUniversalTime().Date —derivado + // de la fecha local—, lo que fallaba en la ventana UTC 00:00–05:00, cuando la + // fecha local de Lima (UTC-5) aún es la del día anterior.) + Assert.Equal(DateTime.UtcNow.Date, audit.CreatedAt.Date); } // ========================================================================= diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Services/TenantScopePolicyTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Services/TenantScopePolicyTests.cs index 85e0dde7..0b53a617 100644 --- a/src/apps/ums.api/Ums.Application.Test/Common/Services/TenantScopePolicyTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Common/Services/TenantScopePolicyTests.cs @@ -28,6 +28,19 @@ private static Tenant BuildTenant(bool isManagementOwner) tenantId: TenantId.Load(Guid.Parse("11111111-1111-1111-1111-111111111111")), isManagementOwner: isManagementOwner).Value; + [Fact] + public void ResolveQueryScope_ReturnsNull_WhenInternalAdmin() + { + // Internal admin has cross-tenant visibility: no scope filter, even when its own + // OrganizationId is populated (e.g. the INTERNAL_ADMIN dev tenant). + _tenantContext.SetupGet(x => x.IsInternalAdmin).Returns(true); + _tenantContext.SetupGet(x => x.OrganizationId).Returns(Guid.NewGuid()); + + var result = CreateSut().ResolveQueryScope(); + + Assert.Null(result); + } + [Fact] public void ResolveQueryScope_ReturnsOrganizationId_WhenContextHasOne() { diff --git a/src/apps/ums.api/Ums.Application.Test/Configuration/AppConfiguration/AppConfigurationCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Configuration/AppConfiguration/AppConfigurationCommandHandlerTests.cs index 9df80fad..dab16159 100644 --- a/src/apps/ums.api/Ums.Application.Test/Configuration/AppConfiguration/AppConfigurationCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Configuration/AppConfiguration/AppConfigurationCommandHandlerTests.cs @@ -327,6 +327,93 @@ public async Task Archive_WhenUnauthenticated_ReturnsFailure() #endregion + // ========================================================================= + #region DeleteAppConfigurationCommandHandler (borrado LÓGICO) + // ========================================================================= + + [Fact] + public async Task Delete_WhenExists_MarcaEliminadaYNoRetiraLaFila() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var config = MakePublished(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(config); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + // La política del propietario, expresada como aserción: el agregado pasa al estado terminal + // y se persiste con UpdateAsync. El repositorio no expone ninguna baja física. + Assert.Equal(ConfigStatus.Deleted, config.Status); + _repo.Verify(r => r.UpdateAsync(config, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_DesalojaLaCache() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var config = MakePublished(); // configuración de inquilino → recarga acotada al inquilino + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(config); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + // Si la configuración deja de resolver, la caché tiene que enterarse igual: la fila sobrevive + // pero su valor ya no participa en la resolución. + _configProvider.Verify( + p => p.ReloadTenantAsync(config.Props.TenantId!.GetValue(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Delete_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((AppConfigurationAggregate?)null); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("not found", result.Error); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_SobreYaEliminada_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var config = MakePublished(); + config.Delete(ActorId.Create("user-000")); // ya eliminada por otro actor + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(config); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.AppConfigAlreadyDeleted, result.Error); + } + + [Fact] + public async Task Delete_WhenUnauthenticated_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns(""); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("Authenticated user is required", result.Error); + } + + #endregion + // ========================================================================= #region UpdateAppConfigurationCommandHandler // ========================================================================= diff --git a/src/apps/ums.api/Ums.Application.Test/Configuration/AvisoDeConfiguracionEntreReplicasTests.cs b/src/apps/ums.api/Ums.Application.Test/Configuration/AvisoDeConfiguracionEntreReplicasTests.cs new file mode 100644 index 00000000..b0d50e11 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Configuration/AvisoDeConfiguracionEntreReplicasTests.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StackExchange.Redis; +using Ums.Infrastructure.Configuration; +using Xunit; + +namespace Ums.Application.Test.Configuration; + +/// +/// El aviso de cambio de configuración sale SIEMPRE, también cuando este pod no tenía nada +/// guardado de ese inquilino. +/// +/// POR QUÉ EXISTE. El aviso estaba condicionado a que la invalidación local hubiera quitado +/// algo, y esa condición no se cumple justo en el caso que importa: un inquilino dado de alta +/// DESPUÉS del arranque no está en la memoria del pod, así que no había nada que quitar y el aviso +/// nunca salía. Los demás pods no se enteraban de su configuración nunca, hasta reiniciar. +/// Medido en vivo con dos réplicas: al publicar una capacidad para un inquilino recién creado, el +/// pod que la publicó la aplicaba y el otro no. +/// +/// La prueba mira el CANAL, no el estado local: lo que se rompió no fue el borrado —ese +/// funcionaba— sino el aviso. Comprobar que el diccionario quedó vacío habría pasado en verde con +/// el defecto puesto. +/// +public sealed class AvisoDeConfiguracionEntreReplicasTests +{ + private static (RedisConfigurationCache Cache, List Avisos) CrearCache() + { + var avisos = new List(); + + // `PublishAsync` y no `Publish`: el aviso se emite sin esperar respuesta para no bloquear + // a quien escribe la configuración. Espiar el método equivocado deja la prueba en verde + // pase lo que pase, porque la lista nunca se llena. + var suscriptor = new Mock(); + suscriptor + .Setup(s => s.PublishAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((canal, _, _) => avisos.Add(canal.ToString())) + .ReturnsAsync(1L); + + var redis = new Mock(); + redis.Setup(r => r.GetSubscriber(It.IsAny())).Returns(suscriptor.Object); + + var cache = new RedisConfigurationCache( + redis.Object, + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider().GetRequiredService()); + + return (cache, avisos); + } + + [Fact] + public void Invalidar_Un_Inquilino_Que_Este_Pod_No_Conoce_Avisa_Igual() + { + // Es el inquilino creado sobre la marcha: este pod nunca guardó nada suyo. + var (cache, avisos) = CrearCache(); + + cache.InvalidateTenant(Guid.NewGuid()); + + Assert.Contains(avisos, canal => canal.EndsWith(":tenant", StringComparison.Ordinal)); + } + + [Fact] + public void Invalidar_Un_Inquilino_Conocido_Sigue_Avisando() + { + // La mitad que ya funcionaba: no se arregla una rompiendo la otra. + var (cache, avisos) = CrearCache(); + var inquilino = Guid.NewGuid(); + cache.PopulateTenant(inquilino, []); + + cache.InvalidateTenant(inquilino); + + Assert.Contains(avisos, canal => canal.EndsWith(":tenant", StringComparison.Ordinal)); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Configuration/Parameter/DeleteParameterDefinitionCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Configuration/Parameter/DeleteParameterDefinitionCommandHandlerTests.cs new file mode 100644 index 00000000..6df57088 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Configuration/Parameter/DeleteParameterDefinitionCommandHandlerTests.cs @@ -0,0 +1,144 @@ +namespace Ums.Application.Test.Configuration.Parameter; + +using Ums.Application.Common.Interfaces; +using Ums.Application.Configuration.Parameter.Commands; +using Ums.Domain.Configuration; +using Ums.Domain.Configuration.Parameter; +using Ums.Domain.Configuration.Parameter.ValueObjects; +using Xunit; + +/// +/// Tests de capa de aplicación del borrado LÓGICO de ParameterDefinition. +/// +/// La política del propietario es que el borrado físico no existe: la fila permanece y lo que +/// cambia es su estado. Estas pruebas fijan esa política en el contrato del handler: +/// – Guarda de autenticación: sin usuario, no se toca el dominio. +/// – No encontrado: una definición inexistente —o ya eliminada, que las lecturas ocultan— da NotFound. +/// – Éxito: se marca `IsDeleted` y se persiste con UpdateAsync; NO existe DeleteAsync que llamar. +/// – Regla transaccional: con dependientes VIVOS → conflicto (409) codificado como +/// BlockedOperationError, sin tocar el agregado. +/// – Con esos mismos dependientes ya eliminados lógicamente (contadores de vivos a 0) → sí borra. +/// +public class DeleteParameterDefinitionCommandHandlerTests +{ + private readonly Mock _repo = new(); + private readonly Mock _ctx = new(); + + private static ParameterDefinition MakeDefinition() => + ParameterDefinition.Create( + Code.Create("PARAM-001"), + ParameterName.Create("Parameter 1"), + Description.Create("Test parameter"), + ParameterDataType.String, + DefaultValue.Create("default"), + ParameterScope.GlobalAndTenant, + isActive: true, + isMandatory: false, + displayOrder: 1, + ActorId.Create("user-001")).Value; + + [Fact] + public async Task Delete_SinDependientes_MarcaEliminadaYNoRetiraLaFila() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var definition = MakeDefinition(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(definition); + _repo.Setup(r => r.CountLiveGlobalValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + _repo.Setup(r => r.CountLiveTenantValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(definition.IsDeleted); + Assert.False(definition.IsActive); // una definición eliminada tampoco resuelve + _repo.Verify(r => r.UpdateAsync(definition, It.IsAny()), Times.Once); + _repo.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ParameterDefinition?)null); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Common.NotFound, result.Error); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_ConValoresGlobalesVivos_ReturnsBlockedConflict() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var definition = MakeDefinition(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(definition); + _repo.Setup(r => r.CountLiveGlobalValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(2); + _repo.Setup(r => r.CountLiveTenantValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterHasActiveValues, result.Error); + Assert.False(definition.IsDeleted); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_ConValoresDeInquilinoVivos_ReturnsBlockedConflict() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var definition = MakeDefinition(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(definition); + _repo.Setup(r => r.CountLiveGlobalValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + _repo.Setup(r => r.CountLiveTenantValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(1); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterHasActiveValues, result.Error); + Assert.False(definition.IsDeleted); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_ConDependientesYaEliminadosLogicamente_SiBorra() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var definition = MakeDefinition(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(definition); + // Existen filas dependientes en la base, pero todas están eliminadas lógicamente: los + // contadores de VIVOS devuelven 0 y la referencia deja de ser real → no bloquea. + _repo.Setup(r => r.CountLiveGlobalValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + _repo.Setup(r => r.CountLiveTenantValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(definition.IsDeleted); + _repo.Verify(r => r.UpdateAsync(definition, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_WhenUnauthenticated_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns(""); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("Authenticated user is required", result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Configuration/Services/ConfigurationAuditServiceTests.cs b/src/apps/ums.api/Ums.Application.Test/Configuration/Services/ConfigurationAuditServiceTests.cs new file mode 100644 index 00000000..45449547 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Configuration/Services/ConfigurationAuditServiceTests.cs @@ -0,0 +1,185 @@ +namespace Ums.Application.Test.Configuration.Services; + +using System.Text.Json; +using FluentAssertions; +using Ums.Application.Common.Aop; +using Ums.Application.Configuration.Services; +using Xunit; + +/// +/// G-105 (residual de G-040#5): el saneador por-clave redacta por +/// nombre de clave y NO cubre el valor de un parámetro secreto, que aterriza bajo las claves +/// PreviousValue/NewValue (no sensibles). debe +/// redactar ese valor por clasificación (parámetro cifrado/secreto) ANTES de escribirlo en la +/// metadata de la traza inmutable (G-081), y dejar intacto el valor de los parámetros no secretos +/// (no-regresión: la auditoría de cambios de configuración sigue siendo útil). +/// +public sealed class ConfigurationAuditServiceTests +{ + private const string Redacted = AuditMetadataSanitizer.RedactionPlaceholder; + private const string SecretPlaintext = "S3cr3t-Api-Key-DO-NOT-LEAK"; + + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly Guid TenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid RootTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + // Captura la única entrada publicada al sumidero para inspeccionar su metadata. + private static (Mock sink, Func captured) MakeSink() + { + AuditTrailEntry? entry = null; + var sink = new Mock(); + sink.Setup(s => s.PublishAsync(It.IsAny(), It.IsAny())) + .Callback((e, _) => entry = e) + .Returns(Task.CompletedTask); + return (sink, () => entry ?? throw new InvalidOperationException("No se publicó ninguna entrada.")); + } + + private static JsonElement Metadata(AuditTrailEntry entry) + => JsonDocument.Parse(entry.Metadata!).RootElement; + + // ── Parámetro SECRETO → el valor se redacta, el claro no aparece en la traza ── + + [Fact] + public async Task SecretParameter_Modified_RedactsBothPreviousAndNewValue() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordConfigurationChangeAsync( + UserId, + "SMTP-PASSWORD", + TenantId, + previousValue: "old-" + SecretPlaintext, + newValue: "new-" + SecretPlaintext, + operationType: "UPDATE", + RootTenantId, + isEncrypted: true); + + var entry = captured(); + var meta = Metadata(entry); + + meta.GetProperty("PreviousValue").GetString().Should().Be(Redacted); + meta.GetProperty("NewValue").GetString().Should().Be(Redacted); + entry.Metadata.Should().NotContain(SecretPlaintext, "el valor secreto en claro nunca debe llegar a la traza inmutable"); + } + + [Fact] + public async Task SecretParameter_Created_RedactsNewValue_PreviousNullStaysNull() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordConfigurationChangeAsync( + UserId, + "SMTP-PASSWORD", + TenantId, + previousValue: null, + newValue: SecretPlaintext, + operationType: "CREATE", + RootTenantId, + isEncrypted: true); + + var meta = Metadata(captured()); + + // Un valor nulo no tiene nada que ocultar → se deja nulo (no se finge un valor redactado). + meta.GetProperty("PreviousValue").ValueKind.Should().Be(JsonValueKind.Null); + meta.GetProperty("NewValue").GetString().Should().Be(Redacted); + } + + [Fact] + public async Task SecretParameter_Deleted_RedactsPreviousValue_AndKeepsDeletedResult() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordConfigurationChangeAsync( + UserId, + "SMTP-PASSWORD", + TenantId, + previousValue: SecretPlaintext, + newValue: null, + operationType: "DELETE", + RootTenantId, + isEncrypted: true); + + var entry = captured(); + var meta = Metadata(entry); + + meta.GetProperty("PreviousValue").GetString().Should().Be(Redacted); + meta.GetProperty("NewValue").ValueKind.Should().Be(JsonValueKind.Null); + // La redacción no altera la clasificación del evento: newValue nulo ⇒ DELETED se preserva. + entry.AuditResult.Should().Be("DELETED"); + entry.Metadata.Should().NotContain(SecretPlaintext); + } + + [Fact] + public async Task SecretParameter_Override_IsRedacted() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordParameterOverrideAsync( + UserId, + "SMTP-PASSWORD", + TenantId, + previousValue: SecretPlaintext, + newValue: "otro-" + SecretPlaintext, + RootTenantId, + isEncrypted: true); + + var entry = captured(); + var meta = Metadata(entry); + + entry.EventType.Should().Be("OVERRIDE"); + meta.GetProperty("PreviousValue").GetString().Should().Be(Redacted); + meta.GetProperty("NewValue").GetString().Should().Be(Redacted); + entry.Metadata.Should().NotContain(SecretPlaintext); + } + + // ── Parámetro NORMAL → el valor se registra tal cual (no-regresión) ── + + [Fact] + public async Task NonSecretParameter_RecordsValuesVerbatim() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordConfigurationChangeAsync( + UserId, + "SESSION-TIMEOUT", + TenantId, + previousValue: "3600", + newValue: "7200", + operationType: "UPDATE", + RootTenantId, + isEncrypted: false); + + var meta = Metadata(captured()); + + meta.GetProperty("PreviousValue").GetString().Should().Be("3600", "un parámetro no secreto conserva su valor auditable"); + meta.GetProperty("NewValue").GetString().Should().Be("7200"); + meta.GetProperty("PreviousValue").GetString().Should().NotBe(Redacted); + meta.GetProperty("NewValue").GetString().Should().NotBe(Redacted); + } + + [Fact] + public async Task NonSecretParameter_Override_RecordsValueVerbatim() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordParameterOverrideAsync( + UserId, + "SESSION-TIMEOUT", + TenantId, + previousValue: "3600", + newValue: "1800", + RootTenantId, + isEncrypted: false); + + var meta = Metadata(captured()); + + meta.GetProperty("NewValue").GetString().Should().Be("1800"); + meta.GetProperty("PreviousValue").GetString().Should().Be("3600"); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/IGA/HeuristicRiskScoreCalculatorTests.cs b/src/apps/ums.api/Ums.Application.Test/IGA/HeuristicRiskScoreCalculatorTests.cs new file mode 100644 index 00000000..af040258 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/IGA/HeuristicRiskScoreCalculatorTests.cs @@ -0,0 +1,90 @@ +namespace Ums.Application.Test.IGA; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Ums.Application.IGA.Services; +using Ums.Domain.Authorization; +using Ums.Domain.Kernel.ValueObjects; +using RoleAggregate = Ums.Domain.Authorization.Role.Role; + +/// +/// Pruebas de la heurística versionada de RiskScore (IGA, ADR-UMS-093, FR-061). La fórmula es +/// determinista: verificamos el enrutamiento de riesgo alto (≥ umbral 70) frente a riesgo bajo, +/// la versión del modelo y el fallo cuando falta un rol. +/// +public class HeuristicRiskScoreCalculatorTests +{ + private readonly Mock _roleRepo = new(); + + private static RoleAggregate MakeRole(int hierarchyLevel, int promotionOrder) + { + RoleId? parent = hierarchyLevel == 0 ? null : RoleId.Load(Guid.NewGuid()); + return RoleAggregate.Create( + TenantId.Load(Guid.NewGuid()), + SystemSuiteId.Create(), + Code.Create("ROLE1"), + Name.Create("Rol"), + Description.Create("Rol de prueba"), + parent, + hierarchyLevel, + promotionOrder, + ActorId.Create("sistema")).Value; + } + + [Fact] + public async Task Calculate_ConGranEscalacion_ProduceRiesgoAlto() + { + var currentRole = Guid.NewGuid(); + var targetRole = Guid.NewGuid(); + _roleRepo.Setup(r => r.GetByIdAsync(currentRole, It.IsAny())).ReturnsAsync(MakeRole(1, 1)); + _roleRepo.Setup(r => r.GetByIdAsync(targetRole, It.IsAny())).ReturnsAsync(MakeRole(4, 4)); + + var calc = new HeuristicRiskScoreCalculator(_roleRepo.Object); + var result = await calc.CalculateAsync( + new RolePromotionRiskContext(Guid.NewGuid(), Guid.NewGuid(), currentRole, targetRole), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Value.Score >= 70, $"Se esperaba riesgo alto, fue {result.Value.Score}."); + Assert.Equal(HeuristicRiskScoreCalculator.ModelVersion, result.Value.RiskModelVersion); + Assert.NotEmpty(result.Value.Factors); + } + + [Fact] + public async Task Calculate_ConEscalacionMinima_ProduceRiesgoBajo() + { + var currentRole = Guid.NewGuid(); + var targetRole = Guid.NewGuid(); + _roleRepo.Setup(r => r.GetByIdAsync(currentRole, It.IsAny())).ReturnsAsync(MakeRole(1, 1)); + _roleRepo.Setup(r => r.GetByIdAsync(targetRole, It.IsAny())).ReturnsAsync(MakeRole(2, 2)); + + var calc = new HeuristicRiskScoreCalculator(_roleRepo.Object); + var result = await calc.CalculateAsync( + new RolePromotionRiskContext(Guid.NewGuid(), Guid.NewGuid(), currentRole, targetRole), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Value.Score < 70, $"Se esperaba riesgo bajo, fue {result.Value.Score}."); + Assert.InRange(result.Value.Score, RiskScore.Min, RiskScore.Max); + } + + [Fact] + public async Task Calculate_CuandoFaltaUnRol_DevuelveFallo() + { + var currentRole = Guid.NewGuid(); + var targetRole = Guid.NewGuid(); + _roleRepo.Setup(r => r.GetByIdAsync(currentRole, It.IsAny())).ReturnsAsync(MakeRole(1, 1)); + _roleRepo.Setup(r => r.GetByIdAsync(targetRole, It.IsAny())).ReturnsAsync((RoleAggregate?)null); + + var calc = new HeuristicRiskScoreCalculator(_roleRepo.Object); + var result = await calc.CalculateAsync( + new RolePromotionRiskContext(Guid.NewGuid(), Guid.NewGuid(), currentRole, targetRole), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("rol objetivo", result.Error, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionCommandHandlerTests.cs new file mode 100644 index 00000000..b10d7e16 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionCommandHandlerTests.cs @@ -0,0 +1,455 @@ +namespace Ums.Application.Test.IGA; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Ums.Application.Common.Interfaces; +using Ums.Application.IGA.RolePromotion.Commands; +using Ums.Application.IGA.Services; +using Ums.Domain.Enums; +using Ums.Domain.Events; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using Ums.Domain.Kernel.ValueObjects; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Pruebas de los handlers de comandos de promoción de rol (IGA, ADR-UMS-093). Cubren, por handler, +/// el camino feliz y las guardas: estado inválido, no elegible → Rejected (fail-closed), +/// SoD violada → Failure y RiskScore alto → enruta a PendingSecurityReview. +/// +public class RolePromotionCommandHandlerTests +{ + private readonly Mock _repo = new(); + private readonly Mock _maturityRepo = new(); + private readonly Mock _calculator = new(); + private readonly Mock _scope = new(); + private readonly Mock _uow = new(); + private readonly Mock _ctx = new(); + // G-094: el ejecutor publica el efecto como evento de integración al outbox; se falsea el puerto. + private readonly Mock _integrationEvents = new(); + + private readonly Guid _tenant = Guid.NewGuid(); + private readonly Guid _target = Guid.NewGuid(); + private readonly Guid _requester = Guid.NewGuid(); + private readonly Guid _approver = Guid.NewGuid(); + private readonly Guid _reviewer = Guid.NewGuid(); + private readonly Guid _executor = Guid.NewGuid(); + private readonly Guid _verifier = Guid.NewGuid(); + private readonly Guid _currentRole = Guid.NewGuid(); + private readonly Guid _targetRole = Guid.NewGuid(); + + public RolePromotionCommandHandlerTests() + { + _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + _maturityRepo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + // Alcance de inquilino no restrictivo por defecto (administrador interno). + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + } + + private void ActingAs(Guid userId) => _ctx.Setup(c => c.UserId).Returns(userId.ToString()); + + private RolePromotionRequestAggregate NewDraft() => + RolePromotionRequestAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + UserId.Load(_requester), + RoleId.Load(_currentRole), + RoleId.Load(_targetRole), + ActorId.Create(_requester.ToString())).Value; + + private RolePromotionRequestAggregate InPendingManagerApproval(int riskScore) + { + var req = NewDraft(); + req.Submit(RiskScore.Create(riskScore).Value, ActorId.Create(_requester.ToString())); + req.ConfirmEligibility(true, ActorId.Create("sistema")); + return req; + } + + private RolePromotionRequestAggregate InApproved(int riskScore = 30) + { + var req = InPendingManagerApproval(riskScore); + req.ManagerApprove(UserId.Load(_approver), ActorId.Create(_approver.ToString())); + return req; + } + + private RolePromotionRequestAggregate InExecuted() + { + var req = InApproved(); + req.Execute(UserId.Load(_executor), ActorId.Create(_executor.ToString())); + return req; + } + + private RoleMaturityStatusAggregate EligibleMaturity() + { + var status = RoleMaturityStatusAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + RoleId.Load(_currentRole), + RoleMaturityLevel.Junior, + DateTime.UtcNow.AddYears(-2), + ActorId.Create("sistema")).Value; + status.UpdatePerformanceScore(4.5m, ActorId.Create("sistema")); + return status; + } + + private RoleMaturityStatusAggregate NotEligibleMaturity() + { + // Desempeño 0 (por defecto) < 3.0 ⇒ no elegible. + return RoleMaturityStatusAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + RoleId.Load(_currentRole), + RoleMaturityLevel.Junior, + DateTime.UtcNow.AddYears(-2), + ActorId.Create("sistema")).Value; + } + + // ── Create ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_ConSolicitanteDistintoDelObjetivo_DevuelveExito() + { + ActingAs(_requester); + var handler = new CreateRolePromotionRequestCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + + var result = await handler.Handle( + new CreateRolePromotionRequestCommand(_tenant, _target, _currentRole, _targetRole), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotEqual(Guid.Empty, result.Value.RolePromotionRequestId); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_CuandoSolicitanteEsElObjetivo_ViolaSoD() + { + ActingAs(_target); // el actor (solicitante) es el objetivo + var handler = new CreateRolePromotionRequestCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + + var result = await handler.Handle( + new CreateRolePromotionRequestCommand(_tenant, _target, _currentRole, _targetRole), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_SinUsuarioAutenticado_DevuelveFallo() + { + _ctx.Setup(c => c.UserId).Returns(string.Empty); + var handler = new CreateRolePromotionRequestCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + + var result = await handler.Handle( + new CreateRolePromotionRequestCommand(_tenant, _target, _currentRole, _targetRole), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("usuario autenticado", result.Error, StringComparison.OrdinalIgnoreCase); + } + + // ── Submit ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Submit_CongelaRiskScoreYAvanza() + { + ActingAs(_requester); + var req = NewDraft(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _calculator + .Setup(c => c.CalculateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new RiskAssessment(42, "iga-risk-heuristic-v1", Array.Empty()))); + + var handler = new SubmitRolePromotionCommandHandler(_repo.Object, _calculator.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SubmitRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(42, req.RiskScore!.GetValue()); + Assert.Equal("PendingEligibilityCheck", req.Status.Name); + _repo.Verify(r => r.UpdateAsync(req, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Submit_CuandoActorEsElObjetivo_ViolaSoD() + { + ActingAs(_target); + var req = NewDraft(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new SubmitRolePromotionCommandHandler(_repo.Object, _calculator.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SubmitRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + _calculator.Verify(c => c.CalculateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Submit_CuandoNoSeEncuentraLaSolicitud_DevuelveFallo() + { + ActingAs(_requester); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RolePromotionRequestAggregate?)null); + + var handler = new SubmitRolePromotionCommandHandler(_repo.Object, _calculator.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SubmitRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + // G-100: el handler ahora devuelve un código estable e idioma-agnóstico (no el texto en español) + // para que el mapeador HTTP lo clasifique como 404 con independencia del idioma del mensaje. + Assert.Equal(DomainErrors.IGA.RolePromotionRequestNotFound, result.Error); + } + + // ── ConfirmEligibility (fail-closed) ───────────────────────────────────────── + + [Fact] + public async Task ConfirmEligibility_CuandoElegible_AvanzaAAprobacionGerente() + { + ActingAs(_requester); + var req = NewDraft(); + req.Submit(RiskScore.Create(30).Value, ActorId.Create(_requester.ToString())); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _maturityRepo + .Setup(r => r.GetByUserAndRoleAsync(_tenant, _target, _currentRole, It.IsAny())) + .ReturnsAsync(EligibleMaturity()); + + var handler = new ConfirmRolePromotionEligibilityCommandHandler(_repo.Object, _maturityRepo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ConfirmRolePromotionEligibilityCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("PendingManagerApproval", req.Status.Name); + } + + [Fact] + public async Task ConfirmEligibility_CuandoNoElegible_RechazaFailClosed() + { + ActingAs(_requester); + var req = NewDraft(); + req.Submit(RiskScore.Create(30).Value, ActorId.Create(_requester.ToString())); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _maturityRepo + .Setup(r => r.GetByUserAndRoleAsync(_tenant, _target, _currentRole, It.IsAny())) + .ReturnsAsync(NotEligibleMaturity()); + + var handler = new ConfirmRolePromotionEligibilityCommandHandler(_repo.Object, _maturityRepo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ConfirmRolePromotionEligibilityCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); // la transición a Rejected es un resultado válido + Assert.Equal("Rejected", req.Status.Name); + _maturityRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ConfirmEligibility_CuandoNoHayEstadoDeMadurez_RechazaFailClosed() + { + ActingAs(_requester); + var req = NewDraft(); + req.Submit(RiskScore.Create(30).Value, ActorId.Create(_requester.ToString())); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _maturityRepo + .Setup(r => r.GetByUserAndRoleAsync(_tenant, _target, _currentRole, It.IsAny())) + .ReturnsAsync((RoleMaturityStatusAggregate?)null); + + var handler = new ConfirmRolePromotionEligibilityCommandHandler(_repo.Object, _maturityRepo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ConfirmRolePromotionEligibilityCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Rejected", req.Status.Name); + } + + // ── ManagerApprove (enrutamiento por RiskScore) ────────────────────────────── + + [Fact] + public async Task ManagerApprove_ConRiesgoBajo_Aprueba() + { + ActingAs(_approver); + var req = InPendingManagerApproval(riskScore: 30); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ManagerApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ManagerApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Approved", req.Status.Name); + } + + [Fact] + public async Task ManagerApprove_ConRiesgoAlto_EnrutaARevisionSeguridad() + { + ActingAs(_approver); + var req = InPendingManagerApproval(riskScore: 80); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ManagerApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ManagerApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("PendingSecurityReview", req.Status.Name); + } + + [Fact] + public async Task ManagerApprove_CuandoAprobadorEsElSolicitante_ViolaSoD() + { + ActingAs(_requester); // el aprobador coincide con el solicitante + var req = InPendingManagerApproval(riskScore: 30); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ManagerApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ManagerApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ManagerApprove_EnEstadoInvalido_DevuelveFallo() + { + ActingAs(_approver); + var req = NewDraft(); // sigue en Draft, no en PendingManagerApproval + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ManagerApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ManagerApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal("Draft", req.Status.Name); + } + + // ── SecurityApprove ────────────────────────────────────────────────────────── + + [Fact] + public async Task SecurityApprove_DesdeRevisionSeguridad_Aprueba() + { + ActingAs(_reviewer); + var req = InPendingManagerApproval(riskScore: 80); + req.ManagerApprove(UserId.Load(_approver), ActorId.Create(_approver.ToString())); // → PendingSecurityReview + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new SecurityApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SecurityApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Approved", req.Status.Name); + } + + [Fact] + public async Task SecurityApprove_CuandoRevisorEsElAprobador_ViolaSoD() + { + ActingAs(_approver); // el revisor coincide con el aprobador + var req = InPendingManagerApproval(riskScore: 80); + req.ManagerApprove(UserId.Load(_approver), ActorId.Create(_approver.ToString())); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new SecurityApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SecurityApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + } + + // ── Execute / Verify ───────────────────────────────────────────────────────── + + [Fact] + public async Task Execute_DesdeAprobado_Ejecuta() + { + ActingAs(_executor); + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, _integrationEvents.Object); + var result = await handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Executed", req.Status.Name); + // El efecto se delega al outbox: se publica exactamente un evento de integración de ejecución. + _integrationEvents.Verify( + p => p.PublishAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Execute_CuandoEjecutorEsElAprobador_ViolaSoD() + { + // ADR-UMS-096 (INV-RPR3 endurecida): el ejecutor no puede ser el aprobador. + ActingAs(_approver); // el ejecutor coincide con el aprobador + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, _integrationEvents.Object); + var result = await handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Approved", req.Status.Name); // no transiciona + // No se ejecutó: no debe publicarse ningún efecto al outbox. + _integrationEvents.Verify( + p => p.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Verify_DesdeEjecutado_Verifica() + { + ActingAs(_verifier); + var req = InExecuted(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new VerifyRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new VerifyRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Verified", req.Status.Name); + } + + [Fact] + public async Task Verify_CuandoVerificadorEsElEjecutor_ViolaSoD() + { + ActingAs(_executor); // el verificador coincide con el ejecutor + var req = InExecuted(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new VerifyRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new VerifyRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + } + + // ── Cancel ─────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Cancel_PorElSolicitante_Cancela() + { + ActingAs(_requester); + var req = NewDraft(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new CancelRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new CancelRolePromotionCommand(Guid.NewGuid(), "Ya no aplica"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Cancelled", req.Status.Name); + } + + [Fact] + public async Task Cancel_PorUnTercero_DevuelveFallo() + { + ActingAs(_approver); // no es el solicitante + var req = NewDraft(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new CancelRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new CancelRolePromotionCommand(Guid.NewGuid(), "Motivo"), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("solicitante", result.Error, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionQueryHandlerTests.cs new file mode 100644 index 00000000..31ae1722 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionQueryHandlerTests.cs @@ -0,0 +1,140 @@ +namespace Ums.Application.Test.IGA; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Ums.Application.Common.Interfaces; +using Ums.Application.IGA.RoleMaturity.Queries; +using Ums.Application.IGA.RolePromotion.Queries; +using Ums.Domain.Enums; +using Ums.Domain.IGA; +using Ums.Domain.Kernel.ValueObjects; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// Pruebas de los handlers de consulta IGA (ADR-UMS-093), incluida la acotación por inquilino. +public class RolePromotionQueryHandlerTests +{ + private readonly Mock _repo = new(); + private readonly Mock _maturityRepo = new(); + private readonly Mock _scope = new(); + + private readonly Guid _tenant = Guid.NewGuid(); + private readonly Guid _target = Guid.NewGuid(); + private readonly Guid _requester = Guid.NewGuid(); + private readonly Guid _currentRole = Guid.NewGuid(); + private readonly Guid _targetRole = Guid.NewGuid(); + + private RolePromotionRequestAggregate NewRequest() => + RolePromotionRequestAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + UserId.Load(_requester), + RoleId.Load(_currentRole), + RoleId.Load(_targetRole), + ActorId.Create(_requester.ToString())).Value; + + private RoleMaturityStatusAggregate NewMaturity() => + RoleMaturityStatusAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + RoleId.Load(_currentRole), + RoleMaturityLevel.Junior, + DateTime.UtcNow.AddYears(-1), + ActorId.Create("sistema")).Value; + + [Fact] + public async Task GetById_DentroDelAlcance_DevuelveDto() + { + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + var req = NewRequest(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new GetRolePromotionRequestByIdQueryHandler(_repo.Object, _scope.Object); + var result = await handler.Handle(new GetRolePromotionRequestByIdQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(_tenant, result.Value.TenantId); + Assert.Equal("Draft", result.Value.Status); + } + + [Fact] + public async Task GetById_FueraDeAlcance_DevuelveFallo() + { + // El solicitante pertenece a otro inquilino distinto del de la solicitud. + _scope.Setup(s => s.ResolveQueryScope()).Returns(Guid.NewGuid()); + var req = NewRequest(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new GetRolePromotionRequestByIdQueryHandler(_repo.Object, _scope.Object); + var result = await handler.Handle(new GetRolePromotionRequestByIdQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("alcance", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetById_CuandoNoExiste_DevuelveCodigoDeNoEncontrado() + { + // G-100: sin la solicitud, el handler debe devolver el CÓDIGO estable de dominio + // (idioma-agnóstico), no el texto en español, para que el mapeador HTTP resuelva 404 y no 400. + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RolePromotionRequestAggregate?)null); + + var handler = new GetRolePromotionRequestByIdQueryHandler(_repo.Object, _scope.Object); + var result = await handler.Handle(new GetRolePromotionRequestByIdQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.IGA.RolePromotionRequestNotFound, result.Error); + } + + [Fact] + public async Task List_PorInquilinoYEstado_UsaConsultaFiltrada() + { + _scope.Setup(s => s.ResolveQueryScope()).Returns(_tenant); + _repo.Setup(r => r.GetByTenantAndStatusAsync(_tenant, "Draft", It.IsAny())) + .ReturnsAsync(new List { NewRequest() }); + + var handler = new ListRolePromotionRequestsQueryHandler(_repo.Object, _scope.Object); + var result = await handler.Handle(new ListRolePromotionRequestsQuery(_tenant, "Draft"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + _repo.Verify(r => r.GetByTenantAndStatusAsync(_tenant, "Draft", It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetRoleMaturityStatus_ConRol_DevuelveEstadoUnico() + { + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + _maturityRepo.Setup(r => r.GetByUserAndRoleAsync(_tenant, _target, _currentRole, It.IsAny())) + .ReturnsAsync(NewMaturity()); + + var handler = new GetRoleMaturityStatusByUserQueryHandler(_maturityRepo.Object, _scope.Object); + var result = await handler.Handle( + new GetRoleMaturityStatusByUserQuery(_tenant, _target, _currentRole), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + Assert.Equal(_target, result.Value[0].UserId); + } + + [Fact] + public async Task GetRoleMaturityStatus_SinRol_DevuelveLista() + { + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + _maturityRepo.Setup(r => r.GetByUserAsync(_tenant, _target, It.IsAny())) + .ReturnsAsync(new List { NewMaturity() }); + + var handler = new GetRoleMaturityStatusByUserQueryHandler(_maturityRepo.Object, _scope.Object); + var result = await handler.Handle( + new GetRoleMaturityStatusByUserQuery(_tenant, _target, null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverSuiteSourceTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverSuiteSourceTests.cs new file mode 100644 index 00000000..ac713adb --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverSuiteSourceTests.cs @@ -0,0 +1,226 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Moq; +using Xunit; +using Ums.Application.Configuration.Services; +using Ums.Application.Identity.Auth; +using Ums.Domain.Configuration; +using Ums.Domain.Configuration.AppConfiguration; +using Ums.Domain.Configuration.IdpConfiguration; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +/// +/// G-110 (residual del slice 2a de FR-042, ADR-UMS-097 §2.2): la procedencia de la suite +/// pre-autenticación. El AuthMethodResolverService ya aceptaba systemSuiteId, pero por +/// la ruta REST real llegaba null porque ni el AuthAccessScope ni el Tenant lo +/// alimentaban. Estas pruebas ejercen la fuente nueva (suite por defecto del inquilino): sin +/// pasar suite explícita (como hace la ruta REST), el filtro por suite del IdpConfigurationSelector +/// se ejerce de verdad — una IdpConfiguration de otra suite NO se elige; la de la suite correcta sí. +/// Y la no-regresión: sin suite por defecto ⇒ filtro omitido (comportamiento previo); portal ⇒ local. +/// +public class AuthMethodResolverSuiteSourceTests +{ + private readonly Mock _config = new(); + private readonly Mock _tenantRepo = new(); + private readonly Mock _idpConfigRepo = new(); + private readonly Guid _tenantId = Guid.NewGuid(); + + private AuthMethodResolverService CreateSut() + => new(_config.Object, _tenantRepo.Object, _idpConfigRepo.Object); + + private void SetupAuthUseExternalIdp(bool value) + => _config.Setup(c => c.GetWithPrecedence(AppConfigurationCodes.AuthUseExternalIdp, _tenantId)) + .Returns(BuildConfiguration(_tenantId, value)); + + private void SetupIdpConfigurations(params IdpConfigurationAggregate[] configurations) + => _idpConfigRepo.Setup(r => r.GetByTenantIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(configurations.ToList()); + + private void SetupTenant(TenantAggregate tenant) + => _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(tenant); + + // ── Fuente de suite: el default del inquilino alimenta el filtro por suite ────────── + + [Fact] + public async Task ResolveAsync_NoScopeSuite_UsesTenantDefaultSuite_SelectsMatchingSuiteConfig() + { + // Ruta REST realista: NO se pasa suite (systemSuiteId == null). El inquilino tiene suite por + // defecto = suiteB y proveedor Keycloak activo. Dos configs: AzureAd@suiteA con MEJOR prioridad + // (1) y Keycloak@suiteB con PEOR prioridad (10). Sin filtro de suite ganaría AzureAd@suiteA por + // prioridad; PERO al poblarse la suite desde el default del inquilino (suiteB), el filtro EXCLUYE + // la config de la otra suite y elige Keycloak@suiteB pese a su peor prioridad. Prueba que el + // SystemSuiteId llega no-null al selector desde una fuente real y que el filtro por suite se ejerce. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.Keycloak, defaultSuiteId: suiteB)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 10, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, systemSuiteId: null); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.NotNull(result.Value.Provider); + Assert.Equal(IdpStrategy.Keycloak, result.Value.Provider!.Strategy); + } + + [Fact] + public async Task ResolveAsync_NoScopeSuite_TenantDefaultSelectsOtherSuiteConfig_NotForcedToActiveProvider() + { + // Mismo par de configs, pero ahora el default del inquilino = suiteA. El filtro por suite elige + // AzureAd@suiteA; como el proveedor activo es Keycloak (no AzureAd), el puente no casa y NO se + // sustituye por otro proveedor (autenticar contra un IdP no elegido sería incorrecto) → Local. + // Contrasta con la prueba anterior: cambiar SOLO la suite por defecto cambia la config elegida, + // lo que demuestra que la suite del inquilino gobierna la selección. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.Keycloak, defaultSuiteId: suiteA)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 10, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, systemSuiteId: null); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); + } + + [Fact] + public async Task ResolveAsync_ScopeSuiteProvided_TakesPrecedenceOverTenantDefault() + { + // Si el AccessScope SÍ fija suite (systemSuiteId != null), esa suite manda sobre el default del + // inquilino (ADR-UMS-097 §2.2: "proviene del AccessScope; si el scope no la fija, el default"). El + // inquilino tiene default = suiteA, pero se pasa suiteB explícita → gana Keycloak@suiteB. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.Keycloak, defaultSuiteId: suiteA)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 10, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, systemSuiteId: suiteB); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.Equal(IdpStrategy.Keycloak, result.Value.Provider!.Strategy); + } + + // ── No-regresión ──────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_NoScopeSuite_NoTenantDefault_OmitsSuiteFilter_PreviousBehavior() + { + // Inquilino SIN suite por defecto (suite única) y sin suite en el scope → efectivo == null → + // el selector OMITE el filtro por suite y gana por prioridad global (AzureAd@suiteA, prioridad 1), + // que casa con el proveedor activo AzureAd. Comportamiento previo intacto (retrocompatible). + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.AzureAd, defaultSuiteId: null)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 10, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, systemSuiteId: null); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.Equal(IdpStrategy.AzureAd, result.Value.Provider!.Strategy); + } + + [Fact] + public async Task ResolveAsync_PortalManagement_WithTenantDefaultSuite_StillForcesLocal() + { + // ADR-UMS-072: el portal de gestión interna SIEMPRE resuelve a local, incluso si el inquilino + // tiene suite por defecto. La fuente de suite no debe filtrarse al portal ni consultar el repo. + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.Keycloak, defaultSuiteId: Guid.NewGuid())); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.PortalManagement, systemSuiteId: null); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); + Assert.Null(result.Value.Provider); + _tenantRepo.Verify(r => r.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // ── Helpers ────────────────────────────────────────────────────────────────────────── + + private static TenantAggregate BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy strategy, Guid? defaultSuiteId) + { + var actor = ActorId.Create("test"); + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + Ums.Domain.Enums.OrganizationType.INTERNAL, + actor, + strategy).Value; + + if (defaultSuiteId.HasValue) + { + tenant.SetDefaultSystemSuite(SystemSuiteId.Load(defaultSuiteId.Value), actor); + } + + tenant.RegisterIdentityProvider( + Code.Create(strategy.Name.ToUpperInvariant()), + Name.Create(strategy.Name), + Description.Create(""), + strategy, + actor); + + var idp = tenant.IdentityProviders.First(); + tenant.ActivateIdentityProvider(idp.GetId(), actor); + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private IdpConfigurationAggregate BuildActiveConfig( + ProviderType providerType, + int priority, + Guid suiteId, + string[]? domainHints = null) + { + var actor = ActorId.Create("test"); + var config = IdpConfigurationAggregate.Create( + TenantId.Load(_tenantId), + SystemSuiteId.Load(suiteId), + providerType, + domainHints ?? Array.Empty(), + "{\"issuer\":\"https://idp.example\"}", + "vault/secret/idp", + priority, + null, + actor).Value; + config.Activate(actor); + return config; + } + + private static AppConfigurationAggregate BuildConfiguration(Guid tenantId, bool value) + { + var actor = ActorId.Create("test"); + return AppConfigurationAggregate.Create( + TenantId.Load(tenantId), + null, + null, + Code.Create(AppConfigurationCodes.AuthUseExternalIdp), + ConfigurationValue.Create(value.ToString().ToLowerInvariant()), + Description.Create("Use external IDP"), + true, + false, + actor) + .Value; + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverTests.cs index 9afd40dd..3c17e498 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverTests.cs @@ -4,28 +4,46 @@ namespace Ums.Application.Test.Identity.Auth; using Xunit; using Ums.Application.Configuration.Services; using Ums.Application.Identity.Auth; +using Ums.Domain.Configuration; using Ums.Domain.Configuration.AppConfiguration; +using Ums.Domain.Configuration.IdpConfiguration; using Ums.Domain.Identity; using Ums.Domain.Identity.Auth; using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; /// /// Tests for AuthMethodResolverService. -/// Verifies dynamic resolution from IConfigurationProvider without hitting the DB. +/// Verifies dynamic resolution from IConfigurationProvider without hitting the DB, +/// plus FR-042 (ADR-UMS-097 slice 2a): selección del proveedor por el motor de reglas +/// (prioridad/suite/dominio) y puente reglas ↔ IdentityProvider. /// public class AuthMethodResolverTests { - private readonly Mock _config = new(); - private readonly Mock _tenantRepo = new(); - private readonly Guid _tenantId = Guid.NewGuid(); + private readonly Mock _config = new(); + private readonly Mock _tenantRepo = new(); + private readonly Mock _idpConfigRepo = new(); + private readonly Guid _tenantId = Guid.NewGuid(); + + public AuthMethodResolverTests() + { + // Por defecto no hay IdpConfiguration → el resolver conserva el comportamiento previo + // (proveedor activo del inquilino). Los tests de reglas sobreescriben este setup. + _idpConfigRepo.Setup(r => r.GetByTenantIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + } private AuthMethodResolverService CreateSut() - => new(_config.Object, _tenantRepo.Object); + => new(_config.Object, _tenantRepo.Object, _idpConfigRepo.Object); private void SetupAuthUseExternalIdp(bool value) => _config.Setup(c => c.GetWithPrecedence(AppConfigurationCodes.AuthUseExternalIdp, _tenantId)) .Returns(BuildConfiguration(_tenantId, value)); + private void SetupIdpConfigurations(params IdpConfigurationAggregate[] configurations) + => _idpConfigRepo.Setup(r => r.GetByTenantIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(configurations.ToList()); + // ── Local mode ──────────────────────────────────────────────────────────── [Fact] @@ -70,7 +88,7 @@ public async Task ResolveAsync_WhenIdpConfigAndActiveIdp_ReturnsIdpMethod() { SetupAuthUseExternalIdp(true); _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) - .ReturnsAsync(BuildTenantWithActiveIdp()); + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); @@ -80,7 +98,7 @@ public async Task ResolveAsync_WhenIdpConfigAndActiveIdp_ReturnsIdpMethod() } [Fact] - public async Task ResolveAsync_WhenIdpConfigButNoActiveIdp_ReturnsError_AUTH011() + public async Task ResolveAsync_WhenIdpConfigButNoActiveIdp_ReturnsLocalAuth() { SetupAuthUseExternalIdp(true); _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) @@ -88,8 +106,8 @@ public async Task ResolveAsync_WhenIdpConfigButNoActiveIdp_ReturnsError_AUTH011( var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); - Assert.True(result.IsFailure); - Assert.Contains("AUTH_011", result.Error); + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); } [Fact] @@ -117,7 +135,7 @@ public async Task ResolveAsync_UsesConfigProvider_NotHardcoded() SetupAuthUseExternalIdp(true); _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) - .ReturnsAsync(BuildTenantWithActiveIdp()); + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); var idp = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); Assert.Equal(AuthMethodType.IDP, idp.Value.Type); } @@ -135,9 +153,110 @@ public async Task ResolveAsync_PortalManagementAlwaysReturnsLocal() _tenantRepo.Verify(r => r.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Never); } + // ── FR-042 (ADR-UMS-097 §2.1): selección por reglas + puente al IdentityProvider ── + + [Fact] + public async Task ResolveAsync_RuleBasedSelection_BridgesToProviderOfWinningConfig() + { + // Dos configuraciones activas de distinta prioridad; el proveedor activo del inquilino + // corresponde a la de menor orden (AzureAd, prioridad 5). El puente reglas↔proveedor casa. + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 5), + BuildActiveConfig(ProviderType.Keycloak, priority: 10)); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.NotNull(result.Value.Provider); + Assert.Equal(IdpStrategy.AzureAd, result.Value.Provider!.Strategy); + } + + [Fact] + public async Task ResolveAsync_RuleSelectsProviderNotActive_DoesNotSubstituteActiveProvider() + { + // La regla elige KEYCLOAK (prioridad 5), pero el único proveedor activo del inquilino es AzureAd. + // No se sustituye por AzureAd (sería autenticar contra un IdP no elegido): se resuelve a Local. + // Prueba de que la selección la gobiernan las reglas, no FirstOrDefault(IsActive). + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.Keycloak, priority: 5), + BuildActiveConfig(ProviderType.AzureAd, priority: 10)); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); + } + + [Fact] + public async Task ResolveAsync_SuiteFilter_SelectsConfigOfMatchingSuite() + { + // Inquilino con proveedor Keycloak activo. Dos suites: A→AzureAd, B→Keycloak (misma prioridad). + // Filtrando por la suite B se elige la config Keycloak y el puente casa con el proveedor activo. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.Keycloak)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 1, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi, systemSuiteId: suiteB); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.Equal(IdpStrategy.Keycloak, result.Value.Provider!.Strategy); + } + + [Fact] + public async Task ResolveAsync_SuiteFilter_WhenMatchingConfigProviderNotActive_ReturnsLocal() + { + // Filtrando por la suite A la regla elige AzureAd, pero el proveedor activo es Keycloak → Local. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.Keycloak)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 1, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi, systemSuiteId: suiteA); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); + } + + [Fact] + public async Task ResolveAsync_DomainRouting_PrefersDomainMatchedConfigOverPriority() + { + // Sin dominio gana Keycloak (prioridad 1). Con dominio acme.com gana AzureAd (casa por dominio, + // aunque su prioridad 10 sea peor): el routing por dominio prima sobre la prioridad. + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 10, domainHints: new[] { "acme.com" }), + BuildActiveConfig(ProviderType.Keycloak, priority: 1)); + + var withDomain = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, emailDomain: "user@acme.com"); + + Assert.True(withDomain.IsSuccess); + Assert.Equal(AuthMethodType.IDP, withDomain.Value.Type); + Assert.Equal(IdpStrategy.AzureAd, withDomain.Value.Provider!.Strategy); + } + // ── Helpers ──────────────────────────────────────────────────────────────── - private static Ums.Domain.Identity.Tenant.Tenant BuildTenantWithActiveIdp() + private static Ums.Domain.Identity.Tenant.Tenant BuildTenantWithActiveIdp(IdpStrategy strategy) { var actor = ActorId.Create("test"); var tenant = Ums.Domain.Identity.Tenant.Tenant.Create( @@ -145,13 +264,13 @@ private static Ums.Domain.Identity.Tenant.Tenant BuildTenantWithActiveIdp() Name.Create("Test Tenant"), Ums.Domain.Enums.OrganizationType.INTERNAL, actor, - Ums.Domain.Enums.IdpStrategy.AzureAd).Value; + strategy).Value; tenant.RegisterIdentityProvider( - Code.Create("AZURE"), - Name.Create("Azure AD"), + Code.Create(strategy.Name.ToUpperInvariant()), + Name.Create(strategy.Name), Description.Create(""), - Ums.Domain.Enums.IdpStrategy.AzureAd, + strategy, actor); var idp = tenant.IdentityProviders.First(); @@ -173,6 +292,27 @@ private static Ums.Domain.Identity.Tenant.Tenant BuildTenantWithNoActiveIdp() return tenant; } + private IdpConfigurationAggregate BuildActiveConfig( + ProviderType providerType, + int priority, + Guid? suiteId = null, + string[]? domainHints = null) + { + var actor = ActorId.Create("test"); + var config = IdpConfigurationAggregate.Create( + TenantId.Load(_tenantId), + suiteId.HasValue ? SystemSuiteId.Load(suiteId.Value) : SystemSuiteId.Create(), + providerType, + domainHints ?? Array.Empty(), + "{\"issuer\":\"https://idp.example\"}", + "vault/secret/idp", + priority, + null, + actor).Value; + config.Activate(actor); + return config; + } + private static AppConfigurationAggregate BuildConfiguration(Guid tenantId, bool value) { var actor = ActorId.Create("test"); diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthenticateUserCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthenticateUserCommandHandlerTests.cs new file mode 100644 index 00000000..bd199476 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthenticateUserCommandHandlerTests.cs @@ -0,0 +1,223 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Application.Common.Interfaces; +using Ums.Application.Configuration.Services; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Configuration.AppConfiguration; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Pruebas unitarias de centradas en el bloqueo +/// temporal de cuenta por intentos fallidos (ADR-UMS-095). Todas las dependencias están mockeadas +/// —sin BD ni infraestructura— y el instante determinista se controla desde el arrange del dominio. +/// +/// Cumplimiento (ADR-UMS-095, sección test-first): +/// · Tras MaxLoginAttempts fallos, el intento N+1 con credencial CORRECTA devuelve «cuenta +/// bloqueada» (AUTH_017), no un login exitoso, y NO se llega a construir el grafo. +/// · Tras expirar LockedUntil, el login correcto vuelve a funcionar y resetea el contador. +/// +public sealed class AuthenticateUserCommandHandlerTests +{ + private readonly Mock _tenantRepo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _methodResolver = new(); + private readonly Mock _localStrategy = new(); + private readonly Mock _chainAuth = new(); + private readonly Mock _graphBuilder = new(); + private readonly Mock _serializer = new(); + private readonly Mock _auditService = new(); + private readonly Mock _configProvider = new(); + private readonly Mock _unitOfWork = new(); + + private const int MaxAttempts = 3; + private const int LockoutMinutes = 15; + private const string CorrectPassword = "correct-horse"; + private const string WrongPassword = "wrong-password"; + private const string TenantCode = "TEST"; + private const string UserEmail = "user@test.com"; + + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + + private AuthenticateUserCommandHandler CreateSut() => new( + _tenantRepo.Object, _userRepo.Object, _methodResolver.Object, + _localStrategy.Object, _chainAuth.Object, _graphBuilder.Object, + _serializer.Object, _auditService.Object, + _configProvider.Object); + + private static AuthenticateUserCommand Command(string password) + => new(TenantCode, UserEmail, password, ClientIp: "10.0.0.1", AuthAccessScope.ExternalApi); + + // ── Test-first ADR-UMS-095: intento N+1 con credencial correcta ⇒ AUTH_017 ───────── + + [Fact] + public async Task AuthenticateLocal_AfterMaxFailedAttempts_ReturnsAccountLocked() + { + var user = BuildActiveUser(); + SetupLocalPipeline(user); + var sut = CreateSut(); + + // MaxLoginAttempts fallos consecutivos con credencial incorrecta. + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + var failed = await sut.Handle(Command(WrongPassword), CancellationToken.None); + Assert.True(failed.IsFailure); + Assert.Contains("AUTH_006", failed.Error); + } + + // Intento N+1 con credencial CORRECTA: debe rechazar por bloqueo, NO iniciar sesión. + var locked = await sut.Handle(Command(CorrectPassword), CancellationToken.None); + + Assert.True(locked.IsFailure); + Assert.Contains("AUTH_017", locked.Error); + Assert.True(user.IsLockedOut(DateTimeOffset.UtcNow)); + // No se llega a construir el grafo: el rechazo ocurre antes de validar credenciales. + _graphBuilder.Verify(g => g.BuildAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task AuthenticateLocal_BelowThreshold_ThenCorrect_Succeeds() + { + var user = BuildActiveUser(); + SetupLocalPipeline(user); + var sut = CreateSut(); + + // Un fallo (por debajo del umbral) no bloquea; el login correcto siguiente entra. + var failed = await sut.Handle(Command(WrongPassword), CancellationToken.None); + Assert.True(failed.IsFailure); + + var ok = await sut.Handle(Command(CorrectPassword), CancellationToken.None); + + Assert.True(ok.IsSuccess); + Assert.Equal(0, user.FailedLoginAttempts); + Assert.Null(user.LockedUntil); + } + + // ── Test-first ADR-UMS-095: tras expirar el bloqueo, el login correcto reabre y resetea ── + + [Fact] + public async Task AuthenticateLocal_AfterLockoutExpires_CorrectLoginSucceedsAndResets() + { + var user = BuildActiveUser(); + // Arrange determinista: bloqueo ya vencido. El instante se inyecta en el dominio (no reloj real): + // un fallo con umbral 1 y duración 1 min, situado 30 min en el pasado ⇒ LockedUntil ~29 min atrás. + var past = DateTimeOffset.UtcNow.AddMinutes(-30); + user.RecordAuthenticationAttempt(false, past, maxAttempts: 1, lockoutMinutes: 1, "Invalid password", "10.0.0.1", ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + Assert.False(user.IsLockedOut(DateTimeOffset.UtcNow)); + + SetupLocalPipeline(user); + var sut = CreateSut(); + + var ok = await sut.Handle(Command(CorrectPassword), CancellationToken.None); + + Assert.True(ok.IsSuccess); + Assert.Equal(0, user.FailedLoginAttempts); + Assert.Null(user.LockedUntil); + Assert.False(user.IsLockedOut(DateTimeOffset.UtcNow)); + } + + // ── Setup helpers ──────────────────────────────────────────────────────────── + + private void SetupLocalPipeline(UserAccountAggregate user) + { + _tenantRepo.Setup(r => r.GetByCodeAsync(TenantCode, It.IsAny())) + .ReturnsAsync(BuildActiveTenant()); + + _methodResolver.Setup(m => m.ResolveAsync(TenantGuid, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(AuthMethod.Local())); + + // G-168: el login busca acotado al inquilino. El stub global por email queda a + // propósito devolviendo null: si alguien reintroduce la búsqueda sin inquilino, estas + // pruebas fallan en vez de pasar por casualidad. + _userRepo.Setup(r => r.GetByTenantAndEmailAsync( + TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(user); + _userRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + _unitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + _userRepo.Setup(r => r.UnitOfWork).Returns(_unitOfWork.Object); + _serializer.Setup(x => x.FileExtension).Returns("json"); + + // Estrategia local: la contraseña correcta valida; cualquier otra falla (AUTH_006). + _localStrategy.Setup(s => s.Authenticate(It.IsAny(), CorrectPassword)) + .Returns(Result.Success()); + _localStrategy.Setup(s => s.Authenticate(It.IsAny(), It.Is(p => p != CorrectPassword))) + .Returns(Result.Failure("AUTH_006: Invalid username or password.")); + + // Config efectiva: umbral y duración del bloqueo (ADR-UMS-095). MfaRequiredForAdmin queda en false por defecto. + _configProvider.Setup(p => p.GetValueAs(AppConfigurationCodes.MaxLoginAttempts, It.IsAny(), It.IsAny())) + .Returns(MaxAttempts); + _configProvider.Setup(p => p.GetValueAs(AppConfigurationCodes.AccountLockoutDurationMinutes, It.IsAny(), It.IsAny())) + .Returns(LockoutMinutes); + + // Pipeline del grafo para el camino exitoso. + _graphBuilder.Setup(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(BuildGraph())); + _serializer.Setup(s => s.Serialize(It.IsAny(), It.IsAny())) + .Returns("{}"); + } + + private static TenantAggregate BuildActiveTenant() + { + var tenant = TenantAggregate.Create( + Code.Create(TenantCode), + Name.Create("Test Tenant"), + OrganizationType.INTERNAL, + ActorId.Create("test"), + IdpStrategy.InternalBcrypt, + tenantId: TenantId.Load(TenantGuid)).Value; // Create ⇒ Status Active + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static UserAccountAggregate BuildActiveUser() + { + var user = UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create(UserEmail), + UserCategory.Internal, + null, null, + ActorId.Create("test"), + null, + UserAccountId.Load(UserGuid)).Value; + user.Activate(ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static AuthorizationGraph BuildGraph() + { + var context = new GraphContext( + new GraphUser(UserGuid, UserEmail, "user", "User", "Active"), + new GraphTenant(TenantGuid, TenantCode, "Test Tenant", "Active", false), + SystemSuite: null, Role: null, Profile: null, Branch: null); + + var authentication = new GraphAuthentication( + "Local", Provider: null, MfaRequired: false, + IssuedAt: DateTime.UtcNow, SessionExpiresAt: DateTime.UtcNow.AddMinutes(30)); + + var effectiveConfig = new GraphEffectiveConfig( + SessionTimeoutMinutes: 30, MaxLoginAttempts: MaxAttempts, MinPasswordLength: 8, + MfaRequiredForAdmin: false, MfaAllowedMethods: Array.Empty(), + AccessTokenDurationMs: 900_000, AuthUseExternalIdp: false); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + DateTime.UtcNow); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ForgotPasswordCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ForgotPasswordCommandHandlerTests.cs new file mode 100644 index 00000000..210710f2 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ForgotPasswordCommandHandlerTests.cs @@ -0,0 +1,299 @@ +namespace Ums.Application.Test.Identity.Auth; + +using System.Text.Json; +using Ums.Application.Common.Interfaces; +using Ums.Application.Common.Notifications; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Enums; +using Ums.Domain.Identity; +using Ums.Domain.Identity.UserAccount; +using Ums.Domain.Kernel; +using Moq; +using Xunit; + +/// +/// G-188: la solicitud anónima de restablecimiento no puede filtrar la contraseña, no puede +/// cambiarla y no puede delatar si la cuenta existe. Estas pruebas fijan las tres cosas. +/// +public class ForgotPasswordCommandHandlerTests +{ + private readonly Mock _tenantRepo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _resetTokens = new(); + private readonly Mock _notifications = new(); + private readonly Mock _timing = new(); + private readonly Mock _uow = new(); + + private static readonly Guid TenantIdValue = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid OtherTenantIdValue = Guid.Parse("9c1c2b3a-0000-4000-8000-000000000099"); + private const string TenantCode = "ACME"; + private const string UserEmail = "admin@acme.com"; + private const string ExistingHash = "hash-de-la-clave-vigente"; + + public ForgotPasswordCommandHandlerTests() + { + _userRepo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + } + + private static Domain.Identity.Tenant.Tenant MakeTenant() => + Domain.Identity.Tenant.Tenant.Create( + Code.Create(TenantCode), Name.Create("Acme Corp"), + OrganizationType.INTERNAL, ActorId.Create("sys"), + tenantId: TenantId.Load(TenantIdValue)).Value; + + private static UserAccount MakeUserWithPassword(Guid tenantId) + { + var user = UserAccount.Create( + TenantId.Load(tenantId), + Email.Create(UserEmail), + UserCategory.Internal, + null, null, + ActorId.Create("sys")).Value; + user.Activate(ActorId.Create("sys")); + user.AddPassword(PasswordHash.Create(ExistingHash), ActorId.Create("sys")); + return user; + } + + private ForgotPasswordCommandHandler CreateHandler() => + new(_tenantRepo.Object, _userRepo.Object, _resetTokens.Object, _notifications.Object, _timing.Object); + + private void GivenTenantExists() => + _tenantRepo.Setup(r => r.GetByCodeAsync(TenantCode, It.IsAny())) + .ReturnsAsync(MakeTenant()); + + private void GivenAccount(UserAccount? account) => + _userRepo.Setup(r => r.GetByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(account); + + private Task> Invoke(string email = UserEmail) => + CreateHandler().Handle(new ForgotPasswordCommand(TenantCode, email), CancellationToken.None); + + // ========================================================================= + #region No se filtra la contraseña + // ========================================================================= + + [Fact] + public void ForgotPassword_ResponseCarriesOnlyTheAmbiguousMessage() + { + // El contrato mismo es la defensa: si alguien reintroduce un campo con la credencial, + // esta prueba se pone en rojo antes de que llegue a un entorno. + var propiedades = typeof(ForgotPasswordResponse).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Equal(new[] { "Message" }, propiedades); + } + + [Fact] + public async Task ForgotPassword_WhenAccountExists_ResponseNeverContainsTheIssuedToken() + { + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + + string? hashEmitido = null; + _resetTokens + .Setup(s => s.IssueAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (_, _, hash, _, _, _) => hashEmitido = hash); + + string? tokenNotificado = null; + _notifications + .Setup(n => n.SendAsync(It.IsAny(), It.IsAny())) + .Callback((n, _) => tokenNotificado = n.Body); + + var result = await Invoke(); + var cuerpo = JsonSerializer.Serialize(result.Value); + + Assert.NotNull(hashEmitido); + Assert.NotNull(tokenNotificado); + Assert.DoesNotContain(hashEmitido!, cuerpo, StringComparison.Ordinal); + Assert.DoesNotContain(ExistingHash, cuerpo, StringComparison.Ordinal); + } + + #endregion + + // ========================================================================= + #region La respuesta es indistinguible + // ========================================================================= + + [Fact] + public async Task ForgotPassword_ResponseIsByteIdenticalForExistingAndUnknownAccount() + { + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + var conCuenta = await Invoke(); + + GivenAccount(null); + var sinCuenta = await Invoke("nadie@acme.com"); + + Assert.True(conCuenta.IsSuccess); + Assert.True(sinCuenta.IsSuccess); + Assert.Equal( + JsonSerializer.Serialize(conCuenta.Value), + JsonSerializer.Serialize(sinCuenta.Value)); + } + + [Fact] + public async Task ForgotPassword_WhenTenantIsUnknown_ResponseIsTheSame() + { + _tenantRepo.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Domain.Identity.Tenant.Tenant?)null); + + var sinTenant = await Invoke(); + + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + var conTodo = await Invoke(); + + Assert.Equal( + JsonSerializer.Serialize(conTodo.Value), + JsonSerializer.Serialize(sinTenant.Value)); + } + + [Fact] + public async Task ForgotPassword_AlwaysNormalizesResponseTiming() + { + // Sin nivelar el reloj, el camino «existe» (lectura + escritura + notificación) tarda + // sistemáticamente más que el camino «no existe»: el tiempo sería el oráculo. + _tenantRepo.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Domain.Identity.Tenant.Tenant?)null); + await Invoke(); + + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + await Invoke(); + + _timing.Verify(t => t.NormalizeAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + #endregion + + // ========================================================================= + #region La contraseña vigente sobrevive a la solicitud + // ========================================================================= + + [Fact] + public async Task ForgotPassword_WhenAccountExists_LeavesTheCurrentPasswordUntouched() + { + GivenTenantExists(); + var user = MakeUserWithPassword(TenantIdValue); + GivenAccount(user); + + await Invoke(); + + var vigente = Assert.Single(user.PasswordCredentials); + Assert.True(vigente.IsActive); + Assert.Equal(ExistingHash, vigente.PasswordHash.GetValue()); + } + + [Fact] + public async Task ForgotPassword_NeverPersistsTheUserAccount() + { + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + + await Invoke(); + + _userRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + + #endregion + + // ========================================================================= + #region Emisión del token + // ========================================================================= + + [Fact] + public async Task ForgotPassword_WhenAccountExists_IssuesShortLivedTokenAndNotifiesTheMailbox() + { + GivenTenantExists(); + var user = MakeUserWithPassword(TenantIdValue); + GivenAccount(user); + + await Invoke(); + + _resetTokens.Verify(s => s.IssueAsync( + TenantIdValue, + user.Props.Id.GetValue(), + It.Is(hash => hash.Length == 64), // SHA-256 hex: nunca el plaintext + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + + _notifications.Verify(n => n.SendAsync( + It.Is(msg => msg.Recipient == UserEmail), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ForgotPassword_WhenAccountBelongsToAnotherTenant_IssuesNothing() + { + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(OtherTenantIdValue)); + + await Invoke(); + + VerifyNothingHappened(); + } + + [Fact] + public async Task ForgotPassword_WhenAccountIsFederated_IssuesNothing() + { + GivenTenantExists(); + var federado = UserAccount.Create( + TenantId.Load(TenantIdValue), + Email.Create(UserEmail), + UserCategory.Internal, + IdentityReference.Create("keycloak|abc"), + IdentityReferenceType.PartnerRef, + ActorId.Create("sys")).Value; + federado.Activate(ActorId.Create("sys")); + GivenAccount(federado); + + await Invoke(); + + VerifyNothingHappened(); + } + + [Fact] + public async Task ForgotPassword_WhenAccountIsBlocked_IssuesNothing() + { + GivenTenantExists(); + var bloqueado = MakeUserWithPassword(TenantIdValue); + bloqueado.Block(Reason.Create("prueba"), ActorId.Create("sys")); + GivenAccount(bloqueado); + + await Invoke(); + + VerifyNothingHappened(); + } + + [Fact] + public async Task ForgotPassword_WhenTenantIsUnknown_DoesNotEvenLookUpTheAccount() + { + _tenantRepo.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Domain.Identity.Tenant.Tenant?)null); + + await Invoke(); + + _userRepo.Verify(r => r.GetByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + VerifyNothingHappened(); + } + + private void VerifyNothingHappened() + { + _resetTokens.Verify(s => s.IssueAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _notifications.Verify(n => n.SendAsync(It.IsAny(), It.IsAny()), Times.Never); + _userRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/IdpChainAuthenticatorTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/IdpChainAuthenticatorTests.cs new file mode 100644 index 00000000..39d76051 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/IdpChainAuthenticatorTests.cs @@ -0,0 +1,338 @@ +namespace Ums.Application.Test.Identity.Auth; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Ums.Application.Common.Interfaces; +using Ums.Application.Configuration.Services; +using Ums.Application.Identity.Auth; +using Ums.Domain.Configuration; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +/// +/// FR-042 (ADR-UMS-097 §2.3/§2.4, slice 2b): pruebas de seguridad del fallback encadenado. +/// +/// Las CLAVE son las de seguridad: +/// · Fallback SOLO por indisponibilidad: primario infra → intenta el siguiente; si el +/// siguiente tiene éxito → login OK. +/// · NO fallback por credenciales (anti credential-spraying): primario rechaza credenciales +/// → TERMINAL y el segundo IdP nunca se invoca. +/// · Ambiguo → terminal: un fallo no clasificable como infra no avanza. +/// · Ciclo A→B→A detectado, sin bucle; cadena agotada → 503 (AUTH_018), no 401. +/// · Auditoría: un evento por proveedor intentado. +/// +public class IdpChainAuthenticatorTests +{ + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly ActorId Actor = ActorId.Create("test"); + private const string Credential = "callback-o-password"; + private const string ClientIp = "10.0.0.1"; + + private readonly Mock _idpConfigRepo = new(); + private readonly Mock _idpStrategy = new(); + private readonly Mock _audit = new(); + private readonly Mock _config = new(); + + public IdpChainAuthenticatorTests() + { + // Tope de saltos: por defecto devuelve el default pasado por el orquestador (5), salvo que un + // test lo sobreescriba. Un método no configurado en Moq devolvería 0 → maxHops=1, así que ESTE + // setup es necesario para que la cadena pueda avanzar en los tests de fallback. + _config.Setup(c => c.GetValueAs(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, defaultValue) => defaultValue); + + _audit.Setup(a => a.RecordAuthEventAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + } + + private IdpChainAuthenticator CreateSut() + => new(_idpConfigRepo.Object, _idpStrategy.Object, _audit.Object, _config.Object); + + // ── Fallback por indisponibilidad ──────────────────────────────────────────── + + [Fact] + public async Task Fallback_PrimaryInfraUnavailable_AdvancesAndSecondSucceeds() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + // A (AzureAd, prio 1) → B (Keycloak, prio 2). El selector elige A por prioridad. + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); // primario: JWKS caído + SetupStrategy(IdpStrategy.Keycloak, Ok("user@ransa.pe")); // respaldo: éxito + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("user@ransa.pe", result.Value.Identity.Email); + Assert.Equal(IdpStrategy.Keycloak.Id, result.Value.Provider.Strategy.Id); + VerifyStrategyCalled(IdpStrategy.AzureAd, Times.Once()); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Once()); + VerifyAuditEvents(Times.Exactly(2)); // un evento por proveedor intentado + } + + // ── NO fallback por credenciales (anti credential-spraying) — PRUEBA CLAVE ──── + + [Fact] + public async Task Credential_PrimaryRejects_Terminal_SecondNeverInvoked() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, CredentialRejected("AUTH_021")); // primario: el IdP RECHAZÓ la credencial + SetupStrategy(IdpStrategy.Keycloak, Ok("attacker@ransa.pe")); // nunca debe llegar aquí + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_021", result.Error); // terminal, se devuelve el error del IdP + VerifyStrategyCalled(IdpStrategy.AzureAd, Times.Once()); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Never()); // el SEGUNDO IdP NO se intenta + VerifyAuditEvents(Times.Exactly(1)); // solo el intento primario + } + + // ── Ambiguo → terminal (fail-closed) ───────────────────────────────────────── + + [Fact] + public async Task Ambiguous_UnclassifiableFailure_Terminal_NoAdvance() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + // Fallo no clasificable como infra (no está en la lista blanca) → fail-closed → terminal. + SetupStrategy(IdpStrategy.AzureAd, Result.Failure("AUTH_099: fallo raro no clasificable")); + SetupStrategy(IdpStrategy.Keycloak, Ok()); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_099", result.Error); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Never()); + VerifyAuditEvents(Times.Exactly(1)); + } + + // ── Ciclo A→B→A detectado; cadena agotada → 503 ────────────────────────────── + + [Fact] + public async Task Cycle_AtoBtoA_Detected_NoInfiniteLoop_Returns503() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + Link(a, b); + Link(b, a); // ciclo + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); + SetupStrategy(IdpStrategy.Keycloak, Infra("AUTH_012")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_018", result.Error); // cadena agotada → 503 + VerifyStrategyCalled(IdpStrategy.AzureAd, Times.Once()); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Once()); + VerifyAuditEvents(Times.Exactly(2)); // A y B, sin bucle infinito + } + + // ── Cadena lineal agotada (todos infra) → 503 ──────────────────────────────── + + [Fact] + public async Task ChainExhausted_AllInfra_ReturnsServiceUnavailable() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); + SetupStrategy(IdpStrategy.Keycloak, Infra("AUTH_034")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_018", result.Error); + VerifyAuditEvents(Times.Exactly(2)); + } + + // ── Tope de saltos configurable ────────────────────────────────────────────── + + [Fact] + public async Task HopCap_LimitsTraversal_StopsAtCap() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak, IdpStrategy.Okta); + var c = BuildActiveConfig(ProviderType.Okta, priority: 3); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + Link(b, c); + SetupConfigurations(a, b, c); + + // Tope = 1 salto: solo se intenta el primario, la cadena se corta antes del segundo. + _config.Setup(cfg => cfg.GetValueAs(IdpChainAuthenticator.MaxHopsConfigCode, It.IsAny(), It.IsAny())) + .Returns(1); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); + SetupStrategy(IdpStrategy.Keycloak, Ok()); + SetupStrategy(IdpStrategy.Okta, Ok()); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_018", result.Error); + VerifyStrategyCalled(IdpStrategy.AzureAd, Times.Once()); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Never()); + VerifyAuditEvents(Times.Exactly(1)); + } + + // ── Camino feliz sin fallback ──────────────────────────────────────────────── + + [Fact] + public async Task Primary_Succeeds_NoAdvance_SingleAttempt() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, Ok("user@ransa.pe")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsSuccess); + Assert.Equal(IdpStrategy.AzureAd.Id, result.Value.Provider.Strategy.Id); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Never()); + VerifyAuditEvents(Times.Exactly(1)); + } + + // ── Legado: sin IdpConfiguration gobernante → intento único, sin 503 ────────── + + [Fact] + public async Task Legacy_NoGoverningConfig_SingleAttempt_Succeeds() + { + var tenant = BuildTenant(IdpStrategy.AzureAd); + SetupConfigurations(); // sin configuraciones → path legado + + SetupStrategy(IdpStrategy.AzureAd, Ok("user@ransa.pe")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsSuccess); + Assert.Equal(IdpStrategy.AzureAd.Id, result.Value.Provider.Strategy.Id); + VerifyAuditEvents(Times.Exactly(1)); + } + + [Fact] + public async Task Legacy_NoGoverningConfig_InfraFailure_ReturnsRawError_NotChainExhausted() + { + // Sin cadena no hay 503: el intento único devuelve el error crudo (comportamiento previo a 2b). + var tenant = BuildTenant(IdpStrategy.AzureAd); + SetupConfigurations(); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_034", result.Error); + Assert.DoesNotContain("AUTH_018", result.Error); + VerifyAuditEvents(Times.Exactly(1)); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private void SetupConfigurations(params IdpConfigurationAggregate[] configurations) + => _idpConfigRepo.Setup(r => r.GetByTenantIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(configurations.ToList()); + + private void SetupStrategy(IdpStrategy strategy, Result result) + => _idpStrategy.Setup(s => s.AuthenticateAsync( + It.IsAny(), It.IsAny(), + It.Is(p => p.Strategy.Id == strategy.Id), It.IsAny())) + .ReturnsAsync(result); + + private void VerifyStrategyCalled(IdpStrategy strategy, Times times) + => _idpStrategy.Verify(s => s.AuthenticateAsync( + It.IsAny(), It.IsAny(), + It.Is(p => p.Strategy.Id == strategy.Id), It.IsAny()), + times); + + private void VerifyAuditEvents(Times times) + => _audit.Verify(a => a.RecordAuthEventAsync(It.IsAny(), It.IsAny()), times); + + private static Result Ok(string email = "user@ransa.pe") + => Result.Success(new ExternalIdentity(email, "sub", "User", new Dictionary())); + + private static Result Infra(string code) + => Result.Failure($"{code}: indisponibilidad de infraestructura del IdP."); + + private static Result CredentialRejected(string code) + => Result.Failure($"{code}: el IdP rechazó la credencial."); + + private static TenantAggregate BuildTenant(params IdpStrategy[] registeredStrategies) + { + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + Ums.Domain.Enums.OrganizationType.INTERNAL, + Actor, + registeredStrategies[0], + tenantId: TenantId.Load(TenantGuid)).Value; + + foreach (var strategy in registeredStrategies) + { + tenant.RegisterIdentityProvider( + Code.Create(strategy.Name.ToUpperInvariant()), + Name.Create(strategy.Name), + Description.Create(string.Empty), + strategy, + Actor); + } + + // Se activa el primer proveedor como «primario» (el inquilino solo admite uno activo). Los demás + // quedan registrados+inactivos: el puente 2b los usa igualmente porque la cadena la gobierna + // IdpConfiguration.Status, no el flag de proveedor activo (ADR-UMS-097 §2.5). + var first = tenant.IdentityProviders.First(); + tenant.ActivateIdentityProvider(first.GetId(), Actor); + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static IdpConfigurationAggregate BuildActiveConfig(ProviderType providerType, int priority) + { + var config = IdpConfigurationAggregate.Create( + TenantId.Load(TenantGuid), + SystemSuiteId.Create(), + providerType, + Array.Empty(), + "{\"issuer\":\"https://idp.example\"}", + "vault/secret/idp", + priority, + fallbackToId: null, + Actor).Value; + config.Activate(Actor); + return config; + } + + // Enlaza from → to por FallbackToId (setter público de Props; el enlace es inmutable vía Create, + // así que en tests se establece directamente para poder construir cadenas y ciclos). + private static void Link(IdpConfigurationAggregate from, IdpConfigurationAggregate to) + => from.Props.FallbackToId = to.Props.Id.GetValue(); +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/HttpOidcTokenClientTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/HttpOidcTokenClientTests.cs new file mode 100644 index 00000000..e5f406fa --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/HttpOidcTokenClientTests.cs @@ -0,0 +1,175 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Net; +using System.Net.Http; +using Xunit; +using Ums.Domain.Identity.Auth; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// FR-042 · ADR-UMS-097 §2.3 · G-108 — pruebas de la SEPARACIÓN ESTRUCTURAL que hace +/// al intercambiar el código en el token endpoint: +/// +/// 5xx / timeout / transporte → indisponibilidad de INFRAESTRUCTURA → código +/// AUTH_035, que el reconoce como infra y que +/// HABILITA el fallback encadenado. +/// 4xx (invalid_grant/…) → fallo de CREDENCIAL → código AUTH_021, que sigue +/// siendo TERMINAL (NUNCA infra): es la invariante anti credential spraying de ADR-UMS-097 §2.3. +/// +/// La distinción se hace por la CLASE de status HTTP / tipo de excepción, no por el texto del mensaje. +/// Todos los casos usan un fake; ningún IdP real interviene. +/// +public sealed class HttpOidcTokenClientTests +{ + private static readonly OidcEndpoints Endpoints = OidcTestSupport.Endpoints(); + private static readonly OidcClientSettings Client = + new("ums-client", "secreto", "https://ums.test/callback"); + + private static HttpOidcTokenClient ClientWith(FakeTokenHttpHandler handler) => + new(new HttpClient(handler)); + + private static Task> Exchange(HttpOidcTokenClient sut, CancellationToken ct = default) => + sut.ExchangeAuthorizationCodeAsync( + Endpoints, Client, code: "auth-code-123", codeVerifier: "verifier-123", + redirectUri: "https://ums.test/callback", ct); + + // ── Rama de INFRAESTRUCTURA: 5xx → AUTH_035 → clasifica como infra (habilita fallback) ──────── + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] // 500 + [InlineData(HttpStatusCode.BadGateway)] // 502 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + public async Task Exchange_TokenEndpoint5xx_EmitsInfraCode_AUTH035(HttpStatusCode status) + { + var sut = ClientWith(FakeTokenHttpHandler.WithStatus(status, "{\"error\":\"server_error\"}")); + + var result = await Exchange(sut); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_035", result.Error); + Assert.DoesNotContain("AUTH_021", result.Error); + // Verificación de extremo a extremo: el código emitido lo trata el clasificador como infra. + Assert.True(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + [Fact] + public async Task Exchange_TransportFailure_EmitsInfraCode_AUTH035() + { + // HttpRequestException = no se pudo completar el round-trip (conexión/red/DNS) → INFRA. + var sut = ClientWith(FakeTokenHttpHandler.Throwing(new HttpRequestException("connection refused"))); + + var result = await Exchange(sut); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_035", result.Error); + Assert.True(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + [Fact] + public async Task Exchange_Timeout_EmitsInfraCode_AUTH035() + { + // Timeout del token endpoint: el HttpClient aborta con TaskCanceledException SIN que el token del + // llamador esté cancelado → INFRA (no es una cancelación del llamador). + var sut = ClientWith(FakeTokenHttpHandler.Throwing(new TaskCanceledException("timeout"))); + + var result = await Exchange(sut, ct: default); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_035", result.Error); + Assert.True(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + // ── Rama de CREDENCIAL: 4xx → AUTH_021 → TERMINAL (NUNCA infra) — anti credential spraying ──── + + [Theory] + [InlineData(HttpStatusCode.BadRequest, "{\"error\":\"invalid_grant\"}")] // 400 invalid_grant (código inválido) + [InlineData(HttpStatusCode.Unauthorized, "{\"error\":\"invalid_client\"}")] // 401 invalid_client + [InlineData(HttpStatusCode.Forbidden, "{\"error\":\"access_denied\"}")] // 403 + public async Task Exchange_TokenEndpoint4xx_EmitsCredentialCode_AUTH021_NotInfra(HttpStatusCode status, string body) + { + var sut = ClientWith(FakeTokenHttpHandler.WithStatus(status, body)); + + var result = await Exchange(sut); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_021", result.Error); + Assert.DoesNotContain("AUTH_035", result.Error); + // GUARDA ANTI-REGRESIÓN DE SEGURIDAD: un 4xx (fallo de credencial) JAMÁS debe clasificarse como + // infra; hacerlo permitiría encadenar la misma credencial contra cada IdP (credential spraying + // cross-IdP, ADR-UMS-097 §2.3). NO "arreglar" esta aserción debilitándola: la resiliencia se ganó + // separando el 5xx en AUTH_035, no relajando el 4xx. + Assert.False(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + // ── Cuerpo 2xx malformado: la credencial fue aceptada pero no parsea → TERMINAL (fail-closed) ─ + + [Fact] + public async Task Exchange_Success2xx_MalformedJsonBody_EmitsTerminalCode_AUTH021_NotInfra() + { + var sut = ClientWith(FakeTokenHttpHandler.WithStatus(HttpStatusCode.OK, "esto-no-es-json")); + + var result = await Exchange(sut); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_021", result.Error); + Assert.False(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + // ── Cancelación del LLAMADOR: no es indisponibilidad del IdP → se propaga (no se enmascara) ─── + + [Fact] + public async Task Exchange_CallerCancels_PropagatesCancellation_NotInfra() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var sut = ClientWith(FakeTokenHttpHandler.HonoringCancellation()); + + await Assert.ThrowsAnyAsync(() => Exchange(sut, cts.Token)); + } + + // ── Camino feliz: 2xx con JSON válido → id_token parseado ───────────────────────────────────── + + [Fact] + public async Task Exchange_Success2xx_ValidJson_ReturnsToken() + { + const string body = + "{\"id_token\":\"abc.def.ghi\",\"access_token\":\"at\",\"token_type\":\"Bearer\",\"expires_in\":300}"; + var sut = ClientWith(FakeTokenHttpHandler.WithStatus(HttpStatusCode.OK, body)); + + var result = await Exchange(sut); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("abc.def.ghi", result.Value.IdToken); + } + + /// + /// fake que devuelve un status configurado, lanza una excepción + /// de transporte/timeout, u honra la cancelación del token. Permite ejercer las ramas 5xx/4xx/2xx + /// y de excepción de sin un IdP real. + /// + private sealed class FakeTokenHttpHandler : HttpMessageHandler + { + private readonly Func _responder; + + private FakeTokenHttpHandler(Func responder) + => _responder = responder; + + public static FakeTokenHttpHandler WithStatus(HttpStatusCode status, string body) => + new((_, _) => new HttpResponseMessage(status) { Content = new StringContent(body) }); + + public static FakeTokenHttpHandler Throwing(Exception ex) => + new((_, _) => throw ex); + + public static FakeTokenHttpHandler HonoringCancellation() => + new((_, ct) => + { + ct.ThrowIfCancellationRequested(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; + }); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_responder(request, cancellationToken)); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcAuthorizationRequestFactoryTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcAuthorizationRequestFactoryTests.cs new file mode 100644 index 00000000..8d4363d9 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcAuthorizationRequestFactoryTests.cs @@ -0,0 +1,102 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Security.Cryptography; +using System.Text; +using Xunit; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Unit tests de la construcción de la URL de autorización (Authorization Code + PKCE +/// S256) y de las primitivas PKCE (ADR-UMS-094). +/// +public sealed class OidcAuthorizationRequestFactoryTests +{ + private static OidcProviderConfig Config() => + new( + OidcTestSupport.Endpoints(), + new OidcClientSettings( + ClientId: "ums-client", + ClientSecret: "secreto", + RedirectUri: "https://ums.test/callback", + Scopes: "openid email profile")); + + [Fact] + public void Build_ProducesAuthorizationCodePkceUrlWithAllRequiredParameters() + { + var config = Config(); + + var request = new OidcAuthorizationRequestFactory().Build(config); + + var uri = new Uri(request.AuthorizationUrl); + var query = ParseQuery(uri.Query); + + Assert.StartsWith(config.Endpoints.AuthorizationEndpoint, request.AuthorizationUrl); + Assert.Equal("code", query["response_type"]); + Assert.Equal("ums-client", query["client_id"]); + Assert.Equal("https://ums.test/callback", query["redirect_uri"]); + Assert.Equal("openid email profile", query["scope"]); + Assert.Equal("S256", query["code_challenge_method"]); + Assert.Equal(request.State, query["state"]); + Assert.Equal(request.Nonce, query["nonce"]); + Assert.Equal(request.CodeChallenge, query["code_challenge"]); + } + + [Fact] + public void Build_CodeChallengeIsS256OfCodeVerifier() + { + var request = new OidcAuthorizationRequestFactory().Build(Config()); + + var expected = OidcPkce.ComputeS256Challenge(request.CodeVerifier); + + Assert.Equal(expected, request.CodeChallenge); + // El code_verifier nunca viaja en la URL de autorización (sólo el challenge). + Assert.DoesNotContain(request.CodeVerifier, request.AuthorizationUrl); + } + + [Fact] + public void Build_GeneratesFreshStateNonceVerifierEachTime() + { + var factory = new OidcAuthorizationRequestFactory(); + + var a = factory.Build(Config()); + var b = factory.Build(Config()); + + Assert.NotEqual(a.State, b.State); + Assert.NotEqual(a.Nonce, b.Nonce); + Assert.NotEqual(a.CodeVerifier, b.CodeVerifier); + } + + [Fact] + public void ComputeS256Challenge_MatchesManualSha256Base64Url() + { + const string verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + + var challenge = OidcPkce.ComputeS256Challenge(verifier); + + var expected = OidcTestSupport.Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + Assert.Equal(expected, challenge); + } + + [Fact] + public void Build_WithLoginHint_IncludesLoginHint() + { + var request = new OidcAuthorizationRequestFactory().Build(Config(), loginHint: "user@ransa.pe"); + + var query = ParseQuery(new Uri(request.AuthorizationUrl).Query); + Assert.Equal("user@ransa.pe", query["login_hint"]); + } + + private static Dictionary ParseQuery(string query) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var idx = pair.IndexOf('='); + var key = idx >= 0 ? pair[..idx] : pair; + var val = idx >= 0 ? pair[(idx + 1)..] : string.Empty; + result[Uri.UnescapeDataString(key)] = Uri.UnescapeDataString(val); + } + + return result; + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdTokenValidatorTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdTokenValidatorTests.cs new file mode 100644 index 00000000..b72fc5f5 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdTokenValidatorTests.cs @@ -0,0 +1,229 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Security.Cryptography; +using Xunit; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Unit tests de la validación estricta del id_token (ADR-UMS-094 · G-049). +/// Verifica éxito y, sobre todo, los negativos de seguridad (firma, exp, aud, nonce, +/// iss, alg) con llaves RSA generadas en el propio test y un JWKS fake — nunca un IdP +/// real (eso es el arnés Keycloak, slice 2). +/// +public sealed class OidcIdTokenValidatorTests +{ + private const string Kid = "test-key-1"; + private const string Audience = "ums-client"; + private const string Nonce = "nonce-123"; + + private readonly RSA _rsa = RSA.Create(2048); + private readonly OidcEndpoints _endpoints = OidcTestSupport.Endpoints(); + + private static OidcIdTokenValidator CreateSut(IJwksProvider jwks, TimeProvider? clock = null) + => new(jwks, clock ?? TimeProvider.System); + + private string SignValid(RSA signingKey, string alg = "RS256") + => OidcTestSupport.BuildIdToken( + signingKey, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, Nonce), + alg); + + // ── Éxito + mapeo de claims ───────────────────────────────────────────────── + + [Fact] + public async Task ValidateAsync_ValidToken_ReturnsSuccessWithMappedIdentity() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = SignValid(_rsa); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("user@ransa.pe", result.Value.Email); + Assert.Equal("idp|abc-123", result.Value.ExternalId); + Assert.Equal("Ana Ransa", result.Value.DisplayName); + Assert.Equal("user@ransa.pe", result.Value.Claims["email"]); + Assert.Equal("aransa", result.Value.Claims["preferred_username"]); + } + + // ── Negativos: firma ──────────────────────────────────────────────────────── + + [Fact] + public async Task ValidateAsync_SignatureFromDifferentKey_ReturnsFailure_AUTH026() + { + using var attacker = RSA.Create(2048); + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); // publica la buena + var token = SignValid(attacker); // firmada con otra llave + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_026", result.Error); + } + + [Fact] + public async Task ValidateAsync_AlgorithmNone_ReturnsFailure_AUTH024() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = SignValid(_rsa, alg: "none"); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_024", result.Error); + } + + [Fact] + public async Task ValidateAsync_AlgorithmConfusionHs256_ReturnsFailure_AUTH024() + { + // Confusión de algoritmo: un atacante firma HS256 con el 'client secret'. + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = SignValid(_rsa, alg: "HS256"); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_024", result.Error); + } + + [Fact] + public async Task ValidateAsync_UnknownKid_ReturnsFailure_AUTH025() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, "otro-kid")); + var token = SignValid(_rsa); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_025", result.Error); + } + + [Fact] + public async Task ValidateAsync_JwksFetchFails_ReturnsFailure_AUTH034() + { + var jwks = FakeJwksProvider.Failing("boom"); + var token = SignValid(_rsa); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_034", result.Error); + } + + // ── Negativos: claims ─────────────────────────────────────────────────────── + + [Fact] + public async Task ValidateAsync_Expired_ReturnsFailure_AUTH029() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var past = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 3600; + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, Nonce, exp: past, nbf: past - 60)); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_029", result.Error); + } + + [Fact] + public async Task ValidateAsync_NotYetValid_ReturnsFailure_AUTH030() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var future = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 3600; + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, Nonce, exp: future + 300, nbf: future)); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_030", result.Error); + } + + [Fact] + public async Task ValidateAsync_WrongAudience_ReturnsFailure_AUTH028() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, audience: "otro-cliente", nonce: Nonce)); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_028", result.Error); + } + + [Fact] + public async Task ValidateAsync_WrongIssuer_ReturnsFailure_AUTH027() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(issuer: "https://malicioso.test", audience: Audience, nonce: Nonce)); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_027", result.Error); + } + + [Fact] + public async Task ValidateAsync_NonceMismatch_ReturnsFailure_AUTH031() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, nonce: "nonce-del-atacante")); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, expectedNonce: Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_031", result.Error); + } + + [Fact] + public async Task ValidateAsync_MissingEmail_ReturnsFailure_AUTH032() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var payload = OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, Nonce); + payload.Remove("email"); + var token = OidcTestSupport.BuildIdToken(_rsa, Kid, payload); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_032", result.Error); + } + + [Fact] + public async Task ValidateAsync_MalformedToken_ReturnsFailure_AUTH023() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + + var result = await CreateSut(jwks).ValidateAsync("no-es-un-jwt", _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_023", result.Error); + } + + [Fact] + public async Task ValidateAsync_ExpiredButWithinClockSkew_ReturnsSuccess() + { + // exp hace 30s, con skew por defecto de 2 min → aún válido. + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var now = DateTimeOffset.UtcNow; + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload( + _endpoints.Issuer, Audience, Nonce, + exp: now.ToUnixTimeSeconds() - 30)); + + var clock = new FixedTimeProvider(now); + var result = await CreateSut(jwks, clock).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdpAuthAdapterTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdpAuthAdapterTests.cs new file mode 100644 index 00000000..59afcc6b --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdpAuthAdapterTests.cs @@ -0,0 +1,156 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Security.Cryptography; +using System.Text.Json; +using Xunit; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Unit tests de la orquestación del (Authorization +/// Code + PKCE): resolución de config → validación de state → intercambio de código → +/// validación del id_token. Todos los puertos (config/token/JWKS) son fakes; ningún +/// IdP real interviene (ADR-UMS-094 · slice 1). +/// +public sealed class OidcIdpAuthAdapterTests +{ + private const string Kid = "adapter-key"; + private const string ClientId = "ums-client"; + private const string Nonce = "nonce-abc"; + private const string State = "state-xyz"; + + private readonly RSA _rsa = RSA.Create(2048); + private readonly OidcEndpoints _endpoints = OidcTestSupport.Endpoints(); + + private OidcProviderConfig BuildConfig() => + new(_endpoints, new OidcClientSettings(ClientId, "secreto", "https://ums.test/callback")); + + private OidcIdTokenValidator BuildValidator() => + new(FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)), TimeProvider.System); + + private static IdentityProvider BuildProvider() + => IdentityProvider.Create( + TenantId.Load(Guid.NewGuid()), + Code.Create("KC"), + Name.Create("Keycloak"), + Description.Create("IdP corporativo"), + IdpStrategy.Keycloak, + ActorId.Create("test")).Value; + + private static string Callback(string state = State, string expectedState = State, string expectedNonce = Nonce) + => JsonSerializer.Serialize(new + { + code = "auth-code-123", + state, + expectedState, + expectedNonce, + codeVerifier = "verifier-123", + redirectUri = "https://ums.test/callback", + }); + + private string ValidIdToken() + => OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, ClientId, Nonce)); + + [Fact] + public async Task ValidateAsync_HappyPath_ReturnsExternalIdentity() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback()); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("user@ransa.pe", result.Value.Email); + } + + [Fact] + public async Task ValidateAsync_StateMismatch_ReturnsFailure_AUTH033() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback(state: "state-del-atacante")); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_033", result.Error); + } + + [Fact] + public async Task ValidateAsync_TokenExchangeFails_ReturnsFailure_AUTH021() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Failing(OidcAuthErrors.TokenExchangeFailed("HTTP 400.")), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback()); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_021", result.Error); + } + + [Fact] + public async Task ValidateAsync_MissingIdToken_ReturnsFailure_AUTH022() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(idToken: null), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback()); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_022", result.Error); + } + + [Fact] + public async Task ValidateAsync_ConfigResolutionFails_ReturnsFailure_AUTH020() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Failing(OidcAuthErrors.ConfigNotFound("sin config")), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback()); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_020", result.Error); + } + + [Fact] + public async Task ValidateAsync_MalformedCallback_ReturnsFailure_AUTH033() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), "esto-no-es-json"); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_033", result.Error); + } + + [Fact] + public async Task ValidateAsync_NonceMismatchInIdToken_ReturnsFailure_AUTH031() + { + // El callback declara un nonce distinto al que trae el id_token → rechazo. + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync( + BuildProvider(), + Callback(expectedNonce: "otro-nonce")); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_031", result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcProviderConfigParserTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcProviderConfigParserTests.cs new file mode 100644 index 00000000..9c8e43de --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcProviderConfigParserTests.cs @@ -0,0 +1,103 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using Xunit; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Unit tests del parseo de la configuración OIDC desde el ConfigPayload del +/// inquilino: los endpoints se leen de configuración, nunca se hardcodean (ADR-UMS-094). +/// +public sealed class OidcProviderConfigParserTests +{ + [Fact] + public void Parse_FullDiscoveryPayload_ReadsAllEndpoints() + { + const string payload = """ + { + "issuer": "https://idp.test/realms/beyondnet", + "authorization_endpoint": "https://idp.test/auth", + "token_endpoint": "https://idp.test/token", + "jwks_uri": "https://idp.test/certs", + "client_id": "ums-client", + "client_secret": "s3cr3t", + "redirect_uri": "https://ums.test/callback", + "scope": "openid email" + } + """; + + var result = OidcProviderConfigParser.Parse(payload); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("https://idp.test/auth", result.Value.Endpoints.AuthorizationEndpoint); + Assert.Equal("https://idp.test/token", result.Value.Endpoints.TokenEndpoint); + Assert.Equal("https://idp.test/certs", result.Value.Endpoints.JwksUri); + Assert.Equal("https://idp.test/realms/beyondnet", result.Value.Endpoints.Issuer); + Assert.Equal("ums-client", result.Value.Client.ClientId); + Assert.Equal("s3cr3t", result.Value.Client.ClientSecret); + Assert.Equal("openid email", result.Value.Client.Scopes); + } + + [Fact] + public void Parse_IssuerOnly_DerivesKeycloakEndpoints() + { + const string payload = """ + { + "issuer": "https://idp.test/realms/beyondnet", + "clientId": "ums-client", + "redirectUri": "https://ums.test/callback" + } + """; + + var result = OidcProviderConfigParser.Parse(payload); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("https://idp.test/realms/beyondnet/protocol/openid-connect/auth", result.Value.Endpoints.AuthorizationEndpoint); + Assert.Equal("https://idp.test/realms/beyondnet/protocol/openid-connect/token", result.Value.Endpoints.TokenEndpoint); + Assert.Equal("https://idp.test/realms/beyondnet/protocol/openid-connect/certs", result.Value.Endpoints.JwksUri); + Assert.Equal("openid email profile", result.Value.Client.Scopes); // por defecto + } + + [Fact] + public void Parse_ExplicitClientSecretArgument_OverridesPayload() + { + const string payload = """ + { "issuer": "https://idp.test/realms/beyondnet", "clientId": "ums-client", "redirectUri": "https://ums.test/cb" } + """; + + var result = OidcProviderConfigParser.Parse(payload, clientSecret: "desde-vault"); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("desde-vault", result.Value.Client.ClientSecret); + } + + [Fact] + public void Parse_MissingRequiredKeys_ReturnsFailure_AUTH020() + { + const string payload = """{ "issuer": "https://idp.test/realms/beyondnet" }"""; + + var result = OidcProviderConfigParser.Parse(payload); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_020", result.Error); + Assert.Contains("client_id", result.Error); + Assert.Contains("redirect_uri", result.Error); + } + + [Fact] + public void Parse_InvalidJson_ReturnsFailure_AUTH020() + { + var result = OidcProviderConfigParser.Parse("no-es-json"); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_020", result.Error); + } + + [Fact] + public void Parse_EmptyPayload_ReturnsFailure_AUTH020() + { + var result = OidcProviderConfigParser.Parse(" "); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_020", result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcTestSupport.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcTestSupport.cs new file mode 100644 index 00000000..9916df5a --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcTestSupport.cs @@ -0,0 +1,152 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Utilidades de prueba para el flujo OIDC: generación de llaves RSA de prueba, +/// construcción de id_token firmados y fakes de los puertos HTTP/JWKS. +/// Ningún test toca un IdP real (ADR-UMS-094 · slice 1). +/// +internal static class OidcTestSupport +{ + public static string Base64Url(ReadOnlySpan bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public static OidcEndpoints Endpoints(string issuer = "https://idp.test/realms/beyondnet") => + new( + AuthorizationEndpoint: $"{issuer}/protocol/openid-connect/auth", + TokenEndpoint: $"{issuer}/protocol/openid-connect/token", + JwksUri: $"{issuer}/protocol/openid-connect/certs", + Issuer: issuer); + + public static OidcJsonWebKey PublicJwk(RSA rsa, string kid) + { + var p = rsa.ExportParameters(includePrivateParameters: false); + return new OidcJsonWebKey( + Kid: kid, + Kty: "RSA", + Alg: "RS256", + Use: "sig", + N: Base64Url(p.Modulus!), + E: Base64Url(p.Exponent!)); + } + + /// Construye un id_token RS256 firmado con . + public static string BuildIdToken( + RSA signingKey, + string kid, + IReadOnlyDictionary payload, + string alg = "RS256") + { + var header = new Dictionary { ["alg"] = alg, ["typ"] = "JWT" }; + if (kid is not null) + { + header["kid"] = kid; + } + + var headerSegment = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(header))); + var payloadSegment = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload))); + var signingInput = Encoding.ASCII.GetBytes($"{headerSegment}.{payloadSegment}"); + + byte[] signature = alg switch + { + "none" => Array.Empty(), + "HS256" => new HMACSHA256(Encoding.UTF8.GetBytes("client-secret")).ComputeHash(signingInput), + _ => signingKey.SignData(signingInput, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1), + }; + + return $"{headerSegment}.{payloadSegment}.{Base64Url(signature)}"; + } + + public static Dictionary ValidPayload( + string issuer = "https://idp.test/realms/beyondnet", + string audience = "ums-client", + string nonce = "nonce-123", + string email = "user@ransa.pe", + long? exp = null, + long? nbf = null) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return new Dictionary + { + ["iss"] = issuer, + ["aud"] = audience, + ["exp"] = exp ?? now + 300, + ["nbf"] = nbf ?? now - 60, + ["iat"] = now, + ["nonce"] = nonce, + ["sub"] = "idp|abc-123", + ["email"] = email, + ["email_verified"] = true, + ["name"] = "Ana Ransa", + ["preferred_username"] = "aransa", + }; + } +} + +/// Fake de con llaves controladas. +internal sealed class FakeJwksProvider : IJwksProvider +{ + private readonly Result> _result; + + private FakeJwksProvider(Result> result) => _result = result; + + public static FakeJwksProvider WithKeys(params OidcJsonWebKey[] keys) => + new(Result>.Success(keys)); + + public static FakeJwksProvider Failing(string error) => + new(Result>.Failure(error)); + + public Task>> GetSigningKeysAsync( + OidcEndpoints endpoints, CancellationToken cancellationToken = default) + => Task.FromResult(_result); +} + +/// Fake de con respuesta controlada. +internal sealed class FakeOidcTokenClient : IOidcTokenClient +{ + private readonly Result _result; + + private FakeOidcTokenClient(Result result) => _result = result; + + public static FakeOidcTokenClient Returning(string? idToken) => + new(Result.Success(new OidcTokenResponse(idToken, "access", null, "Bearer", 300))); + + public static FakeOidcTokenClient Failing(string error) => + new(Result.Failure(error)); + + public Task> ExchangeAuthorizationCodeAsync( + OidcEndpoints endpoints, OidcClientSettings client, string code, string codeVerifier, + string redirectUri, CancellationToken cancellationToken = default) + => Task.FromResult(_result); +} + +/// Fake de . +internal sealed class FakeOidcProviderConfigStore : IOidcProviderConfigStore +{ + private readonly Result _result; + + private FakeOidcProviderConfigStore(Result result) => _result = result; + + public static FakeOidcProviderConfigStore Returning(OidcProviderConfig config) => + new(Result.Success(config)); + + public static FakeOidcProviderConfigStore Failing(string error) => + new(Result.Failure(error)); + + public Task> GetAsync( + Ums.Domain.Identity.Tenant.IdentityProvider.IdentityProvider provider, + CancellationToken cancellationToken = default) + => Task.FromResult(_result); +} + +/// fijo para probar exp/nbf de forma determinista. +internal sealed class FixedTimeProvider : TimeProvider +{ + private readonly DateTimeOffset _now; + public FixedTimeProvider(DateTimeOffset now) => _now = now; + public override DateTimeOffset GetUtcNow() => _now; +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshAuthenticationCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshAuthenticationCommandHandlerTests.cs new file mode 100644 index 00000000..d821f753 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshAuthenticationCommandHandlerTests.cs @@ -0,0 +1,378 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Moq; +using Xunit; +using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Application.Common.Interfaces; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Tests unitarios de (ADR-UMS-091 / FR-015/016). +/// Dan cobertura verificable (SD-04/SD-05) al residual «falta E2E dedicado» de G-050 y G-034: +/// +/// G-050 — auditoría y revocación: +/// · cada fallo de renovación (incl. detección de reuso) se audita; +/// · los códigos de fallo se distinguen (el reuso NO colapsa con la expiración); +/// +/// G-034 — refresh configurable + revocación: +/// · fail-closed cuando la política del inquilino está apagada; +/// · detección de reuso ⇒ invalidación de la familia; +/// · revocación efectiva ante inquilino/usuario inactivos (bloqueo/suspensión); +/// · regeneración COMPLETA del grafo al renovar; +/// · rotación del token (y su ausencia cuando la política no rota). +/// +/// Todas las dependencias están mockeadas — sin BD ni infraestructura. +/// +public sealed class RefreshAuthenticationCommandHandlerTests +{ + private readonly Mock _store = new(); + private readonly Mock _policyProvider = new(); + private readonly Mock _tenantRepo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _methodResolver = new(); + private readonly Mock _graphBuilder = new(); + private readonly Mock _formatProvider = new(); + private readonly Mock _serializer = new(); + private readonly Mock _auditService = new(); + + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + private static readonly Guid FamilyGuid = Guid.NewGuid(); + + private RefreshAuthenticationCommandHandler CreateSut() => new( + _store.Object, _policyProvider.Object, _tenantRepo.Object, _userRepo.Object, + _methodResolver.Object, _graphBuilder.Object, _formatProvider.Object, + _serializer.Object, _auditService.Object); + + private static RefreshAuthenticationCommand Command(string token = "opaque-refresh") + => new(token, ClientIp: "10.0.0.1"); + + // ── Validación de entrada ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_WhenRefreshTokenEmpty_ReturnsInvalid_WithoutTouchingStore() + { + var result = await CreateSut().Handle(Command(" "), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Invalid, result.Error); + _store.Verify(s => s.FindByHashAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenTokenUnknown_ReturnsInvalid_AndAuditsFailure() + { + _store.Setup(s => s.FindByHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RefreshTokenSnapshot?)null); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Invalid, result.Error); + VerifyFailureAudited(RefreshErrorCodes.Invalid); + } + + // ── G-050: detección de reuso auditada + código distinto (no colapsa a expiración) ── + + [Fact] + public async Task Handle_WhenRotatedTokenPresented_DetectsReuse_RevokesFamily_AndAudits() + { + SetupSnapshot(RefreshTokenStatuses.Rotated); + _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EnabledPolicy(detectReuse: true)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.ReuseDetected, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(FamilyGuid, "reuse_detected", It.IsAny(), It.IsAny()), Times.Once); + VerifyFailureAudited(RefreshErrorCodes.ReuseDetected); + } + + [Fact] + public async Task Handle_WhenReuseDetectionDisabled_DoesNotRevokeFamily_ButStillReportsReuse() + { + SetupSnapshot(RefreshTokenStatuses.Used); + _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EnabledPolicy(detectReuse: false)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.ReuseDetected, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + VerifyFailureAudited(RefreshErrorCodes.ReuseDetected); + } + + [Fact] + public async Task Handle_WhenRevoked_ReturnsRevoked_DistinctFromReuse_AndAudits() + { + SetupSnapshot(RefreshTokenStatuses.Revoked); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Revoked, result.Error); + Assert.DoesNotContain(RefreshErrorCodes.ReuseDetected, result.Error); + VerifyFailureAudited(RefreshErrorCodes.Revoked); + } + + [Fact] + public async Task Handle_WhenExpired_ReturnsExpired_DistinctFromReuse_AndAudits() + { + // Un token Active pero vencido ⇒ AUTH_REFRESH_004, nunca el código de reuso: + // el bug histórico (G-050) era que expiración y reuso colapsaban a AUTH_007. + SetupSnapshot(RefreshTokenStatuses.Active, expiresAtUtc: DateTime.UtcNow.AddMinutes(-1)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Expired, result.Error); + Assert.DoesNotContain(RefreshErrorCodes.ReuseDetected, result.Error); + VerifyFailureAudited(RefreshErrorCodes.Expired); + } + + // ── G-034: fail-closed cuando el inquilino no tiene la capacidad activa ─────── + + [Fact] + public async Task Handle_WhenPolicyDisabled_ReturnsDisabled_FailClosed() + { + SetupSnapshot(RefreshTokenStatuses.Active); + _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(RefreshTokenPolicy.Disabled); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Disabled, result.Error); + VerifyFailureAudited(RefreshErrorCodes.Disabled); + // Fail-closed: no se regenera grafo ni se rota nada. + _graphBuilder.Verify(g => g.BuildAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenMaxRenewalsReached_RevokesFamily_AndReturnsMaxRenewals() + { + SetupSnapshot(RefreshTokenStatuses.Active, renewalCount: 5); + _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EnabledPolicy(maxRenewals: 5)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.MaxRenewals, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(FamilyGuid, "max_renewals", It.IsAny(), It.IsAny()), Times.Once); + VerifyFailureAudited(RefreshErrorCodes.MaxRenewals); + } + + // ── G-034: revocación efectiva ante principal inactivo (bloqueo/suspensión) ─── + + [Fact] + public async Task Handle_WhenTenantInactive_RevokesFamily_ReturnsPrincipalGone() + { + SetupSnapshot(RefreshTokenStatuses.Active); + SetupEnabledPolicy(); + _tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync((TenantAggregate?)null); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.PrincipalGone, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(FamilyGuid, "tenant_inactive", It.IsAny(), It.IsAny()), Times.Once); + VerifyFailureAudited(RefreshErrorCodes.PrincipalGone); + } + + [Fact] + public async Task Handle_WhenUserBlocked_RevokesFamily_ReturnsPrincipalGone() + { + // Un usuario bloqueado no puede renovar ⇒ la familia se revoca (revocación efectiva + // por bloqueo/suspensión, G-034/D-012). + SetupSnapshot(RefreshTokenStatuses.Active); + SetupEnabledPolicy(); + SetupActiveTenant(); + _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildBlockedUser()); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.PrincipalGone, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(FamilyGuid, "user_inactive", It.IsAny(), It.IsAny()), Times.Once); + VerifyFailureAudited(RefreshErrorCodes.PrincipalGone); + } + + // ── G-034: happy-path — regeneración COMPLETA del grafo + rotación + auditoría ─ + + [Fact] + public async Task Handle_HappyPath_RegeneratesGraph_RotatesToken_AndAuditsSuccess() + { + SetupSnapshot(RefreshTokenStatuses.Active); + SetupEnabledPolicy(rotate: true); + SetupActiveTenant(); + SetupActiveUser(); + SetupGraphPipeline(); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsSuccess); + // Regeneración COMPLETA del grafo (decisión 1 de ADR-UMS-091): el builder se invoca. + _graphBuilder.Verify(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + // Rotación: el token vigente se canjea por uno nuevo de la misma familia. + _store.Verify(s => s.RotateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + Assert.False(string.IsNullOrWhiteSpace(result.Value.NewRefreshToken)); + // Auditoría de éxito persistida (G-050). + _auditService.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Refresh.Success" && e.Succeeded), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_HappyPath_WhenPolicyDoesNotRotate_KeepsClientToken() + { + SetupSnapshot(RefreshTokenStatuses.Active); + SetupEnabledPolicy(rotate: false); + SetupActiveTenant(); + SetupActiveUser(); + SetupGraphPipeline(); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsSuccess); + _store.Verify(s => s.RotateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Null(result.Value.NewRefreshToken); + // Aun sin rotación, el grafo se regenera por completo. + _graphBuilder.Verify(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ── Setup helpers ──────────────────────────────────────────────────────────── + + private void SetupSnapshot( + string status, + DateTime? expiresAtUtc = null, + int renewalCount = 0) + { + var snapshot = new RefreshTokenSnapshot( + Id: Guid.NewGuid(), + TenantId: TenantGuid, + UserId: UserGuid, + FamilyId: FamilyGuid, + Status: status, + IssuedAtUtc: DateTime.UtcNow.AddMinutes(-10), + ExpiresAtUtc: expiresAtUtc ?? DateTime.UtcNow.AddDays(1), + RenewalCount: renewalCount); + + _store.Setup(s => s.FindByHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(snapshot); + } + + private static RefreshTokenPolicy EnabledPolicy( + bool rotate = true, bool detectReuse = true, int maxRenewals = 0) + => new(Enabled: true, LifetimeMinutes: 60, Rotate: rotate, DetectReuse: detectReuse, MaxRenewals: maxRenewals); + + private void SetupEnabledPolicy(bool rotate = true) + => _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EnabledPolicy(rotate: rotate)); + + private void SetupActiveTenant() + => _tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync(BuildActiveTenant()); + + private void SetupActiveUser() + => _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildActiveUser()); + + private void SetupGraphPipeline() + { + _methodResolver.Setup(m => m.ResolveAsync(TenantGuid, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(AuthMethod.Local())); + _graphBuilder.Setup(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(BuildGraph())); + _formatProvider.Setup(f => f.GetDefaultFormatAsync(TenantGuid, It.IsAny())) + .ReturnsAsync("JSON"); + _serializer.Setup(s => s.Serialize(It.IsAny(), It.IsAny())) + .Returns("{}"); + } + + private static TenantAggregate BuildActiveTenant() + { + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + Ums.Domain.Enums.OrganizationType.INTERNAL, + ActorId.Create("test"), + Ums.Domain.Enums.IdpStrategy.InternalBcrypt, + tenantId: TenantId.Load(TenantGuid)).Value; // Create ⇒ Status Active + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static UserAccountAggregate BuildActiveUser() + { + var user = BuildUser(); + user.Activate(ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static UserAccountAggregate BuildBlockedUser() + { + var user = BuildUser(); + user.Activate(ActorId.Create("test")); + user.Block(Reason.Create("suspendido"), ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static UserAccountAggregate BuildUser() + => UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create("user@test.com"), + Ums.Domain.Enums.UserCategory.Internal, + null, null, + ActorId.Create("test"), + null, + UserAccountId.Load(UserGuid)).Value; + + private static AuthorizationGraph BuildGraph() + { + var context = new GraphContext( + new GraphUser(UserGuid, "user@test.com", "user", "User", "Active"), + new GraphTenant(TenantGuid, "TEST", "Test Tenant", "Active", false), + SystemSuite: null, Role: null, Profile: null, Branch: null); + + var authentication = new GraphAuthentication( + "Local", Provider: null, MfaRequired: false, + IssuedAt: DateTime.UtcNow, SessionExpiresAt: DateTime.UtcNow.AddMinutes(30)); + + var effectiveConfig = new GraphEffectiveConfig( + SessionTimeoutMinutes: 30, MaxLoginAttempts: 5, MinPasswordLength: 8, + MfaRequiredForAdmin: false, MfaAllowedMethods: Array.Empty(), + AccessTokenDurationMs: 900_000, AuthUseExternalIdp: false); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + DateTime.UtcNow); + } + + private void VerifyFailureAudited(string code) + => _auditService.Verify(a => a.RecordAuthEventAsync( + It.Is(e => + e.EventType == "Auth.Refresh.Failure" && + !e.Succeeded && + e.FailureReason != null && e.FailureReason.Contains(code)), + It.IsAny()), Times.Once); +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshSessionCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshSessionCommandHandlerTests.cs new file mode 100644 index 00000000..ab239299 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshSessionCommandHandlerTests.cs @@ -0,0 +1,211 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Moq; +using Xunit; +using Ums.Application.Authorization.Graph; +using Ums.Application.Common.Interfaces; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Tests unitarios de (D-019 / ADR-UMS-091). +/// +/// El refresh deslizante por cookie debe ESPEJAR el login: regenera el grafo COMPLETO desde el +/// estado vigente (no re-firma claims estancados). Se verifica: +/// · happy-path ⇒ el grafo se regenera (IAuthorizationGraphBuilder) y el evento se audita; +/// · el resultado surfacea el grafo VIGENTE del builder (un cambio de permisos desde el login +/// se refleja, porque el grafo se reconstruye, no se cachea); +/// · corte en caliente ⇒ inquilino/usuario inactivo no renueva (y no se reconstruye el grafo). +/// +/// Todas las dependencias están mockeadas — sin BD ni infraestructura. +/// +public sealed class RefreshSessionCommandHandlerTests +{ + private readonly Mock _tenantRepo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _methodResolver = new(); + private readonly Mock _graphBuilder = new(); + private readonly Mock _formatProvider = new(); + private readonly Mock _auditService = new(); + + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + + private RefreshSessionCommandHandler CreateSut() => new( + _tenantRepo.Object, _userRepo.Object, _methodResolver.Object, + _graphBuilder.Object, _formatProvider.Object, _auditService.Object); + + private static RefreshSessionCommand Command() + => new(UserGuid, TenantGuid, ClientIp: "10.0.0.1"); + + // ── Happy-path: regeneración COMPLETA del grafo + auditoría ─────────────────── + + [Fact] + public async Task Handle_HappyPath_RegeneratesGraph_AndAuditsSuccess() + { + SetupActiveTenant(); + SetupActiveUser(); + SetupGraphPipeline(BuildGraph(accessTokenDurationMs: 900_000)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsSuccess); + // Regeneración COMPLETA (decisión 1 de ADR-UMS-091): el builder se invoca. + _graphBuilder.Verify(g => g.BuildAsync( + It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + // ExpiresIn se deriva del grafo vigente, no de un valor hardcodeado. + Assert.Equal(900, result.Value.ExpiresIn); + _auditService.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Refresh.Success" && e.Succeeded), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_SurfacesCurrentGraph_NotALoginSnapshot() + { + // El handler NO tiene acceso a los permisos/claims del login; su única fuente es el + // builder. Un cambio de permisos aplicado tras el login se refleja porque el grafo se + // reconstruye desde cero: el resultado ES exactamente el grafo recién construido. + SetupActiveTenant(); + SetupActiveUser(); + var freshGraph = BuildGraph(accessTokenDurationMs: 1_800_000); + SetupGraphPipeline(freshGraph); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Same(freshGraph, result.Value.Graph); + Assert.Equal(1800, result.Value.ExpiresIn); + } + + // ── Corte en caliente: principal inactivo no renueva ───────────────────────── + + [Fact] + public async Task Handle_WhenTenantInactiveOrMissing_Fails_WithoutRebuildingGraph() + { + _tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync((TenantAggregate?)null); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + _graphBuilder.Verify(g => g.BuildAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + VerifyFailureAudited(); + } + + [Fact] + public async Task Handle_WhenUserBlocked_Fails_WithoutRebuildingGraph() + { + SetupActiveTenant(); + _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildBlockedUser()); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + _graphBuilder.Verify(g => g.BuildAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + VerifyFailureAudited(); + } + + // ── Setup helpers ──────────────────────────────────────────────────────────── + + private void SetupActiveTenant() + => _tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync(BuildActiveTenant()); + + private void SetupActiveUser() + => _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildActiveUser()); + + private void SetupGraphPipeline(AuthorizationGraph graph) + { + _methodResolver.Setup(m => m.ResolveAsync(TenantGuid, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(AuthMethod.Local())); + _graphBuilder.Setup(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(graph)); + _formatProvider.Setup(f => f.GetDefaultFormatAsync(TenantGuid, It.IsAny())) + .ReturnsAsync("JSON"); + } + + private static TenantAggregate BuildActiveTenant() + { + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + Ums.Domain.Enums.OrganizationType.INTERNAL, + ActorId.Create("test"), + Ums.Domain.Enums.IdpStrategy.InternalBcrypt, + tenantId: TenantId.Load(TenantGuid)).Value; // Create ⇒ Status Active + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static UserAccountAggregate BuildActiveUser() + { + var user = BuildUser(); + user.Activate(ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static UserAccountAggregate BuildBlockedUser() + { + var user = BuildUser(); + user.Activate(ActorId.Create("test")); + user.Block(Reason.Create("suspendido"), ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static UserAccountAggregate BuildUser() + => UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create("user@test.com"), + Ums.Domain.Enums.UserCategory.Internal, + null, null, + ActorId.Create("test"), + null, + UserAccountId.Load(UserGuid)).Value; + + private static AuthorizationGraph BuildGraph(int accessTokenDurationMs) + { + var context = new GraphContext( + new GraphUser(UserGuid, "user@test.com", "user", "User", "Active"), + new GraphTenant(TenantGuid, "TEST", "Test Tenant", "Active", false), + SystemSuite: null, Role: null, Profile: null, Branch: null); + + var authentication = new GraphAuthentication( + "Local", Provider: null, MfaRequired: false, + IssuedAt: DateTime.UtcNow, SessionExpiresAt: DateTime.UtcNow.AddMinutes(30)); + + var effectiveConfig = new GraphEffectiveConfig( + SessionTimeoutMinutes: 30, MaxLoginAttempts: 5, MinPasswordLength: 8, + MfaRequiredForAdmin: false, MfaAllowedMethods: Array.Empty(), + AccessTokenDurationMs: accessTokenDurationMs, AuthUseExternalIdp: false); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + DateTime.UtcNow); + } + + private void VerifyFailureAudited() + => _auditService.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Refresh.Failure" && !e.Succeeded), + It.IsAny()), Times.AtLeastOnce); +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ResetPasswordCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ResetPasswordCommandHandlerTests.cs new file mode 100644 index 00000000..4e447448 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ResetPasswordCommandHandlerTests.cs @@ -0,0 +1,234 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Ums.Application.Common.Interfaces; +using Ums.Application.Common.Notifications; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Enums; +using Ums.Domain.Identity; +using Ums.Domain.Identity.UserAccount; +using Ums.Domain.Kernel; +using Moq; +using Xunit; + +/// +/// G-188: el canje es el ÚNICO punto donde cambia la contraseña, y solo con un token vivo. +/// Todo lo demás —token inexistente, vencido, ya gastado, cuenta inhabilitada— colapsa en el +/// mismo error, sin tocar la credencial. +/// +public class ResetPasswordCommandHandlerTests +{ + private readonly Mock _resetTokens = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _hasher = new(); + private readonly Mock _refreshTokens = new(); + private readonly Mock _notifications = new(); + private readonly Mock _uow = new(); + + private static readonly Guid TenantIdValue = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid TokenId = Guid.Parse("11111111-1111-4111-8111-111111111111"); + private const string UserEmail = "admin@acme.com"; + private const string PreviousHash = "hash-de-la-clave-vigente"; + private const string NewHash = "hash-de-la-clave-nueva"; + private const string PlainToken = "token-en-claro"; + private const string NewPassword = "Nueva#Clave#2026"; + + public ResetPasswordCommandHandlerTests() + { + _userRepo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + _hasher.Setup(h => h.Hash(It.IsAny())).Returns(NewHash); + } + + private static UserAccount MakeUserWithPassword(Guid tenantId = default) + { + var user = UserAccount.Create( + TenantId.Load(tenantId == default ? TenantIdValue : tenantId), + Email.Create(UserEmail), + UserCategory.Internal, + null, null, + ActorId.Create("sys")).Value; + user.Activate(ActorId.Create("sys")); + user.AddPassword(PasswordHash.Create(PreviousHash), ActorId.Create("sys")); + return user; + } + + private static PasswordResetTokenSnapshot MakeSnapshot( + Guid userId, + string status = PasswordResetTokenStatuses.Active, + int expiresInMinutes = 10) => + new(TokenId, TenantIdValue, userId, status, + DateTime.UtcNow.AddMinutes(-1), + DateTime.UtcNow.AddMinutes(expiresInMinutes)); + + private void GivenToken(PasswordResetTokenSnapshot? snapshot) => + _resetTokens.Setup(s => s.FindByHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(snapshot); + + private void GivenAccount(UserAccount? account) => + _userRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(account); + + private ResetPasswordCommandHandler CreateHandler() => + new(_resetTokens.Object, _userRepo.Object, _hasher.Object, _refreshTokens.Object, _notifications.Object); + + private Task> Invoke() => + CreateHandler().Handle(new ResetPasswordCommand(PlainToken, NewPassword), CancellationToken.None); + + // ========================================================================= + #region Canje válido + // ========================================================================= + + [Fact] + public async Task ResetPassword_WithLiveToken_ReplacesTheActiveCredential() + { + var user = MakeUserWithPassword(); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + var result = await Invoke(); + + Assert.True(result.IsSuccess); + var activa = Assert.Single(user.PasswordCredentials.Where(c => c.IsActive)); + Assert.Equal(NewHash, activa.PasswordHash.GetValue()); + // La anterior no desaparece: queda desactivada, que es como el agregado lleva su historia. + Assert.Contains(user.PasswordCredentials, c => !c.IsActive && c.PasswordHash.GetValue() == PreviousHash); + } + + [Fact] + public async Task ResetPassword_WithLiveToken_PersistsAndBurnsTheToken() + { + var user = MakeUserWithPassword(); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + await Invoke(); + + _userRepo.Verify(r => r.UpdateAsync(user, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + _resetTokens.Verify(s => s.ConsumeAsync(TokenId, It.IsAny(), It.IsAny()), Times.Once); + _resetTokens.Verify(s => s.InvalidateActiveForUserAsync( + TenantIdValue, user.Props.Id.GetValue(), It.IsAny(), + It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ResetPassword_WithLiveToken_ClosesLiveSessionsAndNotifiesTheOwner() + { + var user = MakeUserWithPassword(); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + await Invoke(); + + _refreshTokens.Verify(s => s.RevokeAllForUserAsync( + TenantIdValue, user.Props.Id.GetValue(), It.IsAny(), + It.IsAny(), It.IsAny()), + Times.Once); + _notifications.Verify(n => n.SendAsync( + It.Is(msg => msg.Recipient == UserEmail), + It.IsAny()), + Times.Once); + } + + #endregion + + // ========================================================================= + #region Canje rechazado — la credencial vigente sobrevive + // ========================================================================= + + [Theory] + [InlineData("inexistente")] + [InlineData("vencido")] + [InlineData("gastado")] + [InlineData("invalidado")] + public async Task ResetPassword_WithUnusableToken_FailsWithTheSameErrorAndChangesNothing(string caso) + { + var user = MakeUserWithPassword(); + var userId = user.Props.Id.GetValue(); + GivenAccount(user); + GivenToken(caso switch + { + "inexistente" => null, + "vencido" => MakeSnapshot(userId, expiresInMinutes: -1), + "gastado" => MakeSnapshot(userId, PasswordResetTokenStatuses.Used), + _ => MakeSnapshot(userId, PasswordResetTokenStatuses.Invalidated), + }); + + var result = await Invoke(); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + VerifyCredentialSurvived(user); + } + + [Fact] + public async Task ResetPassword_WithEmptyToken_FailsWithoutTouchingTheStore() + { + var result = await CreateHandler().Handle( + new ResetPasswordCommand(" ", NewPassword), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + _resetTokens.Verify(s => s.FindByHashAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ResetPassword_WhenTokenPointsToAnotherTenantAccount_FailsAndBurnsTheToken() + { + var user = MakeUserWithPassword(Guid.Parse("9c1c2b3a-0000-4000-8000-000000000099")); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + var result = await Invoke(); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + VerifyCredentialSurvived(user); + // El token queda gastado: si la cuenta ya no es la que era, ese secreto no debe seguir vivo. + _resetTokens.Verify(s => s.ConsumeAsync(TokenId, It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ResetPassword_WhenAccountWasBlockedAfterIssuing_Fails() + { + var user = MakeUserWithPassword(); + user.Block(Reason.Create("prueba"), ActorId.Create("sys")); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + var result = await Invoke(); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + VerifyCredentialSurvived(user); + } + + [Fact] + public async Task ResetPassword_WhenAccountVanished_Fails() + { + GivenToken(MakeSnapshot(Guid.NewGuid())); + GivenAccount(null); + + var result = await Invoke(); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + _userRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private void VerifyCredentialSurvived(UserAccount user) + { + var activa = Assert.Single(user.PasswordCredentials.Where(c => c.IsActive)); + Assert.Equal(PreviousHash, activa.PasswordHash.GetValue()); + _userRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + _refreshTokens.Verify(s => s.RevokeAllForUserAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), + Times.Never); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SignupUserCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SignupUserCommandHandlerTests.cs index 58553f0d..7e6d81b3 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SignupUserCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SignupUserCommandHandlerTests.cs @@ -28,13 +28,13 @@ public SignupUserCommandHandlerTests() _hasher.Setup(h => h.Hash(It.IsAny())).Returns("hashed-password"); } - private Domain.Identity.Tenant.Tenant MakeTenant() => + private static Domain.Identity.Tenant.Tenant MakeTenant() => Domain.Identity.Tenant.Tenant.Create( Code.Create(TenantCode), Name.Create("Acme Corp"), OrganizationType.INTERNAL, ActorId.Create("sys"), tenantId: Domain.Kernel.ValueObjects.TenantId.Load(TenantId)).Value; - private UserAccount MakeActiveInternalAdmin() + private static UserAccount MakeActiveInternalAdmin() { var user = UserAccount.Create( Domain.Kernel.ValueObjects.TenantId.Load(TenantId), diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SwitchProfileCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SwitchProfileCommandHandlerTests.cs new file mode 100644 index 00000000..b90d2d52 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SwitchProfileCommandHandlerTests.cs @@ -0,0 +1,137 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Moq; +using Xunit; +using FluentAssertions; +using Ums.Application.Common.Interfaces; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Kernel; +using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Reglas del cambio de perfil. Las dos primeras son el motivo por el que este endpoint no es +/// trivial: el identificador del perfil lo envía el cliente, así que si no se comprueba que +/// pertenece al usuario del token y a su inquilino, cambiar de perfil se convierte en una escalada +/// de privilegios de una línea. +/// +public class SwitchProfileCommandHandlerTests +{ + private readonly Mock _userRepo = new(); + private readonly Mock _profileRepo = new(); + private readonly Mock _graphBuilder = new(); + private readonly Mock _audit = new(); + + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + private static readonly Guid ProfileGuid = Guid.NewGuid(); + + private SwitchProfileCommandHandler CreateSut() + => new(_userRepo.Object, _profileRepo.Object, _graphBuilder.Object, _audit.Object); + + private static SwitchProfileCommand Command() + => new(ProfileGuid, UserGuid, TenantGuid, "10.0.0.1"); + + private static UserAccountAggregate UsuarioActivo() + { + var user = UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create("ana@beyondnet.com.pe"), + UserCategory.Internal, + null, null, + ActorId.Create("system"), + null, + UserAccountId.Load(UserGuid)).Value; + + user.Activate(ActorId.Create("system")); + return user; + } + + private static ProfileAggregate Perfil(Guid userId, Guid tenantId, bool activo = true) + { + var profile = ProfileAggregate.Create( + TenantId.Load(tenantId), + UserId.Load(userId), + RoleId.Load(Guid.NewGuid()), + null, + ActorId.Create("system")).Value; + + if (!activo) profile.Deactivate(ActorId.Create("system")); + return profile; + } + + private void ConUsuarioYPerfil(ProfileAggregate perfil) + { + _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(UsuarioActivo()); + _profileRepo.Setup(r => r.GetByIdAsync(ProfileGuid, It.IsAny())) + .ReturnsAsync(perfil); + } + + [Fact] + public async Task Un_perfil_de_otro_usuario_no_se_puede_asumir() + { + ConUsuarioYPerfil(Perfil(userId: Guid.NewGuid(), tenantId: TenantGuid)); + + var resultado = await CreateSut().Handle(Command(), CancellationToken.None); + + resultado.IsFailure.Should().BeTrue(); + // Mismo mensaje que «no existe»: distinguirlos permitiría enumerar perfiles ajenos. + resultado.Error.Should().Contain("AUTH_020"); + _graphBuilder.Verify(b => b.BuildForProfileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Un_perfil_de_otro_inquilino_no_se_puede_asumir() + { + ConUsuarioYPerfil(Perfil(userId: UserGuid, tenantId: Guid.NewGuid())); + + var resultado = await CreateSut().Handle(Command(), CancellationToken.None); + + resultado.IsFailure.Should().BeTrue(); + resultado.Error.Should().Contain("AUTH_020"); + } + + [Fact] + public async Task El_intento_denegado_queda_auditado() + { + ConUsuarioYPerfil(Perfil(userId: Guid.NewGuid(), tenantId: TenantGuid)); + + await CreateSut().Handle(Command(), CancellationToken.None); + + _audit.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Profile.SwitchDenied" && !e.Succeeded), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Un_perfil_inactivo_no_se_puede_asumir() + { + ConUsuarioYPerfil(Perfil(UserGuid, TenantGuid, activo: false)); + + var resultado = await CreateSut().Handle(Command(), CancellationToken.None); + + resultado.IsFailure.Should().BeTrue(); + resultado.Error.Should().Contain("AUTH_021"); + } + + [Fact] + public async Task Un_perfil_inexistente_devuelve_no_encontrado() + { + _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(UsuarioActivo()); + _profileRepo.Setup(r => r.GetByIdAsync(ProfileGuid, It.IsAny())) + .ReturnsAsync((ProfileAggregate?)null); + + var resultado = await CreateSut().Handle(Command(), CancellationToken.None); + + resultado.IsFailure.Should().BeTrue(); + resultado.Error.Should().Contain("AUTH_020"); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Tenant/Commands/SetManagementOwnerCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Tenant/Commands/SetManagementOwnerCommandHandlerTests.cs index 17dc81d0..b46aea68 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/Tenant/Commands/SetManagementOwnerCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Tenant/Commands/SetManagementOwnerCommandHandlerTests.cs @@ -18,6 +18,10 @@ public SetManagementOwnerCommandHandlerTests() _unitOfWorkMock = new Mock(); _tenantRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object); _unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + // G-045: el handler ahora verifica la unicidad del management owner leyendo todos los + // tenants antes de persistir. Por defecto no existe ningún otro owner. + _tenantRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); _userContextMock = new Mock(); _handler = new SetManagementOwnerCommandHandler(_tenantRepositoryMock.Object, _userContextMock.Object); @@ -91,6 +95,30 @@ public async Task Handle_WhenTenantNotFound_ReturnsFailure() _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task Handle_WhenAnotherManagementOwnerExists_ReturnsFailure() + { + // G-045: otorgar la propiedad de gestión a un segundo tenant viola el índice parcial + // único IX_Tenants_SingleManagementOwner. El handler lo detecta antes de persistir y + // devuelve un fallo (que presentación mapea a 409) en vez de un 500 por violación de índice. + var tenantId = Guid.NewGuid(); + _userContextMock.Setup(u => u.UserId).Returns("user-001"); + var tenant = CreateTenant(isManagementOwner: false); + _tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny())) + .ReturnsAsync(tenant); + var existingOwner = CreateTenant(isManagementOwner: true); + _tenantRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { existingOwner }); + + var result = await _handler.Handle(new SetManagementOwnerCommand(tenantId, true), CancellationToken.None); + + Assert.True(result.IsFailure); + // G-037/G-045: código estable de dominio (presentación lo mapea a 409). + Assert.Equal(DomainErrors.Tenant.ManagementOwnerAlreadyExists, result.Error); + _tenantRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + private static Tenant CreateTenant(bool isManagementOwner) { return Tenant.Create( diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountPasswordMfaCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountPasswordMfaCommandHandlerTests.cs index 775703cb..936ddb73 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountPasswordMfaCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountPasswordMfaCommandHandlerTests.cs @@ -64,7 +64,7 @@ public async Task ActivatePassword_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new ActivatePasswordCommand(user.GetId().GetValue(), credential.Id.GetValue()); + var cmd = new ActivatePasswordCommand(user.GetId().GetValue(), credential.GetId().GetValue()); var handler = new ActivatePasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -124,7 +124,7 @@ public async Task ActivateUserAccountPassword_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new ActivateUserAccountPasswordCommand(user.GetId().GetValue(), credential.Id.GetValue()); + var cmd = new ActivateUserAccountPasswordCommand(user.GetId().GetValue(), credential.GetId().GetValue()); var handler = new ActivateUserAccountPasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -245,7 +245,7 @@ public async Task RemovePassword_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RemovePasswordCommand(user.GetId().GetValue(), credential1.Id.GetValue()); + var cmd = new RemovePasswordCommand(user.GetId().GetValue(), credential1.GetId().GetValue()); var handler = new RemovePasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -305,7 +305,7 @@ public async Task RemovePassword_WhenLastPassword_ReturnsFailure() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RemovePasswordCommand(user.GetId().GetValue(), credential.Id.GetValue()); + var cmd = new RemovePasswordCommand(user.GetId().GetValue(), credential.GetId().GetValue()); var handler = new RemovePasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -323,7 +323,7 @@ public async Task RemoveUserAccountPassword_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RemoveUserAccountPasswordCommand(user.GetId().GetValue(), credential1.Id.GetValue()); + var cmd = new RemoveUserAccountPasswordCommand(user.GetId().GetValue(), credential1.GetId().GetValue()); var handler = new RemoveUserAccountPasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -383,7 +383,7 @@ public async Task RemoveUserAccountPassword_WhenLastPassword_ReturnsFailure() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RemoveUserAccountPasswordCommand(user.GetId().GetValue(), credential.Id.GetValue()); + var cmd = new RemoveUserAccountPasswordCommand(user.GetId().GetValue(), credential.GetId().GetValue()); var handler = new RemoveUserAccountPasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -509,7 +509,7 @@ public async Task VerifyMfa_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new VerifyUserAccountMfaCommand(user.GetId().GetValue(), enrollment.Id.GetValue()); + var cmd = new VerifyUserAccountMfaCommand(user.GetId().GetValue(), enrollment.GetId().GetValue()); var handler = new VerifyUserAccountMfaCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -571,7 +571,7 @@ public async Task RecordAuthenticationAttempt_WithValidCommand_ReturnsSuccess() true, "Password valid", "192.168.1.1"); - var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object); + var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object, _configurationProvider.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess); @@ -586,7 +586,7 @@ public async Task RecordAuthenticationAttempt_WhenNotFound_ReturnsFailure() .ReturnsAsync((UserAccount?)null); var cmd = new RecordAuthenticationAttemptCommand(Guid.NewGuid(), true, "Reason", "127.0.0.1"); - var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object); + var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object, _configurationProvider.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -599,7 +599,7 @@ public async Task RecordAuthenticationAttempt_WhenUnauthenticated_ReturnsFailure _ctx.Setup(u => u.UserId).Returns(""); var cmd = new RecordAuthenticationAttemptCommand(Guid.NewGuid(), true, "Reason", "127.0.0.1"); - var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object); + var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object, _configurationProvider.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -618,7 +618,7 @@ public async Task RecordAuthenticationAttempt_WithFailedAttempt_ReturnsSuccess() false, "Invalid password", "10.0.0.1"); - var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object); + var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object, _configurationProvider.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess); @@ -634,7 +634,7 @@ public async Task RevokeEnrollment_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RevokeUserAccountMfaCommand(user.GetId().GetValue(), enrollment.Id.GetValue()); + var cmd = new RevokeUserAccountMfaCommand(user.GetId().GetValue(), enrollment.GetId().GetValue()); var handler = new RevokeUserAccountMfaCommandHandler(_repo.Object, _ctx.Object, _tenantScopePolicy.Object, _delegationAccess.Object); var result = await handler.Handle(cmd, CancellationToken.None); diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationCommandHandlerTests.cs index 648f5f34..7220adb3 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationCommandHandlerTests.cs @@ -19,6 +19,7 @@ public class DelegationCommandHandlerTests private readonly Mock _userRepo = new(); private readonly Mock _uow = new(); private readonly Mock _ctx = new(); + private readonly Mock _scope = new(); private readonly Guid _currentUserId = Guid.NewGuid(); private readonly Guid _tenantId = Guid.NewGuid(); private readonly Guid _delegatingAdminId; @@ -30,6 +31,9 @@ public DelegationCommandHandlerTests() _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); _ctx.Setup(u => u.UserId).Returns(_currentUserId.ToString()); + // Por defecto el operador es management-owner del inquilino objetivo (gate satisfecho). + _scope.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); } private static UserManagementDelegation MakeDelegation() @@ -48,6 +52,25 @@ private static UserManagementDelegation MakeDelegation() ActorId.Create("user-001")).Value; } + // Delegación con delegatingAdmin conocido, en PendingApproval — para probar la SoD (auto-aprobación). + private static UserManagementDelegation MakePendingApprovalDelegationWith(Guid delegatingAdminId) + { + var delegation = UserManagementDelegation.Create( + TenantId.Load(Guid.NewGuid()), + UserAccountId.Load(delegatingAdminId), + UserAccountId.Load(Guid.NewGuid()), + DelegationScopeType.Tenant, + null, + new List { DelegatedAction.CreateUser }, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddDays(5), + 10, + true, + ActorId.Create(delegatingAdminId.ToString())).Value; + delegation.SubmitForApproval(Guid.NewGuid(), ActorId.Create(delegatingAdminId.ToString())); + return delegation; + } + private UserAccount MakeActiveUser(Guid userId) { var user = UserAccount.Create( @@ -295,4 +318,274 @@ public async Task Expire_WhenNotFound_ReturnsFailure() } #endregion + + // ========================================================================= + #region SubmitDelegationForApprovalCommandHandler (G-132) + // ========================================================================= + + private static UserManagementDelegation MakePendingApprovalDelegation() + { + var delegation = MakeDelegation(); // Born Draft + delegation.SubmitForApproval(Guid.NewGuid(), ActorId.Create("user-001")); + return delegation; + } + + [Fact] + public async Task Submit_WithDraftDelegation_ReturnsSuccess() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakeDelegation(); // Born Draft + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new SubmitDelegationForApprovalCommand(Guid.NewGuid()); + var handler = new SubmitDelegationForApprovalCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + Assert.NotNull(delegation.ApprovalRequestId); + Assert.NotEqual(Guid.Empty, delegation.ApprovalRequestId!.Value); + _repo.Verify(r => r.UpdateAsync(delegation, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Submit_WhenNotDraft_ReturnsFailure() + { + // Camino de RECHAZO: enviar a aprobación fuera de estado Draft (ya en PendingApproval) → falla. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new SubmitDelegationForApprovalCommand(Guid.NewGuid()); + var handler = new SubmitDelegationForApprovalCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Submit_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((UserManagementDelegation?)null); + + var cmd = new SubmitDelegationForApprovalCommand(Guid.NewGuid()); + var handler = new SubmitDelegationForApprovalCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Submit_WhenUnauthenticated_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns(""); + + var cmd = new SubmitDelegationForApprovalCommand(Guid.NewGuid()); + var handler = new SubmitDelegationForApprovalCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("authenticated user is required", result.Error, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + // ========================================================================= + #region ApproveDelegationCommandHandler (G-132) + // ========================================================================= + + [Fact] + public async Task Approve_WithPendingApproval_ReturnsSuccess() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(DelegationStatus.Active, delegation.Status); + _repo.Verify(r => r.UpdateAsync(delegation, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Approve_WhenNotPendingApproval_ReturnsFailure() + { + // Camino de RECHAZO: aprobar (activar) una delegación en Draft, fuera de PendingApproval → falla. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakeDelegation(); // Born Draft + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.Draft, delegation.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Approve_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((UserManagementDelegation?)null); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + } + + // G-148 — AUTORIZACIÓN: sin autoridad de management-owner, aprobar falla y no persiste. + [Fact] + public async Task Approve_WhenNotManagementOwner_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + // El operador NO es management-owner del inquilino → gate rechaza. + _scope.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Failure("AUTH_015: Tenant is not marked as management owner.")); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // G-149 — AISLAMIENTO POR INQUILINO: operar sobre una delegación de otro inquilino falla + // (el gate de management-owner devuelve AUTH_014 tenant mismatch) y no persiste. + [Fact] + public async Task Approve_WhenCrossTenant_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + _scope.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Failure("AUTH_014: Tenant mismatch.")); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_014", result.Error, StringComparison.OrdinalIgnoreCase); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // G-150 — SEPARACIÓN DE FUNCIONES: el administrador delegante no puede autoaprobar su propia + // delegación, aun siendo management-owner (invariante de dominio INV-DEL8). No persiste. + [Fact] + public async Task Approve_WhenSelfApproval_ReturnsFailure() + { + var delegatingAdminId = Guid.NewGuid(); + _ctx.Setup(u => u.UserId).Returns(delegatingAdminId.ToString()); + var delegation = MakePendingApprovalDelegationWith(delegatingAdminId); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + // El gate de autorización se satisface (es management-owner); solo la SoD debe frenarlo. + _scope.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion + + // ========================================================================= + #region RejectDelegationCommandHandler (G-132) + // ========================================================================= + + [Fact] + public async Task Reject_WithPendingApproval_ReturnsSuccess() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new RejectDelegationCommand(Guid.NewGuid(), "Insufficient justification"); + var handler = new RejectDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(DelegationStatus.Rejected, delegation.Status); + _repo.Verify(r => r.UpdateAsync(delegation, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Reject_WhenNotPendingApproval_ReturnsFailure() + { + // Camino de RECHAZO: rechazar una delegación en Draft, fuera de PendingApproval → falla. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakeDelegation(); // Born Draft + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new RejectDelegationCommand(Guid.NewGuid(), "reason"); + var handler = new RejectDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.Draft, delegation.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reject_WhenEmptyReason_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new RejectDelegationCommand(Guid.NewGuid(), " "); + var handler = new RejectDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + } + + [Fact] + public async Task Reject_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((UserManagementDelegation?)null); + + var cmd = new RejectDelegationCommand(Guid.NewGuid(), "reason"); + var handler = new RejectDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + } + + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationQueryHandlerTests.cs index 0f2aa7f6..e70ac6e2 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationQueryHandlerTests.cs @@ -22,7 +22,7 @@ public DelegationQueryHandlerTests() _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); } - private static UserManagementDelegation MakeDelegation(Guid? id = null) + private static UserManagementDelegation MakeDelegation() { return UserManagementDelegation.Create( TenantId.Load(Guid.NewGuid()), diff --git a/src/apps/ums.api/Ums.Application.Test/Observability/FunctionalTransactionTests.cs b/src/apps/ums.api/Ums.Application.Test/Observability/FunctionalTransactionTests.cs new file mode 100644 index 00000000..d9a48cd9 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Observability/FunctionalTransactionTests.cs @@ -0,0 +1,140 @@ +namespace Ums.Application.Test.Observability; + +using Microsoft.Extensions.Logging.Abstractions; +using Ums.Application.Common.Interfaces; +using Ums.Infrastructure.Observability; +using Xunit; + +/// +/// Pruebas del modelo de transacción funcional (ADR-0096 §2.3; ADR-UMS-085). Verifican el +/// invariante del desenlace, la acuñación perezosa e idempotente del localizador legible +/// (ADR-UMS-084) y la reconciliación del estado con las etapas. +/// +public sealed class FunctionalTransactionTests +{ + private sealed class StubLocatorFactory : ITransactionLocatorFactory + { + public int Calls { get; private set; } + + public Task NextAsync(CancellationToken cancellationToken = default) + { + Calls++; + return Task.FromResult($"TX-2026-{Calls:D6}"); + } + } + + private static FunctionalTransaction NewTransaction(out StubLocatorFactory factory) + { + factory = new StubLocatorFactory(); + return new FunctionalTransaction( + NullLogger.Instance, + factory); + } + + [Fact] + public void El_localizador_no_existe_hasta_acuñarlo() + { + var tx = NewTransaction(out _); + tx.Open("POST /x", actor: "ana"); + + Assert.Null(tx.Locator); + } + + [Fact] + public async Task GetOrMintLocator_es_idempotente_y_acuña_una_sola_vez() + { + var tx = NewTransaction(out var factory); + tx.Open("POST /x", actor: "ana"); + + var first = await tx.GetOrMintLocatorAsync(); + var second = await tx.GetOrMintLocatorAsync(); + + Assert.Equal(first, second); + Assert.Equal("TX-2026-000001", first); + Assert.Equal(1, factory.Calls); + Assert.Equal(first, tx.Locator); + } + + [Fact] + public async Task Un_fallo_acuña_el_localizador_para_mostrarlo_al_usuario() + { + var tx = NewTransaction(out var factory); + tx.Open("GET /x", actor: "ana"); + + await tx.CompleteAsync(TransactionState.Failed, statusCode: 500); + + Assert.Equal(TransactionState.Failed, tx.State); + Assert.NotNull(tx.Locator); + Assert.Equal(1, factory.Calls); + } + + [Fact] + public async Task Una_lectura_exitosa_no_acuña_localizador() + { + var tx = NewTransaction(out var factory); + tx.Open("GET /x", actor: "ana"); + + await tx.CompleteAsync(TransactionState.Completed, statusCode: 200); + + Assert.Equal(TransactionState.Completed, tx.State); + Assert.Null(tx.Locator); + Assert.Equal(0, factory.Calls); + } + + [Fact] + public async Task Exito_nominal_con_etapa_fallida_se_reconcilia_como_parcial() + { + var tx = NewTransaction(out _); + tx.Open("POST /x", actor: "ana"); + tx.RecordStage("validar", detail: "ok"); + tx.RecordStage("publicar-evento", detail: "timeout del bus", failed: true); + + await tx.CompleteAsync(TransactionState.Completed, statusCode: 200); + + Assert.Equal(TransactionState.PartiallyCompleted, tx.State); + Assert.NotNull(tx.Locator); // parcial es no-exitoso → se acuña + } + + [Fact] + public async Task Exito_nominal_con_todas_las_etapas_fallidas_se_reconcilia_como_fallo() + { + var tx = NewTransaction(out _); + tx.Open("POST /x", actor: "ana"); + tx.RecordStage("publicar-evento", failed: true); + + await tx.CompleteAsync(TransactionState.Completed, statusCode: 200); + + Assert.Equal(TransactionState.Failed, tx.State); + } + + [Fact] + public async Task CompleteAsync_es_idempotente() + { + var tx = NewTransaction(out var factory); + tx.Open("GET /x", actor: "ana"); + + await tx.CompleteAsync(TransactionState.Failed, statusCode: 500); + var locatorAfterFirst = tx.Locator; + await tx.CompleteAsync(TransactionState.Completed, statusCode: 200); + + // El segundo desenlace no altera el estado ni vuelve a acuñar. + Assert.Equal(TransactionState.Failed, tx.State); + Assert.Equal(locatorAfterFirst, tx.Locator); + Assert.Equal(1, factory.Calls); + } + + [Fact] + public void RecordEffect_y_RecordDecision_no_lanzan() + { + var tx = NewTransaction(out _); + tx.Open("POST /x", actor: "ana"); + + var effect = Record.Exception(() => + tx.RecordEffect("message.publish", "outbox", "evt-1", EffectReversibility.PendingCompensation)); + var decision = Record.Exception(() => + tx.RecordDecision("aprobar", "el monto está bajo el umbral")); + + Assert.Null(effect); + Assert.Null(decision); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Tenants/AddBranch/AddBranchCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Tenants/AddBranch/AddBranchCommandHandlerTests.cs index 4367188f..7a9756aa 100644 --- a/src/apps/ums.api/Ums.Application.Test/Tenants/AddBranch/AddBranchCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Tenants/AddBranch/AddBranchCommandHandlerTests.cs @@ -58,17 +58,17 @@ public async Task Handle_WhenBranchCodeExistsCrossTenant_ReturnsFailure() { var tenantId = Guid.NewGuid(); _userContextMock.Setup(u => u.UserId).Returns("user-001"); - var tenant = CreateTenant(); + var tenant = CreateTenant(); // agregado sin sucursales (colección vaciada por el filtro global) _tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny())) .ReturnsAsync(tenant); - _tenantRepositoryMock.Setup(r => r.BranchCodeExistsAsync(tenantId, It.IsAny(), It.IsAny())) + _tenantRepositoryMock.Setup(r => r.BranchCodeExistsAsync(tenantId, "BR-001", It.IsAny())) .ReturnsAsync(true); var command = ValidCommand with { TenantId = tenantId }; var result = await _handler.Handle(command, CancellationToken.None); Assert.True(result.IsFailure); - Assert.Equal(DomainErrors.Tenant.BranchCodeNotUnique, result.Error); + Assert.Contains(DomainErrors.Tenant.BranchCodeNotUnique, result.Error); } [Fact] diff --git a/src/apps/ums.api/Ums.Application.Test/Tenants/Branch/BranchCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Tenants/Branch/BranchCommandHandlerTests.cs index 33f1ae13..de6e6c86 100644 --- a/src/apps/ums.api/Ums.Application.Test/Tenants/Branch/BranchCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Tenants/Branch/BranchCommandHandlerTests.cs @@ -1,6 +1,8 @@ namespace Ums.Application.Test.Tenants.Branch; using Ums.Application.Identity.Tenant.Branch.Commands; +using Ums.Application.Identity.Tenant.Branch.Queries; +using Ums.Domain.Authorization; using Ums.Domain.Identity; using Ums.Domain.Identity.Tenant; using Ums.Application.Common.Interfaces; @@ -9,6 +11,8 @@ namespace Ums.Application.Test.Tenants.Branch; public class BranchCommandHandlerTests { private readonly Mock _repo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _profileRepo = new(); private readonly Mock _uow = new(); private readonly Mock _ctx = new(); private readonly Mock _scopePolicy = new(); @@ -20,6 +24,10 @@ public BranchCommandHandlerTests() _ctx.Setup(u => u.UserId).Returns("user-001"); _scopePolicy.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success()); + _userRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + _profileRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); } private static Tenant MakeTenant() @@ -42,14 +50,14 @@ private static Tenant MakeTenant() public async Task DeactivateBranch_WithValidCommand_ReturnsSuccess() { var tenant = MakeTenant(); - var branchResult = tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); var branchId = tenant.Branches.First().GetId().GetValue(); _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(tenant); var cmd = new DeactivateBranchCommand(tenant.Props.Id.GetValue(), branchId); - var handler = new DeactivateBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var handler = new DeactivateBranchCommandHandler(_repo.Object, _userRepo.Object, _ctx.Object, _scopePolicy.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess, result.Error); @@ -64,7 +72,7 @@ public async Task DeactivateBranch_WhenTenantNotFound_ReturnsFailure() .ReturnsAsync((Tenant?)null); var cmd = new DeactivateBranchCommand(Guid.NewGuid(), Guid.NewGuid()); - var handler = new DeactivateBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var handler = new DeactivateBranchCommandHandler(_repo.Object, _userRepo.Object, _ctx.Object, _scopePolicy.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -77,7 +85,7 @@ public async Task DeactivateBranch_WhenUnauthenticated_ReturnsFailure() _ctx.Setup(u => u.UserId).Returns(""); var cmd = new DeactivateBranchCommand(Guid.NewGuid(), Guid.NewGuid()); - var handler = new DeactivateBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var handler = new DeactivateBranchCommandHandler(_repo.Object, _userRepo.Object, _ctx.Object, _scopePolicy.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -92,7 +100,7 @@ public async Task DeactivateBranch_WhenBranchNotFound_ReturnsFailure() .ReturnsAsync(tenant); var cmd = new DeactivateBranchCommand(tenant.Props.Id.GetValue(), Guid.NewGuid()); - var handler = new DeactivateBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var handler = new DeactivateBranchCommandHandler(_repo.Object, _userRepo.Object, _ctx.Object, _scopePolicy.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -168,11 +176,11 @@ public async Task ReactivateBranch_WhenBranchNotFound_ReturnsFailure() #endregion // ========================================================================= - #region RemoveBranchCommandHandler + #region CloseBranchCommandHandler (ADR-0164) // ========================================================================= [Fact] - public async Task RemoveBranch_WithValidCommand_ReturnsSuccess() + public async Task CloseBranch_WithValidCommand_ReturnsSuccess() { var tenant = MakeTenant(); tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); @@ -182,55 +190,132 @@ public async Task RemoveBranch_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(tenant); - var cmd = new RemoveBranchCommand(tenant.Props.Id.GetValue(), branch.GetId().GetValue()); - var handler = new RemoveBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var cmd = new CloseBranchCommand(tenant.Props.Id.GetValue(), branch.GetId().GetValue()); + var handler = NuevoManejador(); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess, result.Error); - Assert.Empty(tenant.Branches); + // ADR-0164 §2.1: la sucursal NO desaparece de la colección. Antes esta misma prueba + // afirmaba `Assert.Empty(tenant.Branches)`. + Assert.Single(tenant.Branches); + Assert.True(tenant.Branches.First().IsClosed); _repo.Verify(r => r.UpdateAsync(tenant, It.IsAny()), Times.Once); } [Fact] - public async Task RemoveBranch_WhenTenantNotFound_ReturnsFailure() + public async Task CloseBranch_ConUsuariosActivos_Devuelve409ConElDesglose() + { + var tenant = MakeTenant(); + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); + var branch = tenant.Branches.First(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _userRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(2); + _profileRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(3); + + var cmd = new CloseBranchCommand(tenant.Props.Id.GetValue(), branch.GetId().GetValue()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.True(BlockedOperationError.TryDecode(result.Error, out var codigo, out var deps)); + Assert.Equal(DomainErrors.Tenant.BranchHasLiveReferences, codigo); + // El desglose nombra LAS DOS clases que bloquean, no solo la primera encontrada. + Assert.Equal(2, deps.Count); + Assert.Equal(2, deps.Single(d => d.EntityType == "UserAccount").Count); + Assert.Equal(3, deps.Single(d => d.EntityType == "Profile").Count); + Assert.False(tenant.Branches.First().IsClosed); + } + + [Fact] + public async Task CloseBranch_ConPerfilesActivosPeroSinUsuarios_TambienBloquea() + { + // Sin este caso la guarda de perfiles podría quedar muerta detrás de la de usuarios. + var tenant = MakeTenant(); + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); + var branch = tenant.Branches.First(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _profileRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(1); + + var cmd = new CloseBranchCommand(tenant.Props.Id.GetValue(), branch.GetId().GetValue()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.True(BlockedOperationError.TryDecode(result.Error, out _, out var deps)); + Assert.Equal("Profile", Assert.Single(deps).EntityType); + } + + [Fact] + public async Task CloseBranch_WhenTenantNotFound_ReturnsFailure() { _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((Tenant?)null); - var cmd = new RemoveBranchCommand(Guid.NewGuid(), Guid.NewGuid()); - var handler = new RemoveBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); - var result = await handler.Handle(cmd, CancellationToken.None); + var cmd = new CloseBranchCommand(Guid.NewGuid(), Guid.NewGuid()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); Assert.Contains("Tenant was not found", result.Error); } [Fact] - public async Task RemoveBranch_WhenUnauthenticated_ReturnsFailure() + public async Task CloseBranch_WhenUnauthenticated_ReturnsFailure() { _ctx.Setup(u => u.UserId).Returns(""); - var cmd = new RemoveBranchCommand(Guid.NewGuid(), Guid.NewGuid()); - var handler = new RemoveBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); - var result = await handler.Handle(cmd, CancellationToken.None); + var cmd = new CloseBranchCommand(Guid.NewGuid(), Guid.NewGuid()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); Assert.Contains("Authenticated user is required", result.Error); } [Fact] - public async Task RemoveBranch_WhenBranchNotFound_ReturnsFailure() + public async Task CloseBranch_WhenBranchNotFound_ReturnsFailure() { var tenant = MakeTenant(); _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(tenant); - var cmd = new RemoveBranchCommand(tenant.Props.Id.GetValue(), Guid.NewGuid()); - var handler = new RemoveBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); - var result = await handler.Handle(cmd, CancellationToken.None); + var cmd = new CloseBranchCommand(tenant.Props.Id.GetValue(), Guid.NewGuid()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); } #endregion + + // ========================================================================= + #region GetBranchesByTenantIdQueryHandler (ADR-0164: listas sin las cerradas) + // ========================================================================= + + [Fact] + public async Task ListarSucursales_OcultaLasCerradasSalvoQueSePidanExpresamente() + { + var tenant = MakeTenant(); + tenant.AddBranch(Code.Create("BR-VIVA"), Name.Create("Sucursal viva"), ActorId.Create("user-001"), null); + tenant.AddBranch(Code.Create("BR-CERRADA"), Name.Create("Sucursal cerrada"), ActorId.Create("user-001"), null); + var cerrada = tenant.Branches.First(b => b.Code.GetValue() == "BR-CERRADA"); + tenant.CloseBranch(cerrada.GetId(), ActorId.Create("user-001")); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + var handler = new GetBranchesByTenantIdQueryHandler(_repo.Object); + + var pordefecto = await handler.Handle(new GetBranchesByTenantIdQuery(tenant.Props.Id.GetValue()), CancellationToken.None); + Assert.True(pordefecto.IsSuccess); + Assert.Equal("BR-VIVA", Assert.Single(pordefecto.Value).Code); + + // Pedirlas explícitamente sí las trae, y marcadas: «oculto» no es «borrado». + var conCerradas = await handler.Handle( + new GetBranchesByTenantIdQuery(tenant.Props.Id.GetValue(), IncludeClosed: true), CancellationToken.None); + Assert.Equal(2, conCerradas.Value.Count); + var dtoCerrada = conCerradas.Value.Single(b => b.Code == "BR-CERRADA"); + Assert.True(dtoCerrada.IsClosed); + Assert.NotNull(dtoCerrada.ClosedAtUtc); + } + + #endregion + + private CloseBranchCommandHandler NuevoManejador() + => new(_repo.Object, _userRepo.Object, _profileRepo.Object, _ctx.Object, _scopePolicy.Object); } diff --git a/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantMultiTenantIsolationTests.cs b/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantMultiTenantIsolationTests.cs index 2d9b0e6f..bcb2bd69 100644 --- a/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantMultiTenantIsolationTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantMultiTenantIsolationTests.cs @@ -35,7 +35,7 @@ private void SetupRepoForTenant(Guid? tenantId, IReadOnlyList result) _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - tenantId, It.IsAny())) + tenantId, It.IsAny(), It.IsAny())) .ReturnsAsync((result, result.Count)); } @@ -60,7 +60,7 @@ public async Task RegularUser_AlwaysScopedToOwnTenant_EvenWithoutRequest() _repo.Verify(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - ransaId, It.IsAny()), Times.Once); + ransaId, It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -85,12 +85,12 @@ public async Task RegularUser_CannotSeeOtherTenants_CrossTenantAttemptIsBlocked( _repo.Verify(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - acmeId, It.IsAny()), Times.Never); + acmeId, It.IsAny(), It.IsAny()), Times.Never); _repo.Verify(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - null, It.IsAny()), Times.Never); + null, It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -98,7 +98,6 @@ public async Task RegularUser_OnlySeesOwnTenantData_NotOthers() { var ransaId = Guid.NewGuid(); var ransaTenant = MakeTenant("RANSA", "Ransa Corp"); - var acmeTenant = MakeTenant("ACME", "Acme Corp"); _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns(ransaId); @@ -124,7 +123,6 @@ public async Task RegularUser_OnlySeesOwnTenantData_NotOthers() [Fact] public async Task InternalAdmin_WithNoFilter_SeesAllTenants() { - var adminTenantId = Guid.NewGuid(); var allTenants = new List { MakeTenant("RANSA", "Ransa Corp"), @@ -147,7 +145,7 @@ public async Task InternalAdmin_WithNoFilter_SeesAllTenants() _repo.Verify(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - null, It.IsAny()), Times.Once); + null, It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -215,13 +213,13 @@ public async Task TwoDifferentTenantUsers_EachSeesOnlyOwnData() var repoRansa = new Mock(); repoRansa.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), ransaId, It.IsAny())) + It.IsAny(), It.IsAny(), ransaId, It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)[ransaTenant], 1)); var repoAcme = new Mock(); repoAcme.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), acmeId, It.IsAny())) + It.IsAny(), It.IsAny(), acmeId, It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)[acmeTenant], 1)); var query = new GetAllTenantsQuery(Page: 1, PageSize: 20); @@ -242,4 +240,58 @@ public async Task TwoDifferentTenantUsers_EachSeesOnlyOwnData() } #endregion + + // ========================================================================= + #region GetTenantById isolation (TS04/F3) + // ========================================================================= + // El agregado Tenant es su propia identidad, así que el global query filter (que aísla entidades + // con columna TenantId) NO lo cubre: sin chequeo, cualquier usuario autenticado leía la identidad + // de cualquier tenant por id (fuga cross-tenant). GetTenantByIdQueryHandler ahora exige propiedad + // vía ITenantScopePolicy.ResolveQueryScope() (null=internal-admin; si no, el propio OrganizationId) + // y devuelve "not found" (404, no filtra existencia) al pedir un tenant ajeno. + + [Fact] + public async Task GetById_RegularUser_OtherTenant_ReturnsNotFound() + { + var target = MakeTenant("AGRONORTE", "Agroexportadora del Norte"); + var otherOrgId = Guid.NewGuid(); // el scope del llamador (COMEX), distinto al tenant pedido + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(target); + _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns(otherOrgId); + + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); + var result = await handler.Handle(new GetTenantByIdQuery(target.Props.Id.GetValue()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetById_RegularUser_OwnTenant_ReturnsSuccess() + { + var own = MakeTenant("COMEX_ANDINA", "Comex Andina"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(own); + _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns(own.Props.Id.GetValue()); + + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); + var result = await handler.Handle(new GetTenantByIdQuery(own.Props.Id.GetValue()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("COMEX_ANDINA", result.Value.Code); + } + + [Fact] + public async Task GetById_InternalAdmin_AnyTenant_ReturnsSuccess() + { + var target = MakeTenant("AGRONORTE", "Agroexportadora del Norte"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(target); + _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns((Guid?)null); // internal-admin cross-tenant + + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); + var result = await handler.Handle(new GetTenantByIdQuery(target.Props.Id.GetValue()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("AGRONORTE", result.Value.Code); + } + + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantQueryHandlerTests.cs index 5e4c470c..d04300eb 100644 --- a/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantQueryHandlerTests.cs @@ -36,7 +36,7 @@ public async Task GetById_WhenFound_ReturnsSuccess() .ReturnsAsync(tenant); var query = new GetTenantByIdQuery(tenant.Props.Id.GetValue()); - var handler = new GetTenantByIdQueryHandler(_repo.Object); + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); var result = await handler.Handle(query, CancellationToken.None); Assert.True(result.IsSuccess); @@ -51,7 +51,7 @@ public async Task GetById_WhenNotFound_ReturnsFailure() .ReturnsAsync((Tenant?)null); var query = new GetTenantByIdQuery(Guid.NewGuid()); - var handler = new GetTenantByIdQueryHandler(_repo.Object); + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); var result = await handler.Handle(query, CancellationToken.None); Assert.True(result.IsFailure); @@ -74,7 +74,7 @@ public async Task GetById_WithExternalTenant_ReturnsCorrectType() .ReturnsAsync(tenant); var query = new GetTenantByIdQuery(tenant.Props.Id.GetValue()); - var handler = new GetTenantByIdQueryHandler(_repo.Object); + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); var result = await handler.Handle(query, CancellationToken.None); Assert.True(result.IsSuccess); @@ -100,7 +100,7 @@ public async Task GetAll_WithoutFilters_ReturnsAll() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, tenants.Count)); var query = new GetAllTenantsQuery(Page: 1, PageSize: 10); @@ -121,7 +121,7 @@ public async Task GetAll_WithPagination_ReturnsCorrectPage() _repo.Setup(r => r.GetPagedAsync( 2, 5, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, 1)); var query = new GetAllTenantsQuery(Page: 2, PageSize: 5); @@ -142,7 +142,7 @@ public async Task GetAll_WithStatusFilter_PassesStatusToRepo() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), "Active", It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, tenants.Count)); var query = new GetAllTenantsQuery(Status: "Active"); @@ -163,7 +163,7 @@ public async Task GetAll_WithSearch_PassesSearchToRepo() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), "target", It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, tenants.Count)); var query = new GetAllTenantsQuery(Search: "target"); @@ -183,7 +183,7 @@ public async Task GetAll_WithEmptyResult_ReturnsZeroTotalPages() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, 0)); var query = new GetAllTenantsQuery(Page: 1, PageSize: 10); @@ -204,7 +204,7 @@ public async Task GetAll_WithSorting_PassesSortToRepo() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), "code", "desc", - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, tenants.Count)); var query = new GetAllTenantsQuery(SortBy: "code", SortOrder: "desc"); diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommand.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommand.cs index 374a7a57..55dbdbac 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommand.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommand.cs @@ -3,4 +3,6 @@ namespace Ums.Application.Approvals.AccessEnforcementPolicy.Commands; public sealed record CreateAccessEnforcementPolicyCommand( - Guid TenantId, Guid? ProfileId, Guid? RoleId, string EnforcementAction) : ICommand; + Guid TenantId, Guid? ProfileId, Guid? RoleId, string EnforcementAction, + // G-120 (FR-053): periodo de gracia en días (0 = inmediato). Opcional para compatibilidad. + int GracePeriodDays = 0) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandHandler.cs index 9feb8c32..0fab68cd 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandHandler.cs @@ -32,7 +32,8 @@ public async Task> Handle(CreateAc request.ProfileId.HasValue ? ProfileId.Load(request.ProfileId.Value) : null, request.RoleId.HasValue ? RoleId.Load(request.RoleId.Value) : null, action, - ActorId.Create(_userContext.UserId)); + ActorId.Create(_userContext.UserId), + request.GracePeriodDays); if (result.IsFailure) return Result.Failure(result.Error); diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandValidator.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandValidator.cs index 48116d7f..2d557c86 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandValidator.cs @@ -7,8 +7,16 @@ public sealed class CreateAccessEnforcementPolicyCommandValidator : AbstractVali public CreateAccessEnforcementPolicyCommandValidator() { RuleFor(c => c.TenantId).NotEmpty(); - RuleFor(c => c.EnforcementAction).NotEmpty(); + // G-045: valida el nombre del enum para evitar un NullReference → 500 cuando el + // handler parsea un valor no vacío pero inválido con `!`. + RuleFor(c => c.EnforcementAction) + .NotEmpty() + .Must(action => DomainEnumerationParser.FromName(action) is not null) + .WithMessage("Enforcement action is not supported."); RuleFor(c => c).Must(c => c.ProfileId.HasValue || c.RoleId.HasValue) .WithMessage("Either ProfileId or RoleId must be provided."); + // G-120 (FR-053): periodo de gracia no negativo (0 = enforcement inmediato). + RuleFor(c => c.GracePeriodDays).GreaterThanOrEqualTo(0) + .WithMessage("Grace period days must be zero or greater."); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/DTOs/AccessEnforcementPolicyDto.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/DTOs/AccessEnforcementPolicyDto.cs index 2d034158..e76c8c04 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/DTOs/AccessEnforcementPolicyDto.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/DTOs/AccessEnforcementPolicyDto.cs @@ -6,4 +6,6 @@ public sealed record AccessEnforcementPolicyDto( Guid? ProfileId, Guid? RoleId, string EnforcementAction, - bool IsActive); + bool IsActive, + // G-120 (FR-053): periodo de gracia en días antes de aplicar el enforcement (0 = inmediato). + int GracePeriodDays); diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAccessEnforcementPolicyByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAccessEnforcementPolicyByIdQueryHandler.cs index 2161f68d..eae8542f 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAccessEnforcementPolicyByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAccessEnforcementPolicyByIdQueryHandler.cs @@ -19,6 +19,7 @@ public async Task> Handle(GetAccessEnforcemen return Result.Success(new AccessEnforcementPolicyDto( entity.Props.Id.GetValue(), entity.Props.TenantId.GetValue(), entity.Props.ProfileId?.GetValue(), - entity.Props.RoleId?.GetValue(), entity.Props.EnforcementAction.ToString(), entity.Props.IsActive)); + entity.Props.RoleId?.GetValue(), entity.Props.EnforcementAction.ToString(), entity.Props.IsActive, + entity.Props.GracePeriodDays)); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAllAccessEnforcementPoliciesQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAllAccessEnforcementPoliciesQueryHandler.cs index 5bc2882e..1db044a7 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAllAccessEnforcementPoliciesQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAllAccessEnforcementPoliciesQueryHandler.cs @@ -36,7 +36,8 @@ public async Task>> Handle(GetAll var query = items.Select(p => new AccessEnforcementPolicyDto( p.Props.Id.GetValue(), p.Props.TenantId.GetValue(), p.Props.ProfileId?.GetValue(), - p.Props.RoleId?.GetValue(), p.Props.EnforcementAction.ToString(), p.Props.IsActive)); + p.Props.RoleId?.GetValue(), p.Props.EnforcementAction.ToString(), p.Props.IsActive, + p.Props.GracePeriodDays)); if (!string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Commands/ApproveRequestCommandHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Commands/ApproveRequestCommandHandler.cs index 4669f61e..585898ed 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Commands/ApproveRequestCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Commands/ApproveRequestCommandHandler.cs @@ -18,6 +18,8 @@ public sealed class ApproveRequestCommandHandler : ICommandHandler Handle(ApproveRequestCommand request, CancellationToke var entity = await _repository.GetByIdAsync(request.ApprovalRequestId, cancellationToken); if (entity is null) return Result.Failure("Approval request not found."); + // G-119 (SoD): segregación de deberes en la aprobación EXPLÍCITA — quien creó la solicitud + // (Audit.CreatedBy) no puede aprobarla. Se guarda aquí (acción humana vía endpoint), no en el + // dominio, para NO bloquear el auto-approve de workflows con RequiresApproval=false (que aprueba + // con el actor creador como acción del sistema, sin intervención humana). Consistente con IGA. + if (string.Equals(_userContext.UserId, entity.Props.Audit.GetValue().CreatedBy, StringComparison.OrdinalIgnoreCase)) + return Result.Failure(DomainErrors.Approvals.SelfApprovalNotAllowed); + var targetUser = await _userAccountRepository.GetByIdAsync(entity.TargetUserId.GetValue(), cancellationToken); if (targetUser is null) return Result.Failure("Target user not found."); @@ -69,6 +82,15 @@ public async Task Handle(ApproveRequestCommand request, CancellationToke if (authorization.IsFailure && !await CanApproveAsDelegatedBranchManagerAsync(entity, targetUser.TenantId.GetValue(), cancellationToken)) return Result.Failure(authorization.Error); + // G-051 F4: exigencia cross-agregado del checklist de documentos requeridos. + // Precondicion de SOLO LECTURA y fail-closed: se cruza el checklist declarado por el + // ApprovalWorkflow contra los UserDocument del usuario objetivo ANTES de transicionar. + // No muta ApprovalWorkflow ni UserDocument, por lo que no amplia la excepcion D-016 de + // este handler (que sigue mutando solo ApprovalRequest + Profile). Ver RequiredDocumentChecklist. + var checklistResult = await EnsureRequiredDocumentsComplete(entity, targetUser, cancellationToken); + if (checklistResult.IsFailure) + return checklistResult; + var result = entity.Approve(ActorId.Create(_userContext.UserId), RoleId.Load(request.GrantedRoleId), request.DecisionReason); if (result.IsFailure) return result; @@ -89,15 +111,24 @@ public async Task Handle(ApproveRequestCommand request, CancellationToke _userContext.UserId, DateTime.UtcNow)); - await using var tx = await _unitOfWorkScope.BeginAsync(cancellationToken); - if (isNewProfile) - await _profileRepository.AddAsync(assignedProfile, cancellationToken); - else - await _profileRepository.UpdateAsync(assignedProfile, cancellationToken); - await _repository.UpdateAsync(entity, cancellationToken); - await _profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - await tx.CommitAsync(cancellationToken); + // TODO(D-016): excepción PROVISIONAL a "un agregado por transacción" (ADR-0098 D2): + // muta ApprovalRequest + Profile en la misma tx. Candidata a separación por despacho + // post-commit / consistencia eventual (ADR-0098 D4). Ver DECISIONS.md D-016 (E2) y GAPS.md G-066. + // Patrón e interpretación: KB-TXN-001 (Base de Conocimiento de Arquitectura, evolith-core). + // Revisar al separar agregados en la progresión a microservicios. + // G-117: begin/commit vía ExecutionStrategy. El BeginAsync manual anterior era incompatible con + // EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) → toda aprobación fallaba con + // InvalidOperationException 'does not support user-initiated transactions' (mapeada a 400). + await _unitOfWorkScope.ExecuteInTransactionAsync(async ct => + { + if (isNewProfile) + await _profileRepository.AddAsync(assignedProfile, ct); + else + await _profileRepository.UpdateAsync(assignedProfile, ct); + await _repository.UpdateAsync(entity, ct); + await _profileRepository.UnitOfWork.SaveEntitiesAsync(ct); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + }, cancellationToken); var tenant = await _tenantRepository.GetByIdAsync(targetUser.TenantId.GetValue(), cancellationToken); await _notificationService.SendAsync( @@ -112,6 +143,24 @@ await _notificationService.SendAsync( return Result.Success(); } + // G-051 F4: valida (fail-closed) que el usuario objetivo satisfaga el checklist de documentos + // obligatorios del workflow de la solicitud antes de aprobar. Si el workflow no se puede + // resolver, se rechaza la aprobacion: no se aprueba sin poder verificar el cumplimiento. + private async Task EnsureRequiredDocumentsComplete( + ApprovalRequest entity, + UserAccount targetUser, + CancellationToken cancellationToken) + { + var workflow = await _workflowRepository.GetByIdAsync(entity.WorkflowId.GetValue(), cancellationToken); + if (workflow is null) + return Result.Failure(DomainErrors.Approvals.RequiredDocumentsIncomplete); + + var targetUserDocuments = await _userDocumentRepository.GetByUserIdAsync( + targetUser.GetId().GetValue(), cancellationToken); + + return RequiredDocumentChecklist.Evaluate(workflow, targetUserDocuments); + } + private async Task CanApproveAsDelegatedBranchManagerAsync( ApprovalRequest entity, Guid tenantId, diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Queries/GetAllApprovalRequestsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Queries/GetAllApprovalRequestsQueryHandler.cs index b1837179..168bf4be 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Queries/GetAllApprovalRequestsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Queries/GetAllApprovalRequestsQueryHandler.cs @@ -54,7 +54,7 @@ public async Task>> Handle(GetAllApproval query = query.Where(r => r.WorkflowId.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)); // G-159: el parámetro userId de la query se ignoraba (contrato expuesto, filtro no cableado). - // Se filtra por el usuario objetivo de la solicitud (TargetUserId), la semántica útil: + // Se filtra por el usuario objetivo de la solicitud (TargetUserId), que es la semántica útil: // «solicitudes de aprobación que conciernen a este usuario». if (request.UserId.HasValue) query = query.Where(r => r.TargetUserId == request.UserId.Value); diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Commands/CreateApprovalWorkflowCommandValidator.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Commands/CreateApprovalWorkflowCommandValidator.cs index dff4b663..95f300b4 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Commands/CreateApprovalWorkflowCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Commands/CreateApprovalWorkflowCommandValidator.cs @@ -10,6 +10,11 @@ public CreateApprovalWorkflowCommandValidator() RuleFor(c => c.Code).NotEmpty().MaximumLength(50); RuleFor(c => c.Name).NotEmpty().MaximumLength(150); RuleFor(c => c.Description).NotEmpty().MaximumLength(500); - RuleFor(c => c.TargetUserCategory).NotEmpty(); + // G-045: valida el nombre del enum para evitar un NullReference → 500 cuando el + // handler parsea un valor no vacío pero inválido con `!`. + RuleFor(c => c.TargetUserCategory) + .NotEmpty() + .Must(category => DomainEnumerationParser.FromName(category) is not null) + .WithMessage("Target user category is not supported."); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/DTOs/ApprovalWorkflowDto.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/DTOs/ApprovalWorkflowDto.cs index 0f8c39b3..542beec7 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/DTOs/ApprovalWorkflowDto.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/DTOs/ApprovalWorkflowDto.cs @@ -8,4 +8,12 @@ public sealed record ApprovalWorkflowDto( string Name, string Description, string TargetUserCategory, - bool RequiresApproval); + bool RequiresApproval, + // WF2/WF3 (G-118): proyecta el checklist de documentos requeridos con su id, para que sea + // legible (antes write-only) y que un cliente pueda obtener el id que necesita el DELETE. + IReadOnlyList RequiredDocuments); + +public sealed record RequiredDocumentDto( + Guid RequiredDocumentId, + Guid DocumentTypeId, + bool IsMandatory); diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetAllApprovalWorkflowsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetAllApprovalWorkflowsQueryHandler.cs index 8ce651db..494fd789 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetAllApprovalWorkflowsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetAllApprovalWorkflowsQueryHandler.cs @@ -39,7 +39,10 @@ public async Task>> Handle(GetAllApprova var query = items.Select(w => new ApprovalWorkflowDto( w.Props.Id.GetValue(), w.Props.TenantId.GetValue(), w.Props.SystemSuiteId?.GetValue(), w.Props.Code.GetValue(), w.Props.Name.GetValue(), w.Props.Description.GetValue(), - w.Props.TargetUserCategory.ToString(), w.Props.RequiresApproval)); + w.Props.TargetUserCategory.ToString(), w.Props.RequiresApproval, + // WF2/WF3 (G-118): checklist observable con id. + w.RequiredDocuments.Select(d => new RequiredDocumentDto( + d.GetId().GetValue(), d.DocumentTypeId.GetValue(), d.IsMandatory)).ToList())); if (!string.IsNullOrWhiteSpace(search)) query = query.Where(w => w.Name.Contains(search, StringComparison.OrdinalIgnoreCase)); diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetApprovalWorkflowByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetApprovalWorkflowByIdQueryHandler.cs index 182b15d1..f341ce0a 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetApprovalWorkflowByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetApprovalWorkflowByIdQueryHandler.cs @@ -20,6 +20,9 @@ public async Task> Handle(GetApprovalWorkflowByIdQue return Result.Success(new ApprovalWorkflowDto( workflow.Props.Id.GetValue(), workflow.Props.TenantId.GetValue(), workflow.Props.SystemSuiteId?.GetValue(), workflow.Props.Code.GetValue(), workflow.Props.Name.GetValue(), workflow.Props.Description.GetValue(), - workflow.Props.TargetUserCategory.ToString(), workflow.Props.RequiresApproval)); + workflow.Props.TargetUserCategory.ToString(), workflow.Props.RequiresApproval, + // WF2/WF3 (G-118): checklist observable con id (para el DELETE por id). + workflow.RequiredDocuments.Select(d => new RequiredDocumentDto( + d.GetId().GetValue(), d.DocumentTypeId.GetValue(), d.IsMandatory)).ToList())); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/DocumentType/Commands/CreateDocumentTypeCommandValidator.cs b/src/apps/ums.api/Ums.Application/Approvals/DocumentType/Commands/CreateDocumentTypeCommandValidator.cs index 1a576301..d8eeabcc 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/DocumentType/Commands/CreateDocumentTypeCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/DocumentType/Commands/CreateDocumentTypeCommandValidator.cs @@ -10,6 +10,12 @@ public CreateDocumentTypeCommandValidator() RuleFor(c => c.Code).NotEmpty().MaximumLength(50); RuleFor(c => c.Name).NotEmpty().MaximumLength(150); RuleFor(c => c.Description).NotEmpty().MaximumLength(500); - RuleFor(c => c.Criticity).NotEmpty(); + // G-045: sin validar el nombre del enum, un valor no vacío pero inválido pasa la + // validación y el handler lo parsea con `!`, produciendo un NullReference → 500. + // Validamos aquí para devolver 400/422 con detalle en vez de un 500 inesperado. + RuleFor(c => c.Criticity) + .NotEmpty() + .Must(criticity => DomainEnumerationParser.FromName(criticity) is not null) + .WithMessage("Document criticity is not supported."); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/UserDocument/Commands/UploadUserDocumentCommandValidator.cs b/src/apps/ums.api/Ums.Application/Approvals/UserDocument/Commands/UploadUserDocumentCommandValidator.cs index baca5086..5f2c1906 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/UserDocument/Commands/UploadUserDocumentCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/UserDocument/Commands/UploadUserDocumentCommandValidator.cs @@ -10,7 +10,12 @@ public UploadUserDocumentCommandValidator() RuleFor(c => c.DocumentTypeId).NotEmpty(); RuleFor(c => c.IssueDate).NotEmpty(); RuleFor(c => c.ExpirationDate).NotEmpty(); - RuleFor(c => c.Criticity).NotEmpty(); + // G-045: valida el nombre del enum para evitar un NullReference → 500 cuando el + // handler parsea un valor no vacío pero inválido con `!`. + RuleFor(c => c.Criticity) + .NotEmpty() + .Must(criticity => DomainEnumerationParser.FromName(criticity) is not null) + .WithMessage("Document criticity is not supported."); RuleFor(c => c.FileStoragePath).NotEmpty().MaximumLength(500); RuleFor(c => c.FileChecksum).NotEmpty().MaximumLength(128); } diff --git a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandHandler.cs b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandHandler.cs index 64edf575..a0cb39fd 100644 --- a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandHandler.cs @@ -1,4 +1,5 @@ using Ums.Application.Audit.AuditRecord.DTOs; +using Ums.Application.Common.Interfaces; namespace Ums.Application.Audit.AuditRecord.Commands; @@ -8,10 +9,12 @@ namespace Ums.Application.Audit.AuditRecord.Commands; public sealed class RecordAuditCommandHandler : ICommandHandler { private readonly IAuditRecordRepository _auditRecordRepository; + private readonly IUserContext _userContext; - public RecordAuditCommandHandler(IAuditRecordRepository auditRecordRepository) + public RecordAuditCommandHandler(IAuditRecordRepository auditRecordRepository, IUserContext userContext) { _auditRecordRepository = auditRecordRepository; + _userContext = userContext; } [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] @@ -19,19 +22,40 @@ public async Task> Handle( RecordAuditCommand request, CancellationToken cancellationToken) { + // G-040 (SEGURIDAD): no repudio. El actor y el inquilino se derivan del + // contexto autenticado, NO del cuerpo de la petición (que es falsificable). + if (!_userContext.IsAuthenticated || string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required."); + } + + if (!Guid.TryParse(_userContext.UserId, out var actorId)) + { + return Result.Failure("Authenticated user identifier is invalid."); + } + + if (!Guid.TryParse(_userContext.TenantId, out var tenantId)) + { + return Result.Failure("Authenticated tenant is required."); + } + var subjectType = DomainEnumerationParser.FromName(request.SubjectType) ?? SubjectType.User; var auditResult = DomainEnumerationParser.FromName(request.AuditResult) ?? AuditResult.Success; + // G-040 (FR-072): desinfecta la metadata antes de persistirla — la traza es append-only e + // inmutable (G-081), así que un secreto (hash/PIN/llave/token) filtrado aquí no se puede borrar. + var sanitizedMetadata = AuditMetadataSanitizer.Sanitize(request.Metadata); + var auditRecordResult = AuditRecord.Record( - request.WhoActed, + actorId, subjectType, request.WhatChanged, request.EventType, auditResult, request.AffectedEntityId, request.AffectedEntityType, - request.RootTenantId, - request.Metadata); + tenantId, + sanitizedMetadata); if (auditRecordResult.IsFailure) { diff --git a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandValidator.cs b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandValidator.cs index e488e5c3..02899ab1 100644 --- a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandValidator.cs @@ -12,5 +12,30 @@ public RecordAuditCommandValidator() RuleFor(command => command.AffectedEntityId).NotEmpty(); RuleFor(command => command.AffectedEntityType).NotEmpty().MaximumLength(100); RuleFor(command => command.RootTenantId).NotEmpty(); + + // G-040: el Metadata de auditoría es opcional pero, si viene, debe estar acotado en tamaño y ser + // JSON válido — evita registros de auditoría con payloads gigantes o malformados/falsificables. + RuleFor(command => command.Metadata) + .MaximumLength(4000) + .Must(BeValidJsonWhenPresent) + .WithMessage("Metadata must be well-formed JSON when provided."); + } + + private static bool BeValidJsonWhenPresent(string? metadata) + { + if (string.IsNullOrWhiteSpace(metadata)) + { + return true; + } + + try + { + using var _ = System.Text.Json.JsonDocument.Parse(metadata); + return true; + } + catch (System.Text.Json.JsonException) + { + return false; + } } } diff --git a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAllAuditRecordsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAllAuditRecordsQueryHandler.cs index 6f57fbf7..7de02636 100644 --- a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAllAuditRecordsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAllAuditRecordsQueryHandler.cs @@ -29,9 +29,12 @@ public async Task>> Handle( var from = request.From ?? DateTime.UtcNow.AddDays(-30); var to = request.To ?? DateTime.UtcNow; - // Tenant isolation: regular users can only query their own tenant's audit records + // Tenant isolation: regular users can only query their own tenant's audit records. + // G-113: el internal-admin puede consultar un tenant específico (request.TenantId); SIN él, + // cae a su PROPIA organización — no a Guid.Empty, que filtraba RootTenantId==empty y devolvía + // 0 pese a existir registros (la escritura persiste; el query los ocultaba por el filtro vacío). var effectiveTenantId = (_tenantContext?.IsInternalAdmin == true) - ? (request.TenantId ?? Guid.Empty) + ? (request.TenantId ?? _tenantContext?.OrganizationId ?? Guid.Empty) : (_tenantContext?.OrganizationId ?? Guid.Empty); IReadOnlyList records; diff --git a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAuditRecordByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAuditRecordByIdQueryHandler.cs index 3fa98bd7..d080784f 100644 --- a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAuditRecordByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAuditRecordByIdQueryHandler.cs @@ -1,4 +1,5 @@ using Ums.Application.Audit.AuditRecord.DTOs; +using Ums.Application.Common.Interfaces; using Ums.Domain.Audit.AuditRecord; namespace Ums.Application.Audit.AuditRecord.Queries; @@ -6,10 +7,12 @@ namespace Ums.Application.Audit.AuditRecord.Queries; public sealed class GetAuditRecordByIdQueryHandler : IQueryHandler { private readonly IAuditRecordRepository _auditRecordRepository; + private readonly ITenantContext? _tenantContext; - public GetAuditRecordByIdQueryHandler(IAuditRecordRepository auditRecordRepository) + public GetAuditRecordByIdQueryHandler(IAuditRecordRepository auditRecordRepository, ITenantContext? tenantContext = null) { _auditRecordRepository = auditRecordRepository; + _tenantContext = tenantContext; } [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] @@ -25,6 +28,18 @@ public async Task> Handle( return Result.Failure("Audit record not found."); } + // G-040 (SEGURIDAD): aislamiento por inquilino. Un usuario regular solo puede + // leer registros de su propio inquilino; los admin internos ven todos. Se + // devuelve "not found" para no revelar la existencia de registros ajenos. + if (_tenantContext?.IsInternalAdmin != true) + { + var effectiveTenantId = _tenantContext?.OrganizationId; + if (effectiveTenantId is null || record.Props.RootTenantId != effectiveTenantId.Value) + { + return Result.Failure("Audit record not found."); + } + } + return Result.Success(new AuditRecordDto( record.Props.Id.GetValue(), record.Props.WhoActed, diff --git a/src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs b/src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs index 0b77f79e..60748d66 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs @@ -7,15 +7,22 @@ using Ums.Domain.Configuration.FeatureFlag; using Ums.Domain.Identity; using Ums.Domain.Identity.Auth; +using Ums.Domain.Authorization.SystemSuite.MenuNode; +using Microsoft.Extensions.Logging; +using SystemSuiteSummary = Ums.Domain.Authorization.SystemSuite.SystemSuiteSummary; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; using RoleAggregate = Ums.Domain.Authorization.Role.Role; using BranchEntity = Ums.Domain.Identity.Tenant.Branch.Branch; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; using FeatureFlagAggregate = Ums.Domain.Configuration.FeatureFlag.FeatureFlag; namespace Ums.Application.Authorization.Graph; +#pragma warning disable S125 + /// /// Builds the complete AuthorizationGraph for an authenticated user. /// @@ -36,56 +43,95 @@ public sealed class AuthorizationGraphBuilderService : IAuthorizationGraphBuilde private readonly IProfileRepository _profileRepo; private readonly IRoleRepository _roleRepo; private readonly ISystemSuiteRepository _suiteRepo; - private readonly IPermissionTemplateRepository _templateRepo; private readonly ITenantRepository _tenantRepo; private readonly IFeatureFlagRepository _featureFlagRepo; private readonly IFeatureFlagEvaluator _flagEvaluator; private readonly IConfigurationProvider _configProvider; + private readonly ILogger _logger; public AuthorizationGraphBuilderService( IProfileRepository profileRepo, IRoleRepository roleRepo, ISystemSuiteRepository suiteRepo, - IPermissionTemplateRepository templateRepo, ITenantRepository tenantRepo, IFeatureFlagRepository featureFlagRepo, IFeatureFlagEvaluator flagEvaluator, - IConfigurationProvider configProvider) + IConfigurationProvider configProvider, + ILogger logger) { _profileRepo = profileRepo; _roleRepo = roleRepo; _suiteRepo = suiteRepo; - _templateRepo = templateRepo; _tenantRepo = tenantRepo; _featureFlagRepo = featureFlagRepo; _flagEvaluator = flagEvaluator; _configProvider = configProvider; + _logger = logger; } public async Task> BuildAsync( UserAccountAggregate userAccount, Guid tenantId, AuthMethod authMethod, + string? systemCode = null, CancellationToken cancellationToken = default) - => await BuildInternalAsync(userAccount, tenantId, authMethod, null, cancellationToken); + => await BuildInternalAsync(userAccount, tenantId, authMethod, null, systemCode, cancellationToken); public async Task> BuildForProfileAsync( UserAccountAggregate userAccount, Guid tenantId, Guid profileId, AuthMethod authMethod, + string? systemCode = null, CancellationToken cancellationToken = default) - => await BuildInternalAsync(userAccount, tenantId, authMethod, profileId, cancellationToken); + // El perfil vigente NO lo elige el filtro —llega dado— pero el bloque `profiles` sí debe + // respetarlo: sin esto, un satélite acotado a un sistema recibiria al cambiar de perfil la + // lista de todos los sistemas en los que ese usuario trabaja (ADR-0156 §2.5). + => await BuildInternalAsync(userAccount, tenantId, authMethod, profileId, systemCode, cancellationToken); + /// + /// Mide la construcción y delega. La medida se toma aquí y no dentro del método para no + /// mezclar la instrumentación con los doce pasos de armado, y para que cubra también las + /// salidas por fallo: un grafo que tarda y falla es exactamente el que hay que ver. + /// private async Task> BuildInternalAsync( UserAccountAggregate userAccount, Guid tenantId, AuthMethod authMethod, Guid? profileIdOverride, + string? systemCode, + CancellationToken cancellationToken) + { + var cronometro = System.Diagnostics.Stopwatch.StartNew(); + var resultado = await ConstruirAsync( + userAccount, tenantId, authMethod, profileIdOverride, systemCode, cancellationToken); + cronometro.Stop(); + + // Cardinalidad acotada a propósito: sistema y rol, nunca usuario ni inquilino. + GraphMetrics.Duracion.Record( + cronometro.Elapsed.TotalMilliseconds, + new KeyValuePair("system", resultado.IsSuccess ? resultado.Value.Context.SystemSuite?.Code : "n/a"), + new KeyValuePair("role", resultado.IsSuccess ? resultado.Value.Context.Role?.Code : "n/a"), + new KeyValuePair("outcome", resultado.IsSuccess ? "ok" : "error")); + + return resultado; + } + + private async Task> ConstruirAsync( + UserAccountAggregate userAccount, + Guid tenantId, + AuthMethod authMethod, + Guid? profileIdOverride, + string? systemCode, CancellationToken cancellationToken) { var userId = userAccount.Props.Id.GetValue(); + // Normalización única del código pedido, para que el eco del contexto y la comparación del + // filtro no puedan divergir. Cadena vacía o en blanco == no se pidió sistema. + var sistemaPedido = string.IsNullOrWhiteSpace(systemCode) ? null : systemCode.Trim(); + var ecoSistema = sistemaPedido is null ? null : new GraphRequestedSystem(sistemaPedido); + // ── 1. Tenant ───────────────────────────────────────────────────────── var tenant = await _tenantRepo.GetByIdAsync(tenantId, cancellationToken); if (tenant is null) @@ -94,6 +140,50 @@ private async Task> BuildInternalAsync( // ── 2. Active profile for this user + tenant ─────────────────────────── ProfileAggregate? profile; + // Los perfiles activos del usuario en este inquilino se cargan SIEMPRE: alimentan el + // bloque `profiles` del grafo, que es lo que permite al cliente ofrecer el cambio de + // perfil sin otra llamada. La consulta ya se hacía antes; lo que no se hacía era + // aprovecharla (G-177). + var perfilesDelUsuario = await _profileRepo.GetActiveByUserAndTenantAsync( + userId, tenantId, cancellationToken); + + // Un rol pertenece a exactamente un sistema, así que los roles de esos perfiles traen + // consigo el nivel de jerarquía y el sistema. Una consulta por lote, no una por perfil. + var rolesDeLosPerfiles = (await _roleRepo.GetByIdsAsync( + perfilesDelUsuario.Select(p => p.Props.RoleId.GetValue()).Distinct().ToList(), + cancellationToken)) + .ToDictionary(r => r.GetId().GetValue()); + + // Los resúmenes de suite se resuelven AQUÍ y no dentro de `ConstruirPerfilesAsync`, que es + // donde vivían: el filtro por sistema los necesita antes de elegir perfil. No añade + // consultas — mueve una. + var suitesPorId = (await _suiteRepo.GetSummariesByIdsAsync( + rolesDeLosPerfiles.Values.Select(r => r.Props.SystemSuiteId.GetValue()).Distinct().ToList(), + cancellationToken)) + .ToDictionary(x => x.Id); + + // El código de suite de cada perfil se resuelve UNA vez: lo usan el filtro y el desempate, + // y su cálculo emite avisos cuando un perfil no resuelve. Recalcularlo dentro del + // comparador de la ordenación repetiría esos avisos tantas veces como comparaciones haga. + var suiteDelPerfil = perfilesDelUsuario.ToDictionary( + p => p.GetId().GetValue(), + p => CodigoDeSuiteDelPerfil(p, rolesDeLosPerfiles, suitesPorId)); + + // ── 2.b Filtro por sistema (ADR-0156 §4) ─────────────────────────────── + // + // El filtro se aplica SOBRE LOS PERFILES QUE EL USUARIO YA TIENE. Este camino NO consulta + // el catálogo de sistemas por código, nunca: es lo que hace que un código inexistente y un + // código existente sin perfil produzcan el mismo estado interno —lista vacía— y por tanto + // la misma respuesta, sin dos ramas que puedan divergir en un mensaje, un estado o un + // tiempo. Validar contra el catálogo y «devolver el mismo error» es la variante frágil: + // sobrevive hasta el primer refactor que añada un log distinto en cada rama. + var perfilesCandidatos = sistemaPedido is null + ? perfilesDelUsuario + : perfilesDelUsuario + .Where(p => suiteDelPerfil[p.GetId().GetValue()] is { } codigo + && string.Equals(codigo, sistemaPedido, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (profileIdOverride.HasValue) { profile = await _profileRepo.GetByIdAsync(profileIdOverride.Value, cancellationToken); @@ -114,13 +204,33 @@ private async Task> BuildInternalAsync( } else { - var profiles = await _profileRepo.GetByUserIdAsync(userId, cancellationToken); - profile = profiles.FirstOrDefault(p => - p.Props.TenantId.GetValue() == tenantId && p.IsActive); + profile = perfilesCandidatos + // Desempate explicable y estable: primero el rol de mayor jerarquía, luego el + // sistema y el rol por código. Antes se ordenaba por RoleId —un GUID—, así que + // ni el usuario ni el operador podían explicar por qué entró con ese perfil. + // + // G-211: el segundo criterio seguía ordenando por `SystemSuiteId`, otro GUID, pese + // a que el comentario decía «por código». Ahora ordena de verdad por el código de + // la suite, que el diccionario de resúmenes ya tiene resuelto: cero consultas más. + .OrderBy(p => rolesDeLosPerfiles.TryGetValue(p.Props.RoleId.GetValue(), out var r) ? r.Props.HierarchyLevel : int.MaxValue) + .ThenBy(p => suiteDelPerfil[p.GetId().GetValue()] ?? string.Empty, StringComparer.Ordinal) + .ThenBy(p => rolesDeLosPerfiles.TryGetValue(p.Props.RoleId.GetValue(), out var r) ? r.Props.Code.GetValue() : string.Empty, StringComparer.Ordinal) + .FirstOrDefault(); if (profile is null) - return Result.Failure( - "No active profile found for user in this tenant."); + { + // Dos ausencias distintas, y confundirlas le mostraría a un usuario dado de alta un + // flujo de alta (ADR-0156 §5.1): + // + // · SIN NINGÚN perfil en el inquilino → grafo LOBBY (G-043). El login no falla con + // un 401 opaco: el cliente recibe contexto real y muestra el onboarding. + // · CON perfiles pero ninguno en el sistema pedido → grafo SIN ACCESO. La cuenta + // está de alta; lo que no tiene es acceso a ESTE sistema. + return Result.Success( + perfilesDelUsuario.Count == 0 + ? BuildLobbyGraph(userAccount, tenant, authMethod, ecoSistema) + : BuildNoProfileInSystemGraph(userAccount, tenant, authMethod, ecoSistema)); + } } // ── 3. Role ─────────────────────────────────────────────────────────── @@ -133,13 +243,15 @@ private async Task> BuildInternalAsync( if (suite is null) return Result.Failure("SystemSuite not found for role."); - // ── 5. Published PermissionTemplate for this role+tenant ────────────── - var templates = await _templateRepo.GetByTenantIdAsync(tenantId, cancellationToken); - var template = templates - .Where(t => t.Props.RoleId.GetValue() == role.Props.Id.GetValue() - && t.Status == TemplateStatus.Published) - .OrderByDescending(t => t.Props.Audit.GetValue().CreatedAt) - .FirstOrDefault(); +// G-174: aquí se cargaban TODAS las plantillas del inquilino, con sus ítems, para + // quedarse con una que nunca se usaba: dos consultas y la rehidratación completa + // pagadas en cada login, cada refresco y cada preview, con un volumen que crece con + // el tamaño del inquilino y no con el del usuario. El permMap se construye desde los + // permisos del propio perfil (paso 8), no desde la plantilla. + // + // Cuando se cablee la plantilla como fuente de verdad (G-016), la consulta acotada ya + // existe: `IPermissionTemplateRepository.GetByTenantRoleSuiteAsync`. No reintroducir + // la carga por inquilino. // ── 6. Branch (if BranchScoped) ──────────────────────────────────────── BranchEntity? branch = profile.Props.BranchId is not null @@ -152,12 +264,21 @@ private async Task> BuildInternalAsync( a => a.GetId().GetValue(), a => (Code: a.Props.Code.GetValue(), Name: a.Props.Name.GetValue())); + // Segundo índice, por CÓDIGO. `BuildMenuAccess` resolvía la acción de cada opción con + // un FirstOrDefault sobre la lista dentro de un bucle de cuatro niveles: con 2.000 + // opciones y 200 acciones son cientos de miles de comparaciones de cadena por login, + // teniendo el diccionario ya construido cinco líneas más arriba (R-12). + var actionByCode = suite.Actions.ToDictionary( + a => a.Props.Code.GetValue(), + a => a.GetId().GetValue(), + StringComparer.Ordinal); + // ── 8. Resolve effective permission map ──────────────────────────────── - var permMap = BuildPermissionMap(profile, actionLookup); + var permMap = BuildPermissionMap(profile); // ── 9. Assemble graph sections ───────────────────────────────────────── var actions = BuildActions(suite); - var menuAccess = BuildMenuAccess(suite, permMap); + var menuAccess = BuildMenuAccess(suite, permMap, actionByCode); var domainPermissions = BuildDomainPermissions(suite, permMap, actionLookup); var featureFlags = await EvaluateFeatureFlagsAsync(suite, profile, role, branch, cancellationToken); var effectiveConfig = BuildEffectiveConfig(tenantId); @@ -196,7 +317,8 @@ private async Task> BuildInternalAsync( : new GraphBranch( branch.Props.Id.GetValue(), branch.Props.Code.GetValue(), - branch.Props.Name.GetValue())); + branch.Props.Name.GetValue()), + RequestedSystem: ecoSistema); // ── 11. Authentication node ──────────────────────────────────────────── GraphIdpProvider? idpProvider = authMethod.Provider is not null @@ -216,9 +338,18 @@ private async Task> BuildInternalAsync( IssuedAt: now, SessionExpiresAt: now.AddMinutes(sessionMinutes)); + // Se proyectan los CANDIDATOS, no todos los perfiles del usuario. Es una regla de + // seguridad, no de estética: con el grafo acotado a un sistema, mandar la lista completa + // le entregaría a ese satélite el inventario de los demás sistemas en los que el usuario + // trabaja — información del inquilino filtrándose a quien no la necesita (ADR-0156 §2.5). + var perfiles = ConstruirPerfiles( + perfilesCandidatos, rolesDeLosPerfiles, suitesPorId, tenant, profile.GetId().GetValue()); + var graph = AuthorizationGraph.Build( context, authentication, actions, menuAccess, - domainPermissions, featureFlags, effectiveConfig, scopes, now); + domainPermissions, featureFlags, effectiveConfig, scopes, now, + profiles: perfiles, + settings: ConstruirAjustes(suite)); return Result.Success(graph); } @@ -231,8 +362,7 @@ private async Task> BuildInternalAsync( /// private static Dictionary<(Guid TargetId, Guid ActionId), (AccessEffect Effect, PermissionSource Source)> BuildPermissionMap( - ProfileAggregate profile, - Dictionary actionLookup) + ProfileAggregate profile) { var map = new Dictionary<(Guid TargetId, Guid ActionId), (AccessEffect Effect, PermissionSource Source)>(); @@ -271,6 +401,130 @@ private static AccessEffect ResolveEffect(bool isAllowed, bool isDenied) // ── Section Builders ───────────────────────────────────────────────────── + /// + /// Código de la suite a la que pertenece el perfil, o null si su rol o su suite no + /// resuelven. El sistema NO sale del perfil —que no lo guarda— sino de su rol, que pertenece a + /// exactamente uno. + /// + private string? CodigoDeSuiteDelPerfil( + ProfileAggregate perfil, + Dictionary rolesPorId, + Dictionary suitesPorId) + { + if (!rolesPorId.TryGetValue(perfil.Props.RoleId.GetValue(), out var rol)) + { + // Con el filtro por sistema activo este descarte cambia de significado: deja de ser + // una fila que no se pinta y pasa a poder ser la diferencia entre «tienes acceso» y + // «no tienes acceso» (ADR-0156 §4.3). Callarlo dejaría al operador sin forma de + // explicar un `NoProfileInSystem` que no debería serlo. + _logger.LogWarning( + "Perfil {PerfilId} descartado del grafo: su rol {RolId} no resuelve.", + perfil.GetId().GetValue(), perfil.Props.RoleId.GetValue()); + return null; + } + + if (!suitesPorId.TryGetValue(rol.Props.SystemSuiteId.GetValue(), out var suite)) + { + _logger.LogWarning( + "Perfil {PerfilId} descartado del grafo: la suite {SuiteId} de su rol {RolId} no resuelve.", + perfil.GetId().GetValue(), rol.Props.SystemSuiteId.GetValue(), rol.GetId().GetValue()); + return null; + } + + return suite.Code; + } + + /// + /// Proyecta los perfiles candidatos del usuario para el selector del cliente. + /// + /// El sistema se resuelve por el rol —el perfil no lo guarda— desde los resúmenes de suite que + /// el llamante ya resolvió: cargar el agregado de cada suite costaría siete consultas por + /// suite para obtener dos cadenas. La sucursal sale del inquilino, que ya viene con sus + /// sucursales cargadas, así que no añade ninguna consulta. + /// + private static IReadOnlyList ConstruirPerfiles( + IReadOnlyList perfiles, + Dictionary rolesPorId, + Dictionary suitesPorId, + TenantAggregate tenant, + Guid perfilVigenteId) + { + if (perfiles.Count == 0) return []; + + var opciones = new List(perfiles.Count); + + foreach (var perfil in perfiles) + { + if (!rolesPorId.TryGetValue(perfil.Props.RoleId.GetValue(), out var rol)) continue; + if (!suitesPorId.TryGetValue(rol.Props.SystemSuiteId.GetValue(), out var suite)) continue; + + var sucursal = perfil.Props.BranchId is null + ? null + : tenant.Branches.FirstOrDefault(b => b.Props.Id.GetValue() == perfil.Props.BranchId.GetValue()); + + opciones.Add(new GraphProfileOption( + Id: perfil.GetId().GetValue(), + SystemCode: suite.Code, + SystemName: suite.Name, + RoleCode: rol.Props.Code.GetValue(), + RoleName: rol.Props.Value.GetValue(), + HierarchyLevel: rol.Props.HierarchyLevel, + BranchCode: sucursal?.Props.Code.GetValue(), + BranchName: sucursal?.Props.Name.GetValue(), + Scope: perfil.Scope.Name, + IsCurrent: perfil.GetId().GetValue() == perfilVigenteId)); + } + + // Mismo orden que el desempate del login: lo que el cliente pinta primero es lo que el + // servidor habría elegido. + return opciones + .OrderBy(o => o.HierarchyLevel) + .ThenBy(o => o.SystemCode, StringComparer.Ordinal) + .ThenBy(o => o.RoleCode, StringComparer.Ordinal) + .ToList(); + } + + /// + /// Ajustes del sistema visibles para el cliente, agrupados por espacio de nombres. + /// + /// Convención: la clave es `ESPACIO_RESTO` y se proyecta como `settings["espacio"]["resto"]`, + /// ambos en minúsculas. `BRAND_LOGO_URL` viaja como `settings.brand.logo_url`. Es una regla + /// mecánica y reversible a propósito: cualquier taxonomía cerrada en el código habría que + /// tocarla cada vez que el negocio quiera publicar algo nuevo, y el objetivo es justo lo + /// contrario — que añadir un ajuste no toque el contrato. + /// + /// Solo se proyecta lo marcado con `IsClientVisible` (G-178). Una clave sin espacio de nombres + /// —sin guion bajo— cae en `general`. + /// + private static IReadOnlyDictionary> ConstruirAjustes( + SystemSuiteAggregate suite) + { + var grupos = new Dictionary>(StringComparer.Ordinal); + + foreach (var ajuste in suite.AppSettings.Where(a => a.IsClientVisible)) + { + var clave = ajuste.Key.GetValue(); + var corte = clave.IndexOf('_'); + + var espacio = corte <= 0 ? "general" : clave[..corte].ToLowerInvariant(); + var resto = corte <= 0 ? clave.ToLowerInvariant() : clave[(corte + 1)..].ToLowerInvariant(); + + if (!grupos.TryGetValue(espacio, out var grupo)) + { + grupo = new Dictionary(StringComparer.Ordinal); + grupos[espacio] = grupo; + } + + grupo[resto] = ajuste.Value.GetValue(); + } + + return grupos.ToDictionary( + g => g.Key, + g => (IReadOnlyDictionary)g.Value, + StringComparer.Ordinal); + } + + private static IReadOnlyList BuildActions(SystemSuiteAggregate suite) => suite.Actions .Select(a => new GraphAction( @@ -280,64 +534,36 @@ private static IReadOnlyList BuildActions(SystemSuiteAggregate suit .OrderBy(a => a.Code) .ToList(); + /// + /// Proyecta la navegación ALCANZABLE de cada módulo, recorriendo el árbol de nodos a + /// cualquier profundidad (ADR-0090). + /// + /// Antes recorría exactamente tres niveles literales —Menu, luego SubMenu, luego Option— y + /// cualquier nodo fuera de ese patrón desaparecía sin dejar traza (G-171). Y emitía TODAS las + /// filas, incluidas las `NotGranted`: para un perfil restringido, más de la mitad del payload + /// era decir que no. + /// + /// Regla de poda, coherente con el fail-closed que ya regía: una hoja sin acciones concedidas + /// ni denegadas explícitamente no viaja; una rama que se queda sin hojas tampoco; un módulo + /// sin nodos, tampoco. Ausencia = no concedido. + /// private static IReadOnlyList BuildMenuAccess( SystemSuiteAggregate suite, - Dictionary<(Guid, Guid), (AccessEffect Effect, PermissionSource Source)> permMap) + Dictionary<(Guid, Guid), (AccessEffect Effect, PermissionSource Source)> permMap, + Dictionary actionByCode) { var modules = new List(); foreach (var module in suite.Modules.OrderBy(m => m.Props.SortOrder)) { - var menus = new List(); + var nodos = module.Nodes + .OrderBy(n => n.SortOrder) + .Select(n => ProyectarNodo(n, permMap, actionByCode)) + .Where(n => n is not null) + .Select(n => n!) + .ToList(); - foreach (var menu in module.Menus.OrderBy(m => m.Props.SortOrder)) - { - var subMenus = new List(); - - foreach (var sub in menu.SubMenus.OrderBy(s => s.Props.SortOrder)) - { - var options = new List(); - - foreach (var opt in sub.Options.OrderBy(o => o.Props.SortOrder)) - { - // Match action by ActionCode string (not by ActionId FK — Options reference by code) - var action = suite.Actions.FirstOrDefault(a => - a.Props.Code.GetValue() == opt.Props.ActionCode.GetValue()); - - var effect = AccessEffect.NotGranted; - var source = PermissionSource.Template; - - if (action is not null) - { - var key = (opt.Props.Id.GetValue(), action.GetId().GetValue()); - if (permMap.TryGetValue(key, out var perm)) - (effect, source) = perm; - } - - options.Add(new GraphMenuOption( - opt.Props.Id.GetValue(), - opt.Props.Code.GetValue(), - opt.Props.Label.GetValue(), - opt.Props.ActionCode.GetValue(), - effect, - source)); - } - - subMenus.Add(new GraphSubMenu( - sub.Props.Id.GetValue(), - sub.Props.Code.GetValue(), - sub.Props.Label.GetValue(), - sub.Props.SortOrder, - options)); - } - - menus.Add(new GraphMenu( - menu.Props.Id.GetValue(), - menu.Props.Code.GetValue(), - menu.Props.Label.GetValue(), - menu.Props.SortOrder, - subMenus)); - } + if (nodos.Count == 0) continue; modules.Add(new GraphMenuModule( module.Props.Id.GetValue(), @@ -345,36 +571,94 @@ private static IReadOnlyList BuildMenuAccess( module.Props.Name.GetValue(), module.Props.SortOrder, module.Props.Status?.Name ?? "Active", - menus)); + module.Props.Icon, + nodos)); } return modules; } + /// + /// Proyecta un nodo y su descendencia. Devuelve null si nada de ese subárbol es alcanzable. + /// + private static GraphNavigationNode? ProyectarNodo( + MenuNodeEntity nodo, + Dictionary<(Guid, Guid), (AccessEffect Effect, PermissionSource Source)> permMap, + Dictionary actionByCode) + { + var nodeId = nodo.GetId().GetValue(); + + // N:M (ADR-0090): una opción puede vincular varias acciones. Se conserva una entrada por + // acción con efecto explícito; las que no se conceden ni se deniegan, se omiten. + var acciones = nodo.ActionCodes + .Select(a => a.GetValue()) + .Select(actionCode => + { + if (!actionByCode.TryGetValue(actionCode, out var actionId)) return null; + if (!permMap.TryGetValue((nodeId, actionId), out var perm)) return null; + if (perm.Effect == AccessEffect.NotGranted) return null; + + return new GraphNodeAction(actionCode, perm.Effect, perm.Source); + }) + .Where(a => a is not null) + .Select(a => a!) + .ToList(); + + var hijos = nodo.Children + .OrderBy(n => n.SortOrder) + .Select(n => ProyectarNodo(n, permMap, actionByCode)) + .Where(n => n is not null) + .Select(n => n!) + .ToList(); + + // Ni acciones propias ni descendencia alcanzable: el nodo no existe para este perfil. + if (acciones.Count == 0 && hijos.Count == 0) return null; + + return new GraphNavigationNode( + nodeId, + nodo.Code.GetValue(), + nodo.Label.GetValue(), + nodo.Kind.ToString(), + nodo.SortOrder, + nodo.Props.Presentation.Icon, + nodo.Props.Presentation.Route, + acciones, + hijos); + } + + /// + /// Recursos de dominio con sus acciones RESUELTAS. + /// + /// Antes emitía el producto cartesiano completo —cada recurso por cada acción del sistema— + /// aunque la inmensa mayoría dijera `NotGranted`: sobre datos reales, 286 filas de las que un + /// perfil restringido usaba 22, y más de la mitad del payload del login. Ahora solo viajan las + /// acciones concedidas o denegadas explícitamente, y un recurso sin ninguna no viaja. La + /// ausencia sigue significando denegación (fail-closed, G-039): no se pierde información, se + /// deja de repetir en cada login lo que el contrato dice una vez. + /// private static IReadOnlyList BuildDomainPermissions( SystemSuiteAggregate suite, Dictionary<(Guid, Guid), (AccessEffect Effect, PermissionSource Source)> permMap, Dictionary actionLookup) { var result = new List(); + var accionesOrdenadas = actionLookup.OrderBy(kv => kv.Value.Code).ToList(); foreach (var resource in suite.DomainResources.OrderBy(r => r.Props.Code.GetValue())) { var domainActions = new List(); - foreach (var (actionId, (actionCode, actionName)) in actionLookup.OrderBy(kv => kv.Value.Code)) + foreach (var (actionId, (actionCode, actionName)) in accionesOrdenadas) { - var key = (resource.Props.Id.GetValue(), actionId); - permMap.TryGetValue(key, out var perm); + if (!permMap.TryGetValue((resource.Props.Id.GetValue(), actionId), out var perm)) continue; + if (perm.Effect == AccessEffect.NotGranted) continue; domainActions.Add(new GraphDomainAction( - actionId, - actionCode, - actionName, - perm.Effect, - perm.Source)); + actionId, actionCode, actionName, perm.Effect, perm.Source)); } + if (domainActions.Count == 0) continue; + result.Add(new GraphDomainPermission( resource.Props.Id.GetValue(), resource.Props.Type.Name, @@ -395,7 +679,7 @@ private async Task> EvaluateFeatureFlagsAsync( BranchEntity? branch, CancellationToken cancellationToken) { - var flags = await _featureFlagRepo.GetBySystemSuiteIdAsync( + var flags = await _featureFlagRepo.GetBySystemSuiteIdForEvaluationAsync( suite.Props.Id.GetValue(), cancellationToken); var ctx = new EvaluationContext( @@ -419,6 +703,131 @@ private async Task> EvaluateFeatureFlagsAsync( .ToList(); } + /// + /// G-043 — construye el GRAFO LOBBY para un usuario autenticado y aprobado que aún no tiene perfil + /// activo (onboarding pendiente). Trae Context de usuario+inquilino reales pero sin SystemSuite/Role/ + /// Profile, con Actions/MenuAccess/DomainPermissions/FeatureFlags/Scopes vacíos y OnboardingPending=true. + /// Así el login NO falla (evita el 401 AUTH_000 opaco) y el cliente puede mostrar el onboarding. + /// + private AuthorizationGraph BuildLobbyGraph( + UserAccountAggregate userAccount, TenantAggregate tenant, AuthMethod authMethod, + GraphRequestedSystem? ecoSistema = null) + { + var userId = userAccount.Props.Id.GetValue(); + var email = userAccount.Props.Email.GetValue(); + var effectiveConfig = BuildEffectiveConfig(tenant.Props.Id.GetValue()); + var now = DateTime.UtcNow; + + GraphIdpProvider? idpProvider = authMethod.Provider is not null + ? new GraphIdpProvider( + authMethod.Provider.GetId().GetValue(), + authMethod.Provider.Props.Name.GetValue(), + authMethod.Provider.Props.Code.GetValue(), + authMethod.Provider.Props.Strategy.Name) + : null; + + var context = new GraphContext( + User: new GraphUser( + userId, email, + userAccount.Props.IdentityReference?.GetValue() ?? email, + email, + userAccount.Props.Status.ToString()), + Tenant: new GraphTenant( + tenant.Props.Id.GetValue(), + tenant.Props.Code.GetValue(), + tenant.Props.Name.GetValue(), + tenant.Props.Status.ToString(), + tenant.IsManagementOwner), + SystemSuite: null, Role: null, Profile: null, Branch: null, + RequestedSystem: ecoSistema); + + var authentication = new GraphAuthentication( + Method: authMethod.Type.ToString(), + Provider: idpProvider, + MfaRequired: effectiveConfig.MfaRequiredForAdmin, + IssuedAt: now, + SessionExpiresAt: now.AddMinutes(effectiveConfig.SessionTimeoutMinutes)); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + now, + onboardingPending: true); + } + + /// + /// ADR-0156 §5.1 — grafo SIN ACCESO: el usuario tiene perfiles activos en el inquilino, pero + /// ninguno en el sistema que se pidió. O el sistema pedido no existe, que es el mismo estado + /// interno y por tanto la misma respuesta (§4.1). + /// + /// Se parece al lobby en la forma —contexto real, secciones vacías— y se diferencia en lo + /// único que importa: OnboardingPending = false. La cuenta está dada de alta; lo que no + /// tiene es acceso a ESTE sistema. Enseñarle un flujo de alta sería mentirle. + /// + /// El token se emite igualmente (§5.4): la identidad quedó probada y el satélite necesita un + /// portador para volver a consultar GET /client/graph más tarde — un administrador + /// puede asignarle el perfil sin que el usuario vuelva a teclear su contraseña. + /// + private AuthorizationGraph BuildNoProfileInSystemGraph( + UserAccountAggregate userAccount, TenantAggregate tenant, AuthMethod authMethod, + GraphRequestedSystem? ecoSistema) + { + var effectiveConfig = BuildEffectiveConfig(tenant.Props.Id.GetValue()); + var now = DateTime.UtcNow; + var email = userAccount.Props.Email.GetValue(); + + GraphIdpProvider? idpProvider = authMethod.Provider is not null + ? new GraphIdpProvider( + authMethod.Provider.GetId().GetValue(), + authMethod.Provider.Props.Name.GetValue(), + authMethod.Provider.Props.Code.GetValue(), + authMethod.Provider.Props.Strategy.Name) + : null; + + var context = new GraphContext( + User: new GraphUser( + userAccount.Props.Id.GetValue(), email, + userAccount.Props.IdentityReference?.GetValue() ?? email, + email, + userAccount.Props.Status.ToString()), + Tenant: new GraphTenant( + tenant.Props.Id.GetValue(), + tenant.Props.Code.GetValue(), + tenant.Props.Name.GetValue(), + tenant.Props.Status.ToString(), + tenant.IsManagementOwner), + SystemSuite: null, Role: null, Profile: null, Branch: null, + RequestedSystem: ecoSistema); + + var authentication = new GraphAuthentication( + Method: authMethod.Type.ToString(), + Provider: idpProvider, + MfaRequired: effectiveConfig.MfaRequiredForAdmin, + IssuedAt: now, + SessionExpiresAt: now.AddMinutes(effectiveConfig.SessionTimeoutMinutes)); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + now, + onboardingPending: false, + // `profiles` vacío a propósito: el usuario TIENE perfiles, pero ninguno de este + // sistema, y enumerárselos aquí sería entregarle a un satélite el inventario de los + // demás sistemas del inquilino — exactamente lo que el filtro evita (§2.5). + profiles: [], + accessState: GraphAccessState.NoProfileInSystem); + } + private GraphEffectiveConfig BuildEffectiveConfig(Guid tenantId) => new( SessionTimeoutMinutes: _configProvider.GetValueAs(AppConfigurationCodes.SessionTimeoutMinutes, tenantId, AppConfigurationDefaults.SessionTimeoutMinutes), @@ -435,16 +844,26 @@ private static IReadOnlyList DeriveScopes( { var scopes = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var module in menuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options.Where(o => o.Effect == AccessEffect.Allow)) - scopes.Add($"{opt.Code.ToLowerInvariant()}.{opt.ActionCode.ToLowerInvariant()}"); + foreach (var (nodo, accion) in menuAccess + .SelectMany(module => RecorrerNodos(module.Nodes)) + .SelectMany(n => n.Actions.Where(a => a.Effect == AccessEffect.Allow), (n, a) => (n, a))) + scopes.Add($"{nodo.Code.ToLowerInvariant()}.{accion.ActionCode.ToLowerInvariant()}"); - foreach (var res in domainPerms) - foreach (var act in res.Actions.Where(a => a.Effect == AccessEffect.Allow)) + foreach (var (res, act) in domainPerms + .SelectMany(res => res.Actions.Where(a => a.Effect == AccessEffect.Allow), + (res, act) => (res, act))) scopes.Add($"{res.ResourceCode.ToLowerInvariant()}.{act.ActionCode.ToLowerInvariant()}"); return scopes.OrderBy(s => s).ToList(); } + + /// Aplana el árbol de navegación en profundidad, sin presuponer niveles. + private static IEnumerable RecorrerNodos(IEnumerable nodos) + { + foreach (var nodo in nodos) + { + yield return nodo; + foreach (var hijo in RecorrerNodos(nodo.Children)) yield return hijo; + } + } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/Graph/GraphMetrics.cs b/src/apps/ums.api/Ums.Application/Authorization/Graph/GraphMetrics.cs new file mode 100644 index 00000000..e42947ba --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/Graph/GraphMetrics.cs @@ -0,0 +1,40 @@ +namespace Ums.Application.Authorization.Graph; + +using System.Diagnostics.Metrics; + +/// +/// Instrumentos del camino de construcción del grafo de autorización. +/// +/// El meter `UMS.Application` ya estaba registrado en el exportador OTel, pero sin un solo +/// instrumento: el tablero sabía cuánto tarda `POST /auth/login` y nada más. Sin desglose, decidir +/// dónde optimizar es adivinar — y la recomendación más cara del informe de arquitectura (cachear +/// el catálogo de la suite) depende justamente de saber si la suite domina el coste. +/// +/// Dos instrumentos, no más: +/// · — cuánto tarda construir el grafo, aislado del hash de la contraseña +/// y de la serialización. Es lo que responde «¿el desvío sobre el SLO es BCrypt o somos +/// nosotros?». +/// · — cuántos bytes se emiten. Es la métrica que hace visible el +/// crecimiento del catálogo, que es lo que realmente dimensiona el payload: un perfil sin +/// permisos recibe casi lo mismo que un administrador. +/// +/// El etiquetado es deliberadamente pobre en cardinalidad: código de sistema y de rol, nunca +/// identificadores de usuario o de inquilino. Una etiqueta por usuario multiplica las series +/// temporales por el número de usuarios y tumba al recolector de métricas. +/// +public static class GraphMetrics +{ + public const string MeterName = "UMS.Application"; + + private static readonly Meter Meter = new(MeterName); + + public static readonly Histogram Duracion = Meter.CreateHistogram( + "ums.auth_graph.build.duration", + unit: "ms", + description: "Tiempo de construcción del grafo de autorización, sin autenticación ni serialización."); + + public static readonly Histogram TamanoPayload = Meter.CreateHistogram( + "ums.auth_graph.payload.size", + unit: "By", + description: "Tamaño del grafo serializado que se entrega al cliente, antes de comprimir."); +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/Graph/PreviewProfileAuthGraphCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Graph/PreviewProfileAuthGraphCommandHandler.cs index dd8fab86..c2c314ed 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Graph/PreviewProfileAuthGraphCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Graph/PreviewProfileAuthGraphCommandHandler.cs @@ -66,7 +66,7 @@ public async Task> Handle( var methodResult = await methodResolver.ResolveAsync( tenantId, AuthAccessScope.InternalPreview, - cancellationToken); + cancellationToken: cancellationToken); if (methodResult.IsFailure) { @@ -76,8 +76,11 @@ public async Task> Handle( var authMethod = methodResult.Value; // ── 5. Build graph for the exact requested profile ──────────────────── + // Sin acotar por sistema: la vista previa es una herramienta de administración y su + // usuario quiere ver los perfiles del sujeto tal como son, no filtrados por el sistema + // desde el que mira. var graphResult = await graphBuilder.BuildForProfileAsync( - user, tenantId, command.ProfileId, authMethod, cancellationToken); + user, tenantId, command.ProfileId, authMethod, systemCode: null, cancellationToken); if (graphResult.IsFailure) { @@ -113,6 +116,10 @@ await RecordAuditAsync( AuthMethodUsed: authMethod.Type.ToString())); } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "'profileUserId' y 'requestId' se conservan como contexto de auditoría reservado; " + + "aún no se proyectan en AuthAuditEvent (pendiente de enriquecer el evento, ver G-016).")] private Task RecordAuditAsync( PreviewProfileAuthGraphCommand command, Guid tenantId, diff --git a/src/apps/ums.api/Ums.Application/Authorization/Graph/Serializers/AuthGraphPayload.cs b/src/apps/ums.api/Ums.Application/Authorization/Graph/Serializers/AuthGraphPayload.cs new file mode 100644 index 00000000..9968ed4b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/Graph/Serializers/AuthGraphPayload.cs @@ -0,0 +1,241 @@ +namespace Ums.Application.Authorization.Graph.Serializers; + +using Ums.Domain.Authorization.Graph; + +/// +/// Forma canónica del grafo de autorización tal como viaja al cliente, en un +/// único lugar. La consumen los dos emisores del grafo: el login web +/// (POST /api/v1/auth/login) y la autenticación de sistemas satélite +/// (POST /api/v1/client/authenticate). +/// +/// Existe porque hasta 2026-08-01 cada emisor tenía su propia forma y el +/// contrato publicado en src/libs/sdk/contracts/auth-graph.schema.json +/// no describía ninguna de las dos (G-167): el login serializaba el record de +/// dominio (con sortOrder, sin ids, resourceCode), el endpoint de +/// cliente proyectaba otro modelo (sin sortOrder, con ids opcionales, +/// code), y el SDK declaraba una tercera (ids obligatorios, +/// name/label, envoltorios module/resource). Un +/// satélite que integrara leyendo el contrato obtenía undefined en cada +/// nombre. La única forma de que esto no vuelva a divergir es que exista un +/// solo mapeador y que ambos endpoints pasen por él. +/// +/// Convenciones de la forma canónica: +/// +/// Todo nombre o etiqueta se expone como value. +/// Las jerarquías son listas planas: menuAccess es una lista de +/// módulos (sin envoltorio module) y domainPermissions una de +/// recursos (sin envoltorio resource). +/// Los identificadores técnicos (id, moduleId, +/// systemSuiteId…) son OPCIONALES: solo aparecen con +/// , que el +/// parámetro de inquilino AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA deja +/// en false por defecto. Se OMITEN, no se emiten como null: +/// un cliente distingue «no viene» de «viene vacío». +/// +/// +/// Se construye sobre y no sobre tipos +/// anónimos precisamente por la última regla: un tipo anónimo no puede omitir +/// una propiedad según una condición. +/// +public static class AuthGraphPayload +{ + /// Proyecta el grafo a su forma canónica de transporte. + public static IReadOnlyDictionary Build( + AuthorizationGraph graph, + GraphSerializationOptions? options = null) + { + var opts = options ?? GraphSerializationOptions.Default; + var meta = opts.IncludeTechnicalMetadata; + + return new Dictionary + { + ["schemaVersion"] = graph.SchemaVersion, + ["onboardingPending"] = graph.OnboardingPending, + // Enumeración cerrada (ADR-0156 §5.1). Convive con `onboardingPending`, que se + // conserva para los consumidores de 2.0–2.3; el dominio garantiza que no divergen. + ["accessState"] = graph.AccessState.ToString(), + ["context"] = Context(graph.Context, meta), + ["authentication"] = Authentication(graph.Authentication, meta), + ["actions"] = graph.Actions + .Select(a => Map(("code", (object?)a.Code), ("value", a.Name))) + .ToList(), + ["profiles"] = graph.Profiles.Select(Profile).ToList(), + ["menuAccess"] = graph.MenuAccess.Select(m => Module(m, meta)).ToList(), + ["domainPermissions"] = graph.DomainPermissions.Select(r => Resource(r, meta)).ToList(), + ["featureFlags"] = graph.FeatureFlags.Select(f => FeatureFlag(f, meta)).ToList(), + ["effectiveConfig"] = EffectiveConfig(graph.EffectiveConfig), + // Diccionario abierto por diseño: es el único punto del contrato donde añadir una + // clave nueva NO es un cambio de contrato (G-178). + ["settings"] = graph.Settings, + ["scopes"] = graph.Scopes, + ["generatedAt"] = graph.GeneratedAt.ToString("O"), + ["validUntil"] = graph.ValidUntil.ToString("O"), + }; + } + + private static Dictionary Map(params (string Key, object? Value)[] pairs) + { + var d = new Dictionary(pairs.Length); + foreach (var (key, value) in pairs) d[key] = value; + return d; + } + + /// Antepone el id técnico solo cuando el inquilino lo habilitó. + private static Dictionary WithId(bool meta, string name, Guid? id, Dictionary rest) + { + if (!meta || id is null) return rest; + + var d = new Dictionary { [name] = id.Value.ToString() }; + foreach (var (k, v) in rest) d[k] = v; + return d; + } + + private static object Context(GraphContext ctx, bool meta) => Map( + ("user", WithId(meta, "id", ctx.User.Id, Map( + ("email", (object?)ctx.User.Email), + ("username", ctx.User.Username), + ("value", ctx.User.DisplayName), + ("status", ctx.User.Status)))), + ("tenant", WithId(meta, "id", ctx.Tenant.Id, Map( + ("code", (object?)ctx.Tenant.Code), + ("value", ctx.Tenant.Name), + ("status", ctx.Tenant.Status), + ("isManagementOwner", ctx.Tenant.IsManagementOwner)))), + // G-043: en el grafo lobby (onboarding pendiente) suite, rol y perfil son + // null y viajan como null — el cliente reacciona a `onboardingPending`. + ("systemSuite", ctx.SystemSuite is null ? null : WithId(meta, "id", ctx.SystemSuite.Id, Map( + ("code", (object?)ctx.SystemSuite.Code), + ("value", ctx.SystemSuite.Name), + ("status", ctx.SystemSuite.Status)))), + ("role", ctx.Role is null ? null : WithId(meta, "id", ctx.Role.Id, Map( + ("code", (object?)ctx.Role.Code), + ("value", ctx.Role.Name), + ("hierarchyLevel", ctx.Role.HierarchyLevel)))), + ("profile", ctx.Profile is null ? null : WithId(meta, "id", ctx.Profile.Id, Map( + ("scope", (object?)ctx.Profile.Scope), + ("isActive", ctx.Profile.IsActive)))), + ("branch", ctx.Branch is null ? null : WithId(meta, "id", ctx.Branch.Id, Map( + ("code", (object?)ctx.Branch.Code), + ("value", ctx.Branch.Name)))), + // Eco literal del sistema pedido, ya normalizado, o null si no se pidió ninguno. Se emite + // SIEMPRE la clave: un cliente no debería tener que distinguir «no se pidió» de «la clave + // no existe en esta versión». + ("requestedSystem", ctx.RequestedSystem is null ? null : Map( + ("code", (object?)ctx.RequestedSystem.Code)))); + + private static object Authentication(GraphAuthentication auth, bool meta) => Map( + ("method", (object?)auth.Method), + ("provider", auth.Provider is null ? null : WithId(meta, "id", auth.Provider.Id, Map( + ("code", (object?)auth.Provider.Code), + ("name", auth.Provider.Name), + ("value", auth.Provider.Strategy)))), + ("mfaRequired", auth.MfaRequired), + ("issuedAt", auth.IssuedAt.ToString("O")), + ("sessionExpiresAt", auth.SessionExpiresAt.ToString("O"))); + + /// + /// Perfil disponible para el selector del cliente. `system` y `role` se anidan aunque en el + /// dominio viajen planos: es la forma en que el cliente los consume, y aplanarlos aquí + /// obligaría a cada consumidor a recomponerlos. + /// + /// El `id` se emite SIEMPRE, fuera de (ADR-0156 §5.3). Aquí no es un + /// metadato técnico decorativo como en módulos, nodos, recursos y banderas —donde el `code` es + /// la clave de negocio y la regla se mantiene intacta—: es la clave de una operación que el + /// propio grafo invita a ejecutar, `POST /client/switch-profile`. Un contrato que ofrece una + /// operación y retiene su clave no es un contrato. + /// + /// No se usa un selector semántico `{ systemCode, roleCode, branchCode }`: el índice + /// (TenantId, UserId, RoleId, BranchId) no es único, así que dos perfiles pueden coincidir en + /// los tres campos y el selector obligaría a un conflicto que el cliente no puede resolver. + /// + private static object Profile(GraphProfileOption p) => Map( + ("id", (object?)p.Id.ToString()), + ("system", Map(("code", (object?)p.SystemCode), ("value", p.SystemName))), + ("role", Map( + ("code", (object?)p.RoleCode), + ("value", p.RoleName), + ("hierarchyLevel", p.HierarchyLevel))), + ("branch", p.BranchCode is null ? null : Map( + ("code", (object?)p.BranchCode), + ("value", p.BranchName))), + ("scope", p.Scope), + ("isCurrent", p.IsCurrent)); + + private static object Module(GraphMenuModule m, bool meta) => WithId(meta, "id", m.Id, Map( + ("code", (object?)m.Code), + ("value", m.Name), + // `sortOrder` es dato de presentación, no metadato técnico: sin él un satélite no puede + // pintar el menú en el orden que el tenant configuró. + ("sortOrder", m.SortOrder), + ("status", m.Status), + // Mismo criterio que en el nodo: se emite siempre, también como null. Sin él el cliente + // sabe cómo se llama el módulo pero no con qué pintarlo, y acaba resolviendo el icono por + // código —que es exactamente la tabla estática que el grafo vino a eliminar (G-181). + ("icon", m.Icon), + ("nodes", m.Nodes.Select(n => Node(n, meta)).ToList()))); + + /// + /// Nodo de navegación, recursivo. `children` se emite siempre, aunque venga vacío: un cliente + /// que recorra el árbol no debería tener que distinguir entre «sin hijos» y «clave ausente». + /// + private static object Node(GraphNavigationNode n, bool meta) => WithId(meta, "id", n.Id, Map( + ("code", (object?)n.Code), + ("value", n.Name), + ("kind", n.Kind), + ("sortOrder", n.SortOrder), + // Se emiten siempre, también como null: un cliente que recorra el árbol no debería tener + // que distinguir «sin icono» de «clave ausente». + ("icon", n.Icon), + ("route", n.Route), + ("actions", n.Actions.Select(a => Map( + ("actionCode", (object?)a.ActionCode), + ("effect", a.Effect.ToString()), + ("source", a.Source.ToString()))).ToList()), + ("children", n.Children.Select(h => Node(h, meta)).ToList()))); + + private static object Resource(GraphDomainPermission res, bool meta) + { + var body = Map( + ("resourceType", (object?)res.ResourceType), + // `resourceCode` y no `code`: el mismo dato se llamaba de dos formas + // según el endpoint que lo emitiera (G-167). + ("resourceCode", res.ResourceCode), + ("value", res.ResourceName), + ("actions", res.Actions.Select(a => Map( + ("actionCode", (object?)a.ActionCode), + ("value", a.ActionName), + ("effect", a.Effect.ToString()), + ("source", a.Source.ToString()))).ToList())); + + if (!meta) return body; + + var d = new Dictionary { ["resourceId"] = res.ResourceId.ToString() }; + foreach (var (k, v) in body) d[k] = v; + d["moduleId"] = res.ModuleId?.ToString(); + d["parentResourceId"] = res.ParentResourceId?.ToString(); + return d; + } + + private static object FeatureFlag(GraphFeatureFlag flag, bool meta) + { + var body = Map( + ("flagCode", (object?)flag.FlagCode), + ("isEnabled", flag.IsEnabled), + ("matchedCriteriaType", flag.MatchedCriteriaType)); + + return meta + ? WithId(true, "systemSuiteId", flag.SystemSuiteId, body) + : body; + } + + private static object EffectiveConfig(GraphEffectiveConfig cfg) => Map( + ("sessionTimeoutMinutes", (object?)cfg.SessionTimeoutMinutes), + ("maxLoginAttempts", cfg.MaxLoginAttempts), + ("minPasswordLength", cfg.MinPasswordLength), + ("mfaRequiredForAdmin", cfg.MfaRequiredForAdmin), + // Faltaba en la proyección del endpoint de cliente: el satélite no podía + // saber qué métodos MFA acepta el inquilino. + ("mfaAllowedMethods", cfg.MfaAllowedMethods), + ("accessTokenDurationMs", cfg.AccessTokenDurationMs), + ("authUseExternalIdp", cfg.AuthUseExternalIdp)); +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/Profile/Commands/CreateProfileCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Profile/Commands/CreateProfileCommandHandler.cs index 4e6a0816..8beddfc1 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Profile/Commands/CreateProfileCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Profile/Commands/CreateProfileCommandHandler.cs @@ -75,6 +75,27 @@ public async Task> Handle( "Role does not belong to the requested tenant."); } + // Un perfil por (usuario, rol, sucursal) activo. Sin esta guarda, reejecutar un + // aprovisionamiento —o pulsar dos veces— dejaba al usuario con perfiles duplicados: la + // plantilla se asigna a UNO, así que el otro queda con cero concesiones y el selector los + // ofrece indistinguibles. Entrar por el vacío es entrar sin permisos, y todo respondió 201. + // + // Se comprueba sobre los perfiles ACTIVOS: uno desactivado es historia, y volver a dar de + // alta el mismo rol tras retirarlo es legítimo. + var perfilesDelUsuario = await _profileRepository.GetActiveByUserAndTenantAsync( + request.UserId, request.TenantId, cancellationToken); + + // `is { Count: > 0 }` y no `.Any()` a secas: el contrato declara la lista no nula, pero un + // doble de prueba que no configure esta consulta devuelve null, y una guarda que revienta + // cuando su colaborador calla es peor que la ausencia de guarda. + if (perfilesDelUsuario is { Count: > 0 } && perfilesDelUsuario.Any(p => + p.RoleId.GetValue() == request.RoleId && + p.BranchId?.GetValue() == request.BranchId)) + { + return Result.Failure( + DomainErrors.Authorization.ProfileAlreadyExistsForRole); + } + var profileResult = Profile.Create( TenantId.Load(request.TenantId), UserId.Load(request.UserId), @@ -89,16 +110,27 @@ public async Task> Handle( var profile = profileResult.Value; + // G-043 (materialización de permisos): la plantilla auto-asignada por regla se materializa + // ANTES de persistir, en la MISMA unidad de trabajo del agregado Profile (un agregado por tx). + // Antes el perfil se guardaba primero y la asignación se intentaba en un segundo Save cuyo + // fallo se tragaba (permissionCount=0 sin señal). Ahora los permisos se incorporan al agregado + // y se persisten en un único Save; si la materialización falla, se aborta y se desenmascara el + // error (Result.Failure con código) en vez de crear un perfil sin permisos silenciosamente. + var materializeResult = await MaterializeAutoAssignedTemplateAsync( + profile, request.TenantId, request.RoleId, cancellationToken); + if (materializeResult.IsFailure) + { + return Result.Failure(materializeResult.Error); + } + await _profileRepository.AddAsync(profile, cancellationToken); await _profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - await TryAutoAssignTemplateAsync(profile, request.TenantId, request.RoleId, cancellationToken); - return Result.Success( new CreateProfileResponse(profile.Props.Id.GetValue())); } - private async Task TryAutoAssignTemplateAsync( + private async Task MaterializeAutoAssignedTemplateAsync( Profile profile, Guid tenantId, Guid roleId, @@ -110,19 +142,26 @@ private async Task TryAutoAssignTemplateAsync( var topRule = matchingRules?.FirstOrDefault(); if (topRule is null) { - return; + // No hay regla de auto-asignación activa para (tenant, role): el perfil se crea sin + // plantilla. Es un resultado LEGÍTIMO —no todo rol tiene regla— y no debe fallar. + return Result.Success(); } var template = await _templateRepository.GetByIdAsync(topRule.TemplateId.GetValue(), cancellationToken); if (template is null) { - return; + // La regla referencia una plantilla inexistente: es una inconsistencia de configuración + // que DEBE aflorar (antes: return silencioso → permissionCount=0 sin diagnóstico). + return Result.Failure( + $"PROFILE_TEMPLATE_MISSING: la regla de asignación {topRule.Props.Id.GetValue()} referencia una plantilla inexistente ({topRule.TemplateId.GetValue()})."); } var assignResult = profile.AssignTemplate(template, ActorId.Create(_userContext.UserId)); if (assignResult.IsFailure) { - return; + // Desenmascarar: antes el fallo de AssignTemplate se tragaba y el perfil quedaba sin + // permisos. Ahora se propaga el error de dominio con un código estable. + return Result.Failure($"PROFILE_TEMPLATE_ASSIGN_FAILED: {assignResult.Error}"); } profile.DomainEvents.RaiseEvent(new TemplateAutoAssignedEvent( @@ -130,7 +169,6 @@ private async Task TryAutoAssignTemplateAsync( template.Props.Id.GetValue(), topRule.Props.Id.GetValue())); - await _profileRepository.UpdateAsync(profile, cancellationToken); - await _profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetAllProfilesQueryHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetAllProfilesQueryHandler.cs index ad9f83ca..d8704197 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetAllProfilesQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetAllProfilesQueryHandler.cs @@ -7,6 +7,8 @@ namespace Ums.Application.Authorization.Profile.Queries; +#pragma warning disable S125 + public sealed class GetAllProfilesQueryHandler : IQueryHandler> { private readonly IProfileRepository _profileRepository; @@ -63,14 +65,21 @@ public async Task>> Handle( ? ctxTenantId : (Guid?)null); } - var profiles = request.UserId.HasValue - ? await _profileRepository.GetByUserIdAsync(request.UserId.Value, cancellationToken) - : effectiveTenantId.HasValue - ? await _profileRepository.GetByTenantIdAsync(effectiveTenantId.Value, cancellationToken) - : await _profileRepository.GetAllAsync(effectiveTenantId, cancellationToken); + IReadOnlyList profiles; + if (request.UserId.HasValue) + { + profiles = await _profileRepository.GetByUserIdAsync(request.UserId.Value, cancellationToken); + } + else if (effectiveTenantId.HasValue) + { + profiles = await _profileRepository.GetByTenantIdAsync(effectiveTenantId.Value, cancellationToken); + } + else + { + profiles = await _profileRepository.GetAllAsync(effectiveTenantId, cancellationToken); + } var allTenants = await _tenantRepository.GetAllAsync(null, cancellationToken); - var profileRoleIds = profiles.Select(p => p.Props.RoleId.GetValue()).Distinct().ToList(); var profileTenantIds = profiles.Select(p => p.Props.TenantId.GetValue()).Distinct().ToList(); var allRoles = new List(); diff --git a/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetProfileByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetProfileByIdQueryHandler.cs index 02d23ed5..998acc95 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetProfileByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetProfileByIdQueryHandler.cs @@ -6,6 +6,7 @@ using Ums.Domain.Authorization.Template.PermissionTemplateItem; using Ums.Domain.Identity; using Ums.Domain.Identity.UserAccount; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; namespace Ums.Application.Authorization.Profile.Queries; @@ -145,26 +146,21 @@ private static Dictionary BuildTargetNameLookup(SystemSuiteAggrega lookup[suite.GetId().GetValue()] = suite.Props.Name.GetValue(); - foreach (var module in suite.Modules) + void WalkNodes(IEnumerable nodes) { - lookup[module.Props.Id.GetValue()] = module.Name.GetValue(); - - foreach (var menu in module.Menus) + foreach (var node in nodes) { - lookup[menu.Props.Id.GetValue()] = menu.Label.GetValue(); - - foreach (var subMenu in menu.SubMenus) - { - lookup[subMenu.Props.Id.GetValue()] = subMenu.Label.GetValue(); - - foreach (var option in subMenu.Options) - { - lookup[option.Props.Id.GetValue()] = option.Label.GetValue(); - } - } + lookup[node.GetId().GetValue()] = node.Label.GetValue(); + WalkNodes(node.Children); } } + foreach (var module in suite.Modules) + { + lookup[module.Props.Id.GetValue()] = module.Name.GetValue(); + WalkNodes(module.Nodes); + } + foreach (var resource in suite.DomainResources) { lookup[resource.Id.GetValue()] = resource.Name.GetValue(); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs index 73cc33c5..a06b311e 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs @@ -2,6 +2,8 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using Ums.Application.Authorization.SystemSuite.DTOs; + public sealed record AddDomainResourceCommand( Guid SystemSuiteId, Guid? ModuleId, @@ -9,4 +11,4 @@ public sealed record AddDomainResourceCommand( string Type, string Code, string Name, - string Description) : ICommand; + string Description) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs index 0e495cd0..a73217d2 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs @@ -7,22 +7,23 @@ using Ums.Domain.Kernel; using Ums.Domain.Kernel.ValueObjects; using Ums.Application.Common; +using Ums.Application.Authorization.SystemSuite.DTOs; namespace Ums.Application.Authorization.SystemSuite.Commands; public sealed class AddDomainResourceCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - : ICommandHandler + : ICommandHandler { - public async Task Handle(AddDomainResourceCommand request, CancellationToken cancellationToken) + public async Task> Handle(AddDomainResourceCommand request, CancellationToken cancellationToken) { var suite = await repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); if (suite is null) { - return Result.Failure(DomainErrors.Common.NotFound); + return Result.Failure(DomainErrors.Common.NotFound); } var type = DomainEnumerationParser.FromName(request.Type); - if (type is null) return Result.Failure($"Invalid DomainResourceType: {request.Type}"); + if (type is null) return Result.Failure($"Invalid DomainResourceType: {request.Type}"); var moduleId = request.ModuleId.HasValue ? ModuleId.Load(request.ModuleId.Value) : null; var parentId = request.ParentResourceId.HasValue ? IdValueObject.Load(request.ParentResourceId.Value) : null; @@ -38,12 +39,12 @@ public async Task Handle(AddDomainResourceCommand request, CancellationT if (result.IsFailure) { - return result; + return Result.Failure(result.Error); } await repository.UpdateAsync(suite, cancellationToken); await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); + return Result.Success(new AddDomainResourceResponse(result.Value)); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommand.cs deleted file mode 100644 index af7c9875..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record AddMenuCommand( - Guid SystemSuiteId, - Guid ModuleId, - string Code, - string Label, - string Description, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandHandler.cs deleted file mode 100644 index 7114b2b7..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class AddMenuCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public AddMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(AddMenuCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.AddMenu( - IdValueObject.Load(request.ModuleId), - Code.Create(request.Code), - Name.Create(request.Label), - Description.Create(request.Description), - request.SortOrder, - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandValidator.cs deleted file mode 100644 index 0211fca1..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandValidator.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class AddMenuCommandValidator : AbstractValidator -{ - public AddMenuCommandValidator() - { - RuleLevelCascadeMode = CascadeMode.Stop; - - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - - RuleFor(x => x.Code) - .NotEmpty().WithMessage("Menu code is required.") - .MaximumLength(50).WithMessage("Menu code must not exceed 50 characters.") - .Matches(@"^[A-Za-z0-9_]+$").WithMessage("Menu code may only contain letters, digits, and underscores."); - - RuleFor(x => x.Label) - .NotEmpty().WithMessage("Menu label is required.") - .MaximumLength(150).WithMessage("Menu label must not exceed 150 characters."); - - RuleFor(x => x.Description) - .MaximumLength(500).WithMessage("Description must not exceed 500 characters."); - - RuleFor(x => x.SortOrder) - .GreaterThan(0).WithMessage("Sort order must be greater than zero."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommand.cs index 0f7d8a8b..12419027 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommand.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommand.cs @@ -1,8 +1,10 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using Ums.Application.Authorization.SystemSuite.DTOs; + public sealed record AddModuleCommand( Guid SystemSuiteId, string Code, string Name, string Description, - int SortOrder) : ICommand; + int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommandHandler.cs index c4d95a61..d6b540ee 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommandHandler.cs @@ -1,8 +1,9 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using Ums.Application.Authorization.SystemSuite.DTOs; using Ums.Domain.Authorization; -public sealed class AddModuleCommandHandler : ICommandHandler +public sealed class AddModuleCommandHandler : ICommandHandler { private readonly ISystemSuiteRepository _repository; private readonly IUserContext _userContext; @@ -15,13 +16,13 @@ public AddModuleCommandHandler(ISystemSuiteRepository repository, IUserContext u [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(AddModuleCommand request, CancellationToken cancellationToken) + public async Task> Handle(AddModuleCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); + return Result.Failure("Authenticated user is required."); var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); + if (suite is null) return Result.Failure("System suite not found."); var result = suite.AddModule( Code.Create(request.Code), @@ -30,10 +31,10 @@ public async Task Handle(AddModuleCommand request, CancellationToken can request.SortOrder, ActorId.Create(_userContext.UserId)); - if (result.IsFailure) return result; + if (result.IsFailure) return Result.Failure(result.Error); await _repository.UpdateAsync(suite, cancellationToken); await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); + return Result.Success(new AddModuleResponse(result.Value)); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddNodeCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddNodeCommand.cs new file mode 100644 index 00000000..f3018e79 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddNodeCommand.cs @@ -0,0 +1,89 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using FluentValidation; +using Ums.Application.Authorization.SystemSuite.DTOs; +using Ums.Domain.Authorization; +using Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// Alta de un nodo del árbol recursivo (ADR-0090). ParentNodeId nulo ⇒ nodo raíz del módulo. +public sealed record AddNodeCommand( + Guid SystemSuiteId, + Guid ModuleId, + Guid? ParentNodeId, + string Kind, + string Code, + string Label, + string Description, + int SortOrder, + string? Icon = null, + string? Route = null) : ICommand; + +public sealed class AddNodeCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public AddNodeCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task> Handle(AddNodeCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + if (!Enum.TryParse(request.Kind, ignoreCase: true, out var kind)) + return Result.Failure($"Invalid node kind '{request.Kind}'."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var actor = ActorId.Create(_userContext.UserId); + var moduleId = IdValueObject.Load(request.ModuleId); + var code = Code.Create(request.Code); + var label = Name.Create(request.Label); + var description = Description.Create(request.Description); + + var presentation = MenuNodePresentation.Create(request.Icon, request.Route); + + var result = request.ParentNodeId is { } parentId + ? suite.AddModuleChildNode(moduleId, IdValueObject.Load(parentId), kind, code, label, description, request.SortOrder, actor, presentation: presentation) + : suite.AddModuleRootNode(moduleId, kind, code, label, description, request.SortOrder, actor, presentation: presentation); + + if (result.IsFailure) return Result.Failure(result.Error); + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(new AddNodeResponse(result.Value)); + } + + +public sealed class AddNodeCommandValidator : AbstractValidator +{ + public AddNodeCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.Kind).NotEmpty().WithMessage("Node kind is required."); + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Node code is required.") + .MaximumLength(100) + .Matches(@"^[A-Za-z0-9_]+$").WithMessage("Node code may only contain letters, digits, and underscores."); + RuleFor(x => x.Label).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).MaximumLength(1000); + RuleFor(x => x.SortOrder).GreaterThan(0); + RuleFor(x => x.Icon).MaximumLength(64); + // La ruta es relativa: una absoluta permitiría que un menú llevara a otro dominio. + RuleFor(x => x.Route) + .MaximumLength(400) + .Must(r => string.IsNullOrWhiteSpace(r) || r.StartsWith('/')) + .WithMessage("La ruta debe ser relativa y empezar por '/'."); + } +} +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommand.cs deleted file mode 100644 index bfcfdef5..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record AddOptionCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - Guid SubMenuId, - string Code, - string Label, - string Description, - string ActionCode, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandHandler.cs deleted file mode 100644 index 9ed994d2..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class AddOptionCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public AddOptionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(AddOptionCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.AddOption( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - Code.Create(request.Code), - Name.Create(request.Label), - Description.Create(request.Description), - ActionCode.Create(request.ActionCode), - request.SortOrder, - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandValidator.cs deleted file mode 100644 index 1b9106d4..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandValidator.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class AddOptionCommandValidator : AbstractValidator -{ - public AddOptionCommandValidator() - { - RuleLevelCascadeMode = CascadeMode.Stop; - - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - RuleFor(x => x.SubMenuId).NotEmpty(); - - RuleFor(x => x.Code) - .NotEmpty().WithMessage("Option code is required.") - .MaximumLength(50).WithMessage("Option code must not exceed 50 characters.") - .Matches(@"^[A-Za-z0-9_]+$").WithMessage("Option code may only contain letters, digits, and underscores."); - - RuleFor(x => x.Label) - .NotEmpty().WithMessage("Option label is required.") - .MaximumLength(150).WithMessage("Option label must not exceed 150 characters."); - - RuleFor(x => x.Description) - .MaximumLength(500).WithMessage("Description must not exceed 500 characters."); - - RuleFor(x => x.ActionCode) - .NotEmpty().WithMessage("Action code is required.") - .MaximumLength(50).WithMessage("Action code must not exceed 50 characters."); - - RuleFor(x => x.SortOrder) - .GreaterThan(0).WithMessage("Sort order must be greater than zero."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommand.cs deleted file mode 100644 index c6fe65ae..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record AddSubMenuCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - string Code, - string Label, - string Description, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandValidator.cs deleted file mode 100644 index 1ef73b64..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandValidator.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class AddSubMenuCommandValidator : AbstractValidator -{ - public AddSubMenuCommandValidator() - { - RuleLevelCascadeMode = CascadeMode.Stop; - - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - - RuleFor(x => x.Code) - .NotEmpty().WithMessage("SubMenu code is required.") - .MaximumLength(50).WithMessage("SubMenu code must not exceed 50 characters.") - .Matches(@"^[A-Za-z0-9_]+$").WithMessage("SubMenu code may only contain letters, digits, and underscores."); - - RuleFor(x => x.Label) - .NotEmpty().WithMessage("SubMenu label is required.") - .MaximumLength(150).WithMessage("SubMenu label must not exceed 150 characters."); - - RuleFor(x => x.Description) - .MaximumLength(500).WithMessage("Description must not exceed 500 characters."); - - RuleFor(x => x.SortOrder) - .GreaterThan(0).WithMessage("Sort order must be greater than zero."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommand.cs new file mode 100644 index 00000000..977572f7 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommand.cs @@ -0,0 +1,10 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +/// +/// Elimina LÓGICAMENTE un sistema ya archivado y sin referencias vivas (G-246). +/// +/// El archivado previo se hace con Deprecated. +/// La fila permanece en la base —el catálogo se consulta sobre datos antiguos—; lo que cambia es que +/// deja de aparecer en cualquier lectura. +/// +public sealed record DeleteSystemSuiteCommand(Guid SystemSuiteId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommandHandler.cs new file mode 100644 index 00000000..84c970bf --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommandHandler.cs @@ -0,0 +1,76 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using Ums.Domain.Authorization; +using Ums.Domain.Kernel; + +/// +/// Elimina lógicamente un sistema del catálogo (G-246). +/// +/// Orden deliberado: primero se comprueba el ámbito de gestión —dar de baja un sistema es al menos +/// tan sensible como darlo de alta, así que exige el mismo permiso que CreateSystemSuite—, +/// luego se cuentan las referencias vivas y solo entonces se pregunta al dominio. El dominio dicta +/// la regla; esta capa se limita a traducir su negativa al 409 estructurado que ya usan el resto de +/// guardas de dependencias. +/// +/// La escritura sale por UpdateAsync, igual que un cambio de estado cualquiera, porque eso es +/// exactamente lo que es: la fila se queda, con su estado en Deleted. No hay —ni debe haber— +/// una llamada de borrado en el repositorio. +/// +public sealed class DeleteSystemSuiteCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _systemSuiteRepository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public DeleteSystemSuiteCommandHandler( + ISystemSuiteRepository systemSuiteRepository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _systemSuiteRepository = systemSuiteRepository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteSystemSuiteCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to delete a system suite."); + } + + // GetByIdAsync ya no ve los sistemas eliminados (filtro global): repetir el DELETE sobre uno + // ya eliminado devuelve 404, que es lo coherente con que haya desaparecido de las lecturas. + var systemSuite = await _systemSuiteRepository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (systemSuite is null) + { + return Result.Failure(DomainErrors.Common.NotFound); + } + + var scopeResult = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync( + systemSuite.TenantId.GetValue(), cancellationToken); + if (scopeResult.IsFailure) + { + return scopeResult; + } + + var dependents = await _systemSuiteRepository.GetDependentsAsync(request.SystemSuiteId, cancellationToken); + + var result = systemSuite.Delete(dependents, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + // El detalle de QUÉ bloquea es formato de respuesta, no regla de negocio: el dominio + // devuelve el código con nombre y aquí se le adjunta el desglose que verá el llamador. + return result.Error == DomainErrors.Authorization.SystemSuiteHasDependents + ? Result.Failure(BlockedOperationError.Encode(result.Error, dependents.ToBlockingDependencies())) + : result; + } + + await _systemSuiteRepository.UpdateAsync(systemSuite, cancellationToken); + await _systemSuiteRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/NodeActionCommands.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/NodeActionCommands.cs new file mode 100644 index 00000000..5c87305b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/NodeActionCommands.cs @@ -0,0 +1,106 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using FluentValidation; +using Ums.Domain.Authorization; + +// ── Vincular funcionalidad (N:M) a un nodo hoja (Opción) ── + +public sealed record LinkNodeActionCommand(Guid SystemSuiteId, Guid ModuleId, Guid NodeId, string ActionCode) : ICommand; + +public sealed class LinkNodeActionCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public LinkNodeActionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(LinkNodeActionCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var result = suite.LinkModuleNodeAction( + IdValueObject.Load(request.ModuleId), + IdValueObject.Load(request.NodeId), + Domain.Kernel.ValueObjects.ActionCode.Create(request.ActionCode), + ActorId.Create(_userContext.UserId)); + + if (result.IsFailure) return result; + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); + } +} + +public sealed class LinkNodeActionCommandValidator : AbstractValidator +{ + public LinkNodeActionCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + RuleFor(x => x.ActionCode).NotEmpty().MaximumLength(100); + } +} + +// ── Desvincular funcionalidad ── + +public sealed record UnlinkNodeActionCommand(Guid SystemSuiteId, Guid ModuleId, Guid NodeId, string ActionCode) : ICommand; + +public sealed class UnlinkNodeActionCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public UnlinkNodeActionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(UnlinkNodeActionCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var result = suite.UnlinkModuleNodeAction( + IdValueObject.Load(request.ModuleId), + IdValueObject.Load(request.NodeId), + Domain.Kernel.ValueObjects.ActionCode.Create(request.ActionCode), + ActorId.Create(_userContext.UserId)); + + if (result.IsFailure) return result; + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); + } +} + +public sealed class UnlinkNodeActionCommandValidator : AbstractValidator +{ + public UnlinkNodeActionCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + RuleFor(x => x.ActionCode).NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommand.cs index 960c1945..361eb6a8 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommand.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommand.cs @@ -1,3 +1,5 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; -public sealed record RegisterActionCommand(Guid SystemSuiteId, string Code, string Name) : ICommand; +using Ums.Application.Authorization.SystemSuite.DTOs; + +public sealed record RegisterActionCommand(Guid SystemSuiteId, string Code, string Name) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommandHandler.cs index 772cb62b..543a5adf 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommandHandler.cs @@ -1,8 +1,9 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using Ums.Application.Authorization.SystemSuite.DTOs; using Ums.Domain.Authorization; -public sealed class RegisterActionCommandHandler : ICommandHandler +public sealed class RegisterActionCommandHandler : ICommandHandler { private readonly ISystemSuiteRepository _repository; private readonly IUserContext _userContext; @@ -15,23 +16,23 @@ public RegisterActionCommandHandler(ISystemSuiteRepository repository, IUserCont [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(RegisterActionCommand request, CancellationToken cancellationToken) + public async Task> Handle(RegisterActionCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); + return Result.Failure("Authenticated user is required."); var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); + if (suite is null) return Result.Failure("System suite not found."); var result = suite.RegisterAction( ActionCode.Create(request.Code), Name.Create(request.Name), ActorId.Create(_userContext.UserId)); - if (result.IsFailure) return result; + if (result.IsFailure) return Result.Failure(result.Error); await _repository.UpdateAsync(suite, cancellationToken); await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); + return Result.Success(new RegisterActionResponse(result.Value, request.Code)); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommand.cs deleted file mode 100644 index 3bee5b5e..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommand.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record RemoveMenuCommand(Guid SystemSuiteId, Guid ModuleId, Guid MenuId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommandHandler.cs deleted file mode 100644 index f51a68f7..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommandHandler.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class RemoveMenuCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public RemoveMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(RemoveMenuCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.RemoveMenu( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveNodeCommand.cs similarity index 59% rename from src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandHandler.cs rename to src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveNodeCommand.cs index c3edc2fc..eb062e6b 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveNodeCommand.cs @@ -1,13 +1,16 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using FluentValidation; using Ums.Domain.Authorization; -public sealed class UpdateMenuCommandHandler : ICommandHandler +public sealed record RemoveNodeCommand(Guid SystemSuiteId, Guid ModuleId, Guid NodeId) : ICommand; + +public sealed class RemoveNodeCommandHandler : ICommandHandler { private readonly ISystemSuiteRepository _repository; private readonly IUserContext _userContext; - public UpdateMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + public RemoveNodeCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) { _repository = repository; _userContext = userContext; @@ -15,7 +18,7 @@ public UpdateMenuCommandHandler(ISystemSuiteRepository repository, IUserContext [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateMenuCommand request, CancellationToken cancellationToken) + public async Task Handle(RemoveNodeCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) return Result.Failure("Authenticated user is required."); @@ -23,12 +26,9 @@ public async Task Handle(UpdateMenuCommand request, CancellationToken ca var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); if (suite is null) return Result.Failure("System suite not found."); - var result = suite.UpdateMenu( + var result = suite.RemoveModuleNode( IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - Name.Create(request.Label), - Description.Create(request.Description), - request.SortOrder, + IdValueObject.Load(request.NodeId), ActorId.Create(_userContext.UserId)); if (result.IsFailure) return result; @@ -38,3 +38,14 @@ public async Task Handle(UpdateMenuCommand request, CancellationToken ca return Result.Success(); } } + +public sealed class RemoveNodeCommandValidator : AbstractValidator +{ + public RemoveNodeCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommand.cs deleted file mode 100644 index 006275b1..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommand.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record RemoveOptionCommand(Guid SystemSuiteId, Guid ModuleId, Guid MenuId, Guid SubMenuId, Guid OptionId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommandHandler.cs deleted file mode 100644 index b1622ebf..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommandHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class RemoveOptionCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public RemoveOptionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(RemoveOptionCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.RemoveOption( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - IdValueObject.Load(request.OptionId), - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommand.cs deleted file mode 100644 index e8f97bc4..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommand.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record RemoveSubMenuCommand(Guid SystemSuiteId, Guid ModuleId, Guid MenuId, Guid SubMenuId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommandHandler.cs deleted file mode 100644 index 81417cb7..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommandHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class RemoveSubMenuCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public RemoveSubMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(RemoveSubMenuCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.RemoveSubMenu( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeMetadataCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeMetadataCommand.cs new file mode 100644 index 00000000..df2d49bc --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeMetadataCommand.cs @@ -0,0 +1,77 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using FluentValidation; +using Ums.Domain.Authorization; +using Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// Fija (reemplaza) los metadatos de gobernanza SDLC de un nodo (ADR-0090). Todos opcionales. +public sealed record SetNodeMetadataCommand( + Guid SystemSuiteId, + Guid ModuleId, + Guid NodeId, + string? Responsable, + string? Criticidad, + string? ProductoImpactado, + string? ComponenteTecnico, + string? Dependencias, + string? Evidencias, + string? TrazabilidadSdlc) : ICommand; + +public sealed class SetNodeMetadataCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public SetNodeMetadataCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(SetNodeMetadataCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var metadata = MenuNodeMetadata.Create( + request.Responsable, + request.Criticidad, + request.ProductoImpactado, + request.ComponenteTecnico, + request.Dependencias, + request.Evidencias, + request.TrazabilidadSdlc); + + var result = suite.SetModuleNodeMetadata( + IdValueObject.Load(request.ModuleId), + IdValueObject.Load(request.NodeId), + metadata, + ActorId.Create(_userContext.UserId)); + + if (result.IsFailure) return result; + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); + } +} + +public sealed class SetNodeMetadataCommandValidator : AbstractValidator +{ + public SetNodeMetadataCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + RuleFor(x => x.Criticidad).MaximumLength(50); + RuleFor(x => x.Responsable).MaximumLength(200); + RuleFor(x => x.ProductoImpactado).MaximumLength(200); + RuleFor(x => x.ComponenteTecnico).MaximumLength(200); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeStatusCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeStatusCommand.cs new file mode 100644 index 00000000..f30db36b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeStatusCommand.cs @@ -0,0 +1,54 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using FluentValidation; +using Ums.Domain.Authorization; + +public sealed record SetNodeStatusCommand(Guid SystemSuiteId, Guid ModuleId, Guid NodeId, bool Active) : ICommand; + +public sealed class SetNodeStatusCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public SetNodeStatusCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(SetNodeStatusCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var moduleId = IdValueObject.Load(request.ModuleId); + var nodeId = IdValueObject.Load(request.NodeId); + var actor = ActorId.Create(_userContext.UserId); + + var result = request.Active + ? suite.ActivateModuleNode(moduleId, nodeId, actor) + : suite.DeactivateModuleNode(moduleId, nodeId, actor); + + if (result.IsFailure) return result; + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); + } +} + +public sealed class SetNodeStatusCommandValidator : AbstractValidator +{ + public SetNodeStatusCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommand.cs deleted file mode 100644 index a2ff1f9d..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record UpdateMenuCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - string Label, - string Description, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandValidator.cs deleted file mode 100644 index 59ed0ffe..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class UpdateMenuCommandValidator : AbstractValidator -{ - public UpdateMenuCommandValidator() - { - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - RuleFor(x => x.Label).NotEmpty().MaximumLength(150); - RuleFor(x => x.Description).MaximumLength(500); - RuleFor(x => x.SortOrder).GreaterThan(0).WithMessage("SortOrder must be a positive integer."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateNodeCommand.cs similarity index 55% rename from src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandHandler.cs rename to src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateNodeCommand.cs index 08bdf49c..b1b26adf 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateNodeCommand.cs @@ -1,13 +1,22 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using FluentValidation; using Ums.Domain.Authorization; -public sealed class AddSubMenuCommandHandler : ICommandHandler +public sealed record UpdateNodeCommand( + Guid SystemSuiteId, + Guid ModuleId, + Guid NodeId, + string Label, + string Description, + int SortOrder) : ICommand; + +public sealed class UpdateNodeCommandHandler : ICommandHandler { private readonly ISystemSuiteRepository _repository; private readonly IUserContext _userContext; - public AddSubMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + public UpdateNodeCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) { _repository = repository; _userContext = userContext; @@ -15,7 +24,7 @@ public AddSubMenuCommandHandler(ISystemSuiteRepository repository, IUserContext [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(AddSubMenuCommand request, CancellationToken cancellationToken) + public async Task Handle(UpdateNodeCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) return Result.Failure("Authenticated user is required."); @@ -23,10 +32,9 @@ public async Task Handle(AddSubMenuCommand request, CancellationToken ca var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); if (suite is null) return Result.Failure("System suite not found."); - var result = suite.AddSubMenu( + var result = suite.UpdateModuleNode( IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - Code.Create(request.Code), + IdValueObject.Load(request.NodeId), Name.Create(request.Label), Description.Create(request.Description), request.SortOrder, @@ -39,3 +47,17 @@ public async Task Handle(AddSubMenuCommand request, CancellationToken ca return Result.Success(); } } + +public sealed class UpdateNodeCommandValidator : AbstractValidator +{ + public UpdateNodeCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + RuleFor(x => x.Label).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).MaximumLength(1000); + RuleFor(x => x.SortOrder).GreaterThan(0); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommand.cs deleted file mode 100644 index 119612b5..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record UpdateOptionCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - Guid SubMenuId, - Guid OptionId, - string Label, - string Description, - string ActionCode, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandHandler.cs deleted file mode 100644 index a54d0915..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class UpdateOptionCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public UpdateOptionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateOptionCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.UpdateOption( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - IdValueObject.Load(request.OptionId), - Name.Create(request.Label), - Description.Create(request.Description), - ActionCode.Create(request.ActionCode), - request.SortOrder, - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandValidator.cs deleted file mode 100644 index e9542883..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class UpdateOptionCommandValidator : AbstractValidator -{ - public UpdateOptionCommandValidator() - { - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - RuleFor(x => x.SubMenuId).NotEmpty(); - RuleFor(x => x.OptionId).NotEmpty(); - RuleFor(x => x.Label).NotEmpty().MaximumLength(150); - RuleFor(x => x.Description).MaximumLength(500); - RuleFor(x => x.ActionCode).NotEmpty().MaximumLength(100); - RuleFor(x => x.SortOrder).GreaterThan(0).WithMessage("SortOrder must be a positive integer."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommand.cs deleted file mode 100644 index d83ffec5..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record UpdateSubMenuCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - Guid SubMenuId, - string Label, - string Description, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandHandler.cs deleted file mode 100644 index 59a0d902..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class UpdateSubMenuCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public UpdateSubMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateSubMenuCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.UpdateSubMenu( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - Name.Create(request.Label), - Description.Create(request.Description), - request.SortOrder, - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandValidator.cs deleted file mode 100644 index 7a9f4454..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class UpdateSubMenuCommandValidator : AbstractValidator -{ - public UpdateSubMenuCommandValidator() - { - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - RuleFor(x => x.SubMenuId).NotEmpty(); - RuleFor(x => x.Label).NotEmpty().MaximumLength(150); - RuleFor(x => x.Description).MaximumLength(500); - RuleFor(x => x.SortOrder).GreaterThan(0).WithMessage("SortOrder must be a positive integer."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddDomainResourceResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddDomainResourceResponse.cs new file mode 100644 index 00000000..89046c90 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddDomainResourceResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.SystemSuite.DTOs; + +/// Id del recurso de dominio recién creado, para evitar un GET posterior (G-053). +public sealed record AddDomainResourceResponse(Guid DomainResourceId); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddModuleResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddModuleResponse.cs new file mode 100644 index 00000000..6087199c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddModuleResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.SystemSuite.DTOs; + +/// Id del módulo recién creado, para evitar un GET posterior (G-053). +public sealed record AddModuleResponse(Guid ModuleId); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddNodeResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddNodeResponse.cs new file mode 100644 index 00000000..ef62dd9c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddNodeResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.SystemSuite.DTOs; + +/// Id del nodo recién añadido al árbol del módulo, para evitar un GET posterior (G-053). +public sealed record AddNodeResponse(Guid NodeId); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/RegisterActionResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/RegisterActionResponse.cs new file mode 100644 index 00000000..b98b5fbe --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/RegisterActionResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.SystemSuite.DTOs; + +/// Identificador de la acción recién registrada (G-053). Code es la clave de negocio. +public sealed record RegisterActionResponse(Guid ActionId, string Code); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs index 40a164ae..796eafca 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; + namespace Ums.Application.Authorization.SystemSuite.DTOs; public sealed record SystemSuiteDto( @@ -31,28 +33,7 @@ public static SystemSuiteDto Map(Ums.Domain.Authorization.SystemSuite.SystemSuit m.Description.GetValue(), m.Status.ToString(), m.SortOrder, - m.Menus.Select(menu => new SystemSuiteMenuDto( - menu.Props.Id.GetValue(), // Props.Id = stable DB GUID - menu.Code.GetValue(), - menu.Label.GetValue(), - menu.Description.GetValue(), - menu.SortOrder, - menu.SubMenus.Select(sm => new SystemSuiteSubMenuDto( - sm.Props.Id.GetValue(), // Props.Id = stable DB GUID - sm.Code.GetValue(), - sm.Label.GetValue(), - sm.Description.GetValue(), - sm.SortOrder, - sm.Options.Select(opt => new SystemSuiteOptionDto( - opt.Props.Id.GetValue(), // Props.Id = stable DB GUID - opt.Code.GetValue(), - opt.Label.GetValue(), - opt.Description.GetValue(), - opt.ActionCode.GetValue(), - opt.SortOrder - )).ToList() - )).ToList() - )).ToList() + m.Nodes.Select(MapNode).ToList() )).ToList(), suite.Actions.Select(a => new SystemSuiteActionDto( a.Props.Id.GetValue(), // Props.Id = stable DB GUID @@ -70,6 +51,27 @@ public static SystemSuiteDto Map(Ums.Domain.Authorization.SystemSuite.SystemSuit )).ToList() ); } + + private static SystemSuiteNodeDto MapNode(MenuNodeEntity node) + { + var meta = node.Metadata; + return new SystemSuiteNodeDto( + node.GetId().GetValue(), + node.ParentNodeId?.GetValue(), + node.Kind.ToString(), + node.Code.GetValue(), + node.Label.GetValue(), + node.Description.GetValue(), + node.Status.ToString(), + node.SortOrder, + node.ActionCodes.Select(a => a.GetValue()).ToList(), + meta.IsEmpty + ? null + : new SystemSuiteNodeMetadataDto( + meta.Responsable, meta.Criticidad, meta.ProductoImpactado, + meta.ComponenteTecnico, meta.Dependencias, meta.Evidencias, meta.TrazabilidadSdlc), + node.Children.Select(MapNode).ToList()); + } } public sealed record SystemSuiteModuleDto( @@ -79,31 +81,29 @@ public sealed record SystemSuiteModuleDto( string Description, string Status, int SortOrder, - IReadOnlyList Menus); + IReadOnlyList Nodes); -public sealed record SystemSuiteMenuDto( - Guid Id, - string Code, - string Label, - string Description, - int SortOrder, - IReadOnlyList SubMenus); - -public sealed record SystemSuiteSubMenuDto( +public sealed record SystemSuiteNodeDto( Guid Id, + Guid? ParentNodeId, + string Kind, string Code, string Label, string Description, + string Status, int SortOrder, - IReadOnlyList Options); + IReadOnlyList ActionCodes, + SystemSuiteNodeMetadataDto? Metadata, + IReadOnlyList Children); -public sealed record SystemSuiteOptionDto( - Guid Id, - string Code, - string Label, - string Description, - string ActionCode, - int SortOrder); +public sealed record SystemSuiteNodeMetadataDto( + string? Responsable, + string? Criticidad, + string? ProductoImpactado, + string? ComponenteTecnico, + string? Dependencias, + string? Evidencias, + string? TrazabilidadSdlc); public sealed record SystemSuiteActionDto( Guid Id, diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Queries/GetAllSystemSuitesQueryHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Queries/GetAllSystemSuitesQueryHandler.cs index b23a42e7..b4f46756 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Queries/GetAllSystemSuitesQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Queries/GetAllSystemSuitesQueryHandler.cs @@ -1,6 +1,7 @@ using Ums.Application.Authorization.SystemSuite.DTOs; using Ums.Application.Common.Interfaces; using Ums.Domain.Authorization; +using Ums.Domain.Authorization.SystemSuite; using static Ums.Application.Common.QueryRequestNormalizer; namespace Ums.Application.Authorization.SystemSuite.Queries; @@ -34,43 +35,28 @@ public async Task>> Handle( var effectiveTenantId = _tenantScopePolicy.ResolveQueryScope(); - var systemSuites = effectiveTenantId.HasValue - ? await _systemSuiteRepository.GetByTenantIdAsync(effectiveTenantId.Value, cancellationToken) - : await _systemSuiteRepository.GetAllAsync(null, cancellationToken); + // G-179: filtro, orden y recorte se resuelven EN LA BASE. Antes se traían todas las suites + // del inquilino con su árbol completo —módulos, nodos, acciones y recursos de dominio— para + // devolver una página de veinte: con cientos de sistemas, traer el catálogo entero a la + // aplicación y tirar el 95 %. + var pagina = await _systemSuiteRepository.GetPageAsync( + new SystemSuitePageQuery( + TenantId: effectiveTenantId, + Status: string.Equals(status, "all", StringComparison.OrdinalIgnoreCase) ? null : status, + SearchField: criteria, + Search: string.IsNullOrWhiteSpace(search) ? null : search, + SortBy: sortBy, + Descending: string.Equals(sortOrder, "desc", StringComparison.OrdinalIgnoreCase), + Page: page, + PageSize: pageSize), + cancellationToken); - var query = systemSuites.Select(SystemSuiteDto.Map); + // El árbol se carga SOLO para las suites de la página, y en el orden que la página fijó. + var suites = await _systemSuiteRepository.GetByIdsAsync(pagina.Ids, cancellationToken); + var items = suites.Select(SystemSuiteDto.Map).ToList(); - if (!string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(s => string.Equals(s.Status, status, StringComparison.OrdinalIgnoreCase)); - } - - if (!string.IsNullOrWhiteSpace(search)) - { - query = criteria switch - { - "code" => query.Where(s => s.Code.Contains(search, StringComparison.OrdinalIgnoreCase)), - "id" => query.Where(s => s.SystemSuiteId.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)), - _ => query.Where(s => s.Name.Contains(search, StringComparison.OrdinalIgnoreCase)), - }; - } - - query = (sortBy, sortOrder) switch - { - ("code", "desc") => query.OrderByDescending(s => s.Code), - ("code", _) => query.OrderBy(s => s.Code), - ("status", "desc") => query.OrderByDescending(s => s.Status), - ("status", _) => query.OrderBy(s => s.Status), - ("name", "desc") => query.OrderByDescending(s => s.Name), - _ => query.OrderBy(s => s.Name), - }; - - var totalItems = query.Count(); + var totalItems = pagina.TotalItems; var totalPages = totalItems == 0 ? 0 : (int)Math.Ceiling(totalItems / (double)pageSize); - var items = query - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToList(); return Result>.Success(new PagedResult( items, diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommand.cs index ed2f2cea..b66406ed 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommand.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommand.cs @@ -1,9 +1,11 @@ namespace Ums.Application.Authorization.Template.Commands; +using Ums.Application.Authorization.Template.DTOs; + public sealed record AddTemplateItemCommand( Guid TemplateId, string TargetType, Guid TargetId, Guid ActionId, bool IsAllowed, - bool IsDenied) : ICommand; + bool IsDenied) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandHandler.cs index 548ded50..2e4ca257 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandHandler.cs @@ -1,10 +1,11 @@ namespace Ums.Application.Authorization.Template.Commands; +using Ums.Application.Authorization.Template.DTOs; using Ums.Domain.Authorization; using Ums.Domain.Enums; using BeyondNetCode.Shell.Ddd; -public sealed class AddTemplateItemCommandHandler : ICommandHandler +public sealed class AddTemplateItemCommandHandler : ICommandHandler { private readonly IPermissionTemplateRepository _repository; private readonly IUserContext _userContext; @@ -19,19 +20,24 @@ public AddTemplateItemCommandHandler( [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle( + public async Task> Handle( AddTemplateItemCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); + return Result.Failure("Authenticated user is required."); var template = await _repository.GetByIdAsync(request.TemplateId, cancellationToken); - if (template is null) return Result.Failure("Template not found."); + if (template is null) return Result.Failure("Template not found."); - var targetType = DomainEnumeration.FromDisplayName(request.TargetType); + // G-192 — se resuelve con el parseador común (recorta y no distingue mayúsculas) y el + // mensaje enumera los destinos que el DOMINIO declara. Antes usaba FromDisplayName, que + // exige coincidencia exacta, y recitaba una lista de cuatro que el enumerado ya había + // dejado atrás: «Aggregate» y «Entity» son destinos legítimos del arco exclusivo. + var targetType = DomainEnumerationParser.FromName(request.TargetType); if (targetType is null) - return Result.Failure($"Invalid target type '{request.TargetType}'. Valid: SystemSuite, Module, Submodule, Option."); + return Result.Failure( + $"Invalid target type '{request.TargetType}'. Valid: {string.Join(", ", AddTemplateItemCommandValidator.ValidTargetTypes)}."); var result = template.AddItem( targetType, @@ -41,10 +47,10 @@ public async Task Handle( request.IsDenied, ActorId.Create(_userContext.UserId)); - if (result.IsFailure) return result; + if (result.IsFailure) return Result.Failure(result.Error); await _repository.UpdateAsync(template, cancellationToken); await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); + return Result.Success(new AddTemplateItemResponse(result.Value)); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandValidator.cs index 6a25314a..0c31c4c3 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandValidator.cs @@ -2,15 +2,34 @@ namespace Ums.Application.Authorization.Template.Commands; public sealed class AddTemplateItemCommandValidator : AbstractValidator { - private static readonly string[] ValidTargetTypes = ["SystemSuite", "Module", "Submodule", "Option"]; + /// + /// Destinos admitidos, DERIVADOS de . + /// + /// G-192: aquí había una copia a mano —SystemSuite, Module, Submodule, Option— que dejó fuera + /// a Aggregate y Entity. El dominio sí los admite (PermissionTemplate.AddItem + /// no restringe el arco), Profile.AssignTemplate los copia tal cual al permiso del perfil + /// y AuthorizationGraphBuilderService.BuildDomainPermissions los proyecta casando + /// (TargetId, ActionId) contra los recursos de dominio de la suite; el seeder canónico los usa. + /// O sea: el único que los negaba era este validador, y con él ninguna concesión sobre un objeto + /// de dominio podía darse de alta por API, dejando `domainPermissions` vacío en todo perfil + /// provisionado así. Derivar la lista del enumerado hace que la divergencia no pueda repetirse. + /// + internal static readonly IReadOnlyList ValidTargetTypes = + DomainEnumerationParser.NamesOf(); + + internal static readonly string ValidTargetTypesMessage = + $"TargetType must be one of: {string.Join(", ", ValidTargetTypes)}."; public AddTemplateItemCommandValidator() { RuleFor(x => x.TemplateId).NotEmpty(); RuleFor(x => x.TargetType) .NotEmpty() - .Must(v => ValidTargetTypes.Contains(v, StringComparer.OrdinalIgnoreCase)) - .WithMessage("TargetType must be one of: SystemSuite, Module, Submodule, Option."); + // Se resuelve con el MISMO criterio que usará el manejador (recorte y sin distinguir + // mayúsculas): si el validador aceptara una forma que el manejador no sabe resolver, el + // alta fallaría después con un error opaco en vez de con un 400 explícito. + .Must(v => DomainEnumerationParser.FromName(v) is not null) + .WithMessage(ValidTargetTypesMessage); RuleFor(x => x.TargetId).NotEmpty(); RuleFor(x => x.ActionId).NotEmpty(); RuleFor(x => x).Must(x => x.IsAllowed || x.IsDenied || (!x.IsAllowed && !x.IsDenied)) diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/CreatePermissionTemplateCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/CreatePermissionTemplateCommandHandler.cs index 490ebb9c..579476e4 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/CreatePermissionTemplateCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/CreatePermissionTemplateCommandHandler.cs @@ -38,11 +38,18 @@ public async Task> Handle( return Result.Failure(scopeResult.Error); } - var templateResult = PermissionTemplate.Create( + // G-140: resuelve la versión SIGUIENTE a partir de las plantillas existentes para la terna + // (tenant, rol, suite). Antes se asignaba siempre 0.1.0 y el alta sobre un rol ya plantillado + // colisionaba con el índice único → DbUpdateException no controlada → 500 opaco. + var existing = await _templateRepository.GetByTenantRoleSuiteAsync( + request.TenantId, request.RoleId, request.SystemSuiteId, cancellationToken); + + var templateResult = PermissionTemplate.CreateNextVersion( TenantId.Load(request.TenantId), RoleId.Load(request.RoleId), SystemSuiteId.Load(request.SystemSuiteId), - ActorId.Create(_userContext.UserId)); + ActorId.Create(_userContext.UserId), + (existing ?? []).Select(t => t.Version)); if (templateResult.IsFailure) { diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeletePermissionTemplateCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeletePermissionTemplateCommandHandler.cs index 8bf4815b..b2872996 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeletePermissionTemplateCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeletePermissionTemplateCommandHandler.cs @@ -2,17 +2,35 @@ namespace Ums.Application.Authorization.Template.Commands; using Ums.Domain.Authorization; using Ums.Domain.Authorization.Template; +using Ums.Domain.Kernel; +/// +/// Borrado LÓGICO de una plantilla de permisos. El contrato HTTP no cambia —DELETE sigue devolviendo +/// 204, 404 o 409 igual que antes—; lo que cambia es que la fila NO se elimina de la base. +/// +/// Orden de ejecución (importa): +/// 1. Carga. El repositorio ya oculta lo eliminado, así que un segundo DELETE da 404. +/// 2. Guardia de cascada: perfiles VIVOS que referencian la plantilla → 409 con dependencias +/// estructuradas. Los perfiles ya desactivados no cuentan: su referencia también está +/// lógicamente eliminada, que es exactamente la regla que pide la política. +/// 3. PermissionTemplate.Delete valida el estado (solo Draft/Deprecated) y transiciona al +/// estado terminal Deleted. +/// 4. UpdateAsync persiste el nuevo StatusId y la auditoría (quién y cuándo lo eliminó): sin +/// esta llamada el rastro de la eliminación se perdería, que es justo lo que la política protege. +/// public sealed class DeletePermissionTemplateCommandHandler : ICommandHandler { private readonly IPermissionTemplateRepository _templateRepository; + private readonly IProfileRepository _profileRepository; private readonly IUserContext _userContext; public DeletePermissionTemplateCommandHandler( IPermissionTemplateRepository templateRepository, + IProfileRepository profileRepository, IUserContext userContext) { _templateRepository = templateRepository; + _profileRepository = profileRepository; _userContext = userContext; } @@ -31,18 +49,30 @@ public async Task Handle(DeletePermissionTemplateCommand request, Cancel return Result.Failure("Authenticated user is required."); } - var deleteResult = template.Delete(ActorId.Create(_userContext.UserId)); - if (deleteResult.IsFailure) + // ── Guardia de cascada: perfiles vivos que usan la plantilla ────────── + // Análoga a un ON DELETE RESTRICT: no se elimina lógicamente algo con referencias reales + // que no hayan sido eliminadas lógicamente primero. Mismo conteo y mismo código de error + // que la depreciación (DeprecatePermissionTemplateCommandHandler), que ya lo aplica. + var activeProfileCount = await _profileRepository.CountActiveByTemplateAsync( + request.TemplateId, cancellationToken); + + if (activeProfileCount > 0) { - return deleteResult; + var deps = new List + { + new("Profile", "Active", activeProfileCount), + }; + return Result.Failure(BlockedOperationError.Encode( + DomainErrors.Authorization.TemplateHasActiveProfiles, deps)); } - var deleted = await _templateRepository.DeleteAsync(request.TemplateId, cancellationToken); - if (!deleted) + var deleteResult = template.Delete(ActorId.Create(_userContext.UserId), activeProfileCount); + if (deleteResult.IsFailure) { - return Result.Failure("Template could not be deleted."); + return deleteResult; } + await _templateRepository.UpdateAsync(template, cancellationToken); await _templateRepository.UnitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(); } diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommand.cs deleted file mode 100644 index 2a2bd3bf..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommand.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Authorization.Template.Commands; - -public sealed record RemoveTemplateItemCommand(Guid TemplateId, Guid ItemId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommandHandler.cs deleted file mode 100644 index e6a36eab..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommandHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Ums.Application.Authorization.Template.Commands; - -using Ums.Domain.Authorization; - -public sealed class RemoveTemplateItemCommandHandler : ICommandHandler -{ - private readonly IPermissionTemplateRepository _repository; - private readonly IUserContext _userContext; - - public RemoveTemplateItemCommandHandler( - IPermissionTemplateRepository repository, - IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle( - RemoveTemplateItemCommand request, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var template = await _repository.GetByIdAsync(request.TemplateId, cancellationToken); - if (template is null) return Result.Failure("Template not found."); - - var result = template.RemoveItem( - IdValueObject.Load(request.ItemId), - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(template, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/DTOs/AddTemplateItemResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/DTOs/AddTemplateItemResponse.cs new file mode 100644 index 00000000..b516a42b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/DTOs/AddTemplateItemResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.Template.DTOs; + +/// Id del ítem de plantilla recién creado, para evitar un GET posterior (G-053). +public sealed record AddTemplateItemResponse(Guid ItemId); diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Queries/GetPermissionTemplateByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Queries/GetPermissionTemplateByIdQueryHandler.cs index 0cfedbc2..dd5a754b 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Queries/GetPermissionTemplateByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Queries/GetPermissionTemplateByIdQueryHandler.cs @@ -1,6 +1,7 @@ using Ums.Application.Authorization.Template.DTOs; using Ums.Domain.Authorization; using Ums.Domain.Authorization.Template; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; namespace Ums.Application.Authorization.Template.Queries; @@ -85,26 +86,21 @@ private static Dictionary BuildTargetNameLookup(SystemSuiteAggrega var lookup = new Dictionary(); if (suite is null) return lookup; - foreach (var module in suite.Modules) + void WalkNodes(IEnumerable nodes) { - lookup[module.Props.Id.GetValue()] = module.Name.GetValue(); - - foreach (var menu in module.Menus) + foreach (var node in nodes) { - lookup[menu.Props.Id.GetValue()] = menu.Label.GetValue(); - - foreach (var subMenu in menu.SubMenus) - { - lookup[subMenu.Props.Id.GetValue()] = subMenu.Label.GetValue(); - - foreach (var option in subMenu.Options) - { - lookup[option.Props.Id.GetValue()] = option.Label.GetValue(); - } - } + lookup[node.GetId().GetValue()] = node.Label.GetValue(); + WalkNodes(node.Children); } } + foreach (var module in suite.Modules) + { + lookup[module.Props.Id.GetValue()] = module.Name.GetValue(); + WalkNodes(module.Nodes); + } + return lookup; } } diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/AuditMetadataSanitizer.cs b/src/apps/ums.api/Ums.Application/Common/Aop/AuditMetadataSanitizer.cs new file mode 100644 index 00000000..3aaacc6e --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Aop/AuditMetadataSanitizer.cs @@ -0,0 +1,173 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Ums.Application.Common.Aop; + +/// +/// G-040 (residual #5, FR-072): desinfecta la metadata serializada de la traza de auditoría +/// antes de persistirla, redactando los valores cuyas claves casen patrones sensibles (contraseñas, +/// hashes, PIN, llaves, tokens, secretos, credenciales…). Se aplica en AMBAS vías —la manual +/// (RecordAuditCommandHandler) y la automática (AuditTrailAspect)— antes de emitir/persistir. +/// +/// +/// La traza es append-only e inmutable (G-081): un secreto filtrado a ella no se puede borrar +/// después. Por eso la redacción es conservadora (ante la duda, redacta) y por clave, no +/// por valor: se conserva la clave —para dejar constancia de que el dato existía— y se sustituye su +/// valor completo por . Si la clave marca un subárbol (objeto o +/// arreglo) sensible, se redacta el subárbol entero sin descender en él. +/// +/// +/// +/// La metadata de auditoría es un string JSON en ambas vías (la vía manual lo valida como JSON +/// bien formado en RecordAuditCommandValidator; la automática lo serializa con +/// JsonSerializer.Serialize). El saneador parsea ese JSON, recorre el árbol y reconstruye una +/// copia redactada. No muta la entrada. +/// +/// +/// +/// Lista de patrones (fácil de extender — añade a o +/// ). Las comparaciones son insensibles a mayúsculas. Se tokeniza la +/// clave por límites camelCase, snake_case, kebab-case y separadores, de modo que +/// apiKey, api_key y API-KEY se tratan igual. +/// +/// — patrones largos e inequívocos; casan en cualquier parte +/// de la clave (p. ej. passwordHash, bearerToken, clientSecret). +/// — patrones cortos y ambiguos; casan solo como token +/// delimitado para no redactar por accidente claves legítimas (p. ej. shipping contiene +/// «pin», monkey contiene «key»: no deben redactarse). +/// +/// +/// +/// +/// Limitación honesta: la redacción es por nombre de clave. Un secreto embebido en el +/// valor de una clave no sensible (p. ej. un texto libre "note":"la clave es 1234") no +/// se detecta. La lista es deliberadamente amplia por seguridad: puede sobre-redactar claves legítimas +/// que contengan «key» (p. ej. publicKey, idempotencyKey) — la sobre-redacción es el lado +/// seguro para una traza inmutable. +/// +/// +public static class AuditMetadataSanitizer +{ + /// Valor de reemplazo para los datos redactados (alineado con PiiMaskingPolicy). + public const string RedactionPlaceholder = "[REDACTED]"; + + /// + /// Patrones largos e inequívocos: casan como subcadena en cualquier parte de la clave (en minúsculas). + /// Extiende aquí para nuevos secretos «largos». + /// + private static readonly string[] SubstringPatterns = + [ + "password", + "passwd", + "secret", + "token", + "apikey", + "authorization", + "credential", + "hash", + ]; + + /// + /// Patrones cortos y ambiguos: casan solo como token delimitado (p. ej. «key» en apiKey, + /// pero no en monkey). Extiende aquí para nuevos secretos «cortos». + /// + private static readonly HashSet TokenPatterns = new(StringComparer.OrdinalIgnoreCase) + { + "pwd", + "pin", + "key", + "auth", + "cred", + }; + + // Tokeniza una clave por límites camelCase, respetando acrónimos: "APIKey" → [API, Key], + // "apiKey" → [api, Key], "api_key" → [api, key], "password" → [password]. + private static readonly Regex TokenBoundary = new( + "[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + /// + /// Devuelve una copia de la metadata JSON con los valores de las claves sensibles redactados. + /// Entrada nula, en blanco o no-JSON se devuelve sin cambios (no falla): ambas vías reales + /// garantizan JSON válido, y una entrada sin estructura clave-valor no tiene claves que redactar. + /// + public static string? Sanitize(string? metadata) + { + if (string.IsNullOrWhiteSpace(metadata)) + { + return metadata; + } + + JsonNode? root; + try + { + root = JsonNode.Parse(metadata); + } + catch (JsonException) + { + // No es JSON: sin estructura clave-valor sobre la que redactar por clave. Se devuelve tal cual. + return metadata; + } + + if (root is null) + { + // Literal JSON "null": nada que redactar. + return metadata; + } + + var sanitized = SanitizeNode(root); + return sanitized?.ToJsonString(); + } + + /// + /// Indica si una clave debe considerarse sensible (y por tanto redactarse su valor). Público para + /// que la lista de patrones sea verificable de forma unitaria y fácil de razonar. + /// + public static bool IsSensitiveKey(string? key) + { + if (string.IsNullOrEmpty(key)) + { + return false; + } + + var lower = key.ToLowerInvariant(); + if (SubstringPatterns.Any(pattern => lower.Contains(pattern, StringComparison.Ordinal))) + { + return true; + } + + return TokenBoundary.Matches(key).Any(token => TokenPatterns.Contains(token.Value)); + } + + // Reconstruye el nodo redactado. No muta la entrada: los objetos/arreglos se rehacen desde cero y + // los escalares se clonan, de modo que ningún nodo cambia de padre. + private static JsonNode? SanitizeNode(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + var sanitizedObject = new JsonObject(); + foreach (var property in obj) + { + sanitizedObject[property.Key] = IsSensitiveKey(property.Key) + ? RedactionPlaceholder + : SanitizeNode(property.Value); + } + + return sanitizedObject; + + case JsonArray array: + var sanitizedArray = new JsonArray(); + foreach (var item in array) + { + sanitizedArray.Add(SanitizeNode(item)); + } + + return sanitizedArray; + + default: + return node?.DeepClone(); + } + } +} diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/AuditTrailAspect.cs b/src/apps/ums.api/Ums.Application/Common/Aop/AuditTrailAspect.cs index e103de01..d67ceda9 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/AuditTrailAspect.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/AuditTrailAspect.cs @@ -3,6 +3,11 @@ namespace Ums.Application.Common.Aop; public sealed class AuditTrailAspect : AbstractAspect { private static readonly Guid SystemActorId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para AOP transversal. DispatchProxy (ADR-UMS-060) es " + + "síncrono; resolver el wrapper genérico privado WrapAsyncOfT por reflexión es la única vía " + + "para preservar la async-correctness de Task.")] private static readonly MethodInfo WrapAsyncOfTMethod = typeof(AuditTrailAspect).GetMethod(nameof(WrapAsyncOfT), BindingFlags.Instance | BindingFlags.NonPublic)!; @@ -59,11 +64,11 @@ public override void Apply(IJoinPoint joinPoint) return; } - Capture(joinPoint, attribute, joinPoint.Return, null); + CaptureAsync(joinPoint, attribute, joinPoint.Return, null).GetAwaiter().GetResult(); } catch (Exception ex) { - Capture(joinPoint, attribute, null, ex); + CaptureAsync(joinPoint, attribute, null, ex).GetAwaiter().GetResult(); throw; } } @@ -81,11 +86,11 @@ private async Task WrapAsync(IJoinPoint joinPoint, Task task, AuditTrailAttribut try { await task.ConfigureAwait(false); - Capture(joinPoint, attribute, null, null); + await CaptureAsync(joinPoint, attribute, null, null).ConfigureAwait(false); } catch (Exception ex) { - Capture(joinPoint, attribute, null, ex); + await CaptureAsync(joinPoint, attribute, null, ex).ConfigureAwait(false); throw; } } @@ -95,17 +100,17 @@ private async Task WrapAsyncOfT(IJoinPoint joinPoint, Task tas try { var result = await ((Task)task).ConfigureAwait(false); - Capture(joinPoint, attribute, result, null); + await CaptureAsync(joinPoint, attribute, result, null).ConfigureAwait(false); return result; } catch (Exception ex) { - Capture(joinPoint, attribute, null, ex); + await CaptureAsync(joinPoint, attribute, null, ex).ConfigureAwait(false); throw; } } - private void Capture(IJoinPoint joinPoint, AuditTrailAttribute attribute, object? resultObject, Exception? exception) + private async Task CaptureAsync(IJoinPoint joinPoint, AuditTrailAttribute attribute, object? resultObject, Exception? exception) { var actorId = ResolveActorId(); var entityId = ResolveAffectedEntityId(joinPoint, resultObject); @@ -134,7 +139,10 @@ private void Capture(IJoinPoint joinPoint, AuditTrailAttribute attribute, object Exception = exception?.Message, }); - _auditTrailSink.TryWrite(new AuditTrailEntry( + // G-040 (FR-072): la metadata se desinfecta de forma centralizada en el sink + // (AuditTrailOutboxSink), único punto por el que pasan TODAS las emisiones automáticas de la + // traza (este aspecto y ConfigurationAuditService) antes de encolarse por el outbox. + await _auditTrailSink.PublishAsync(new AuditTrailEntry( actorId, subjectType, whatChanged, @@ -143,7 +151,7 @@ private void Capture(IJoinPoint joinPoint, AuditTrailAttribute attribute, object entityId, entityType, rootTenantId, - metadata)); + metadata)).ConfigureAwait(false); } private Guid ResolveActorId() diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/AuthorizationAspect.cs b/src/apps/ums.api/Ums.Application/Common/Aop/AuthorizationAspect.cs index f7e59559..4be213e8 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/AuthorizationAspect.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/AuthorizationAspect.cs @@ -74,7 +74,16 @@ public override void Apply(IJoinPoint joinPoint) } else { - Console.WriteLine($"[AOP] Resource/Action code could not be inferred for {joinPoint.TargetType?.Name}"); + // Fail-closed (G-098): el atributo [AuthorizationAspect] está presente + // —intención explícita de proteger el handler— pero el permiso no pudo + // determinarse (el nombre no casa la convención Create/Update/Delete/ + // Get/List y no se declararon ResourceCode/ActionCode). Denegar por + // defecto en lugar de dejar pasar: un método marcado como protegido + // nunca debe ejecutarse sin control de acceso (evita escalada de + // privilegios). Espejo de la rama de denegación anterior. + Console.WriteLine($"[AOP] Access DENIED for {joinPoint.TargetType?.Name}: authorization required but permission could not be determined (fail-closed)"); + throw new UnauthorizedAccessException( + $"Access denied. Authorization is required for '{joinPoint.TargetType?.Name}' but the permission could not be determined."); } Proceed(joinPoint); diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/IAuditTrailSink.cs b/src/apps/ums.api/Ums.Application/Common/Aop/IAuditTrailSink.cs index 5e35cce3..febc3c81 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/IAuditTrailSink.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/IAuditTrailSink.cs @@ -1,6 +1,16 @@ namespace Ums.Application.Common.Aop; +/// +/// Sumidero de la pista de auditoría automática (AUDIT-01..06, ADR-0016). +/// +/// G-040 (residual arquitectural): la vía automática ya no escribe a un canal en memoria con +/// descarte silencioso (el antiguo Channel.TryWrite devolvía false al saturarse y +/// se ignoraba). El registro se encola por el Transactional Outbox de MassTransit +/// cableado en UmsPlatformDbContext (UseBusOutbox()), con entrega POST-commit al +/// consumidor que lo persiste de forma append-only. Un fallo al encolar se registra como error +/// alertable; nunca se descarta en silencio. +/// public interface IAuditTrailSink { - bool TryWrite(AuditTrailEntry entry); + Task PublishAsync(AuditTrailEntry entry, CancellationToken cancellationToken = default); } diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/TenantValidationAspect.cs b/src/apps/ums.api/Ums.Application/Common/Aop/TenantValidationAspect.cs index 1d30e3ec..0303c8a5 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/TenantValidationAspect.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/TenantValidationAspect.cs @@ -22,31 +22,53 @@ public override void Apply(IJoinPoint joinPoint) var attribute = GetAttribute(joinPoint); if (attribute is null) { + // El handler no está marcado con [TenantValidationAspect]: no hay + // intención de validar inquilino → nada que hacer. Proceed(joinPoint); return; } - var request = joinPoint.Arguments.FirstOrDefault(a => a is not System.Threading.CancellationToken); + var userTenantId = _userContext.TenantId; + + // Actor global / pre-inquilino (sin TenantId propio): excepción legítima y + // DOCUMENTADA (G-102). El signup/auto-registro previo al inquilino, la + // creación de inquilino (el operador global carece de TenantId) y la + // administración de plataforma dirigida a un inquilino operan legítimamente + // sin un inquilino propio: no existe frontera de inquilino que cruzar, así + // que se procede a propósito. NO es un fail-open: es la regla de dominio + // para el actor sin inquilino (distinguir admin-interno/pre-tenant del + // cruce indebido, tal como prescribe G-102). + if (string.IsNullOrWhiteSpace(userTenantId)) + { + Proceed(joinPoint); + return; + } + + // Llamante VINCULADO a un inquilino sobre un handler marcado explícitamente + // como tenant-scoped: la petición DEBE dirigirse a su propio inquilino. + // Si el inquilino objetivo no puede confirmarse igual al del usuario, + // DENEGAR (fail-closed, espejo de G-098): un handler marcado para validación + // de inquilino nunca debe ejecutarse sin que el inquilino quede confirmado. + // Esto cierra el soft fail-open previo (si no se podía determinar el + // inquilino de la petición, se dejaba pasar), vía real de cruce indebido. + var request = joinPoint.Arguments?.FirstOrDefault(a => a is not System.Threading.CancellationToken); + + string? requestTenantId = null; if (request is not null) { var requestType = request.GetType(); var tenantIdProperty = _tenantIdPropertyCache.GetOrAdd( requestType, type => type.GetProperty("TenantId", BindingFlags.Instance | BindingFlags.Public)); + requestTenantId = tenantIdProperty?.GetValue(request)?.ToString(); + } - if (tenantIdProperty is not null) - { - var requestTenantId = tenantIdProperty.GetValue(request)?.ToString(); - var userTenantId = _userContext.TenantId; - - // Validate if both are present and not equal - if (!string.IsNullOrWhiteSpace(requestTenantId) && - !string.IsNullOrWhiteSpace(userTenantId) && - !string.Equals(requestTenantId, userTenantId, StringComparison.OrdinalIgnoreCase)) - { - throw new UnauthorizedAccessException($"Tenant mismatch. User belongs to {userTenantId}, but request targets {requestTenantId}."); - } - } + if (string.IsNullOrWhiteSpace(requestTenantId) || + !string.Equals(requestTenantId, userTenantId, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException( + $"Tenant validation failed. User belongs to tenant '{userTenantId}', " + + $"but the request tenant could not be confirmed to match (target: '{requestTenantId ?? ""}')."); } Proceed(joinPoint); diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/TransactionAspect.cs b/src/apps/ums.api/Ums.Application/Common/Aop/TransactionAspect.cs index 3cc98f43..57294941 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/TransactionAspect.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/TransactionAspect.cs @@ -6,6 +6,11 @@ namespace Ums.Application.Common.Aop; public sealed class TransactionAspect : AbstractAspect { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para AOP transversal. DispatchProxy (ADR-UMS-060) es " + + "síncrono; resolver el wrapper genérico privado WrapAsyncOfT por reflexión es la única vía " + + "para preservar la async-correctness de Task.")] private static readonly MethodInfo WrapAsyncOfTMethod = typeof(TransactionAspect).GetMethod(nameof(WrapAsyncOfT), BindingFlags.Instance | BindingFlags.NonPublic)!; @@ -73,6 +78,14 @@ private void Proceed(IJoinPoint joinPoint) GetNext()!.Apply(joinPoint); } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", + Justification = "Firma uniforme con WrapAsyncOfT, que se invoca por reflexión como método de " + + "instancia (BindingFlags.Instance). Se mantienen simétricos e instancia por diseño AOP.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "'joinPoint' forma parte de la firma uniforme del wrapper AOP invocado por reflexión; " + + "se conserva para simetría con WrapAsyncOfT.")] private async Task WrapAsync(IJoinPoint joinPoint, Task task, ITransactionScope tx) { try @@ -91,6 +104,14 @@ private async Task WrapAsync(IJoinPoint joinPoint, Task task, ITransactionScope } } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", + Justification = "Se invoca por reflexión como método de instancia (BindingFlags.Instance, Invoke(this,...)); " + + "volverla estática rompería la resolución del MethodInfo en el ctor.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "'joinPoint' se pasa en el arreglo de argumentos de la invocación por reflexión; " + + "forma parte de la firma uniforme del wrapper AOP.")] private async Task WrapAsyncOfT(IJoinPoint joinPoint, Task task, ITransactionScope tx) { try diff --git a/src/apps/ums.api/Ums.Application/Common/DomainEnumerationParser.cs b/src/apps/ums.api/Ums.Application/Common/DomainEnumerationParser.cs index b7bd9d1e..e0193e25 100644 --- a/src/apps/ums.api/Ums.Application/Common/DomainEnumerationParser.cs +++ b/src/apps/ums.api/Ums.Application/Common/DomainEnumerationParser.cs @@ -16,4 +16,20 @@ internal static class DomainEnumerationParser .GetAll() .FirstOrDefault(value => string.Equals(value.Name, name.Trim(), StringComparison.OrdinalIgnoreCase)); } + + /// + /// Nombres que declara el enumerado de dominio, ordenados por su identificador. + /// + /// Existe para que los validadores y los mensajes de error DERIVEN del dominio en vez de + /// copiarlo a mano: una lista copiada envejece en silencio y acaba negando lo que el dominio + /// sí admite —G-192, donde ExclusiveArcTarget incorporó Aggregate y Entity + /// y la copia de la capa de aplicación se quedó en los cuatro destinos de navegación—. + /// + public static IReadOnlyList NamesOf() + where T : DomainEnumeration + => DomainEnumeration + .GetAll() + .OrderBy(value => value.Id) + .Select(value => value.Name) + .ToArray(); } diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/IFunctionalTransaction.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/IFunctionalTransaction.cs new file mode 100644 index 00000000..babe8f0c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/IFunctionalTransaction.cs @@ -0,0 +1,110 @@ +namespace Ums.Application.Common.Interfaces; + +/// +/// Estados del ciclo de vida de una transacción funcional (ADR-0095, adoptado por +/// ADR-0096 §2.3). El desenlace siempre cae en uno de estos estados; una +/// transacción sin desenlace es un defecto alertable. +/// +public enum TransactionState +{ + /// La transacción se abrió (apertura de la narrativa). + Started, + + /// Hay etapas en curso. + InProgress, + + /// A la espera de un recurso o intervención externa. + Waiting, + + /// Reintentando una etapa fallida de forma controlada. + Retrying, + + /// Desenlace exitoso: todas las etapas se completaron. + Completed, + + /// Desenlace parcial: unas etapas se completaron y otras no. + PartiallyCompleted, + + /// Desenlace fallido. + Failed, + + /// La transacción se canceló (p. ej. el cliente abortó la petición). + Cancelled, + + /// La transacción excedió su tiempo límite. + TimedOut, +} + +/// +/// Reversibilidad de un efecto sobre el mundo externo (ADR-0096 §2.4). +/// +public enum EffectReversibility +{ + /// El efecto ya fue compensado. + Compensated, + + /// El efecto está pendiente de compensación. + PendingCompensation, + + /// El efecto es irreversible. + Irreversible, +} + +/// +/// Puerto de la transacción funcional (ADR-0096, contrato funcional de trazabilidad; +/// decidido para UMS en ADR-UMS-085). Modela el proceso como un relato: +/// apertura → etapas → decisiones (el porqué) → efectos → desenlace siempre. +/// +/// El modelo vive en un middleware que abre y cierra la transacción (garantizando el +/// desenlace por construcción) y emite la narrativa como logs estructurados enriquecidos +/// con atributos beyondnet.transaction.* / beyondnet.stage / beyondnet.effect.* +/// sobre Serilog + OpenTelemetry. La capa de aplicación usa este puerto para registrar +/// las etapas, decisiones y efectos con significado de negocio. +/// +public interface IFunctionalTransaction +{ + /// + /// Localizador legible TX-AAAA-NNNNNN (ADR-UMS-084) si ya se acuñó; null + /// si la transacción aún se identifica solo por su traceId W3C. Se acuña de forma + /// perezosa: siempre en operaciones que mutan estado y ante cualquier fallo. + /// + string? Locator { get; } + + /// Nombre funcional de la transacción (acción del actor). + string Name { get; } + + /// Actor que inició la transacción (usuario autenticado o iniciador anónimo). + string? Actor { get; } + + /// Estado actual de la transacción. + TransactionState State { get; } + + /// + /// Devuelve el localizador legible, acuñándolo si aún no existe. Es idempotente dentro + /// de la misma transacción: llamadas sucesivas devuelven el mismo valor. + /// + Task GetOrMintLocatorAsync(CancellationToken cancellationToken = default); + + /// + /// Registra una etapa con significado de negocio (ADR-0096 §2.3). Si + /// es true, la etapa se contabiliza como fallida y el + /// desenlace podrá resolverse como parcial o fallido. + /// + void RecordStage(string stage, string? detail = null, bool failed = false); + + /// Registra una decisión y su porqué (ADR-0096 §2.3). + void RecordDecision(string decision, string reason); + + /// + /// Registra un efecto sobre el mundo externo con su reversibilidad + /// (ADR-0096 §2.4). Emite los atributos beyondnet.effect.type/target/ref. + /// + void RecordEffect( + string effectType, + string target, + string? reference, + EffectReversibility reversibility); + + /// Marca explícitamente el estado (p. ej. WAITING, RETRYING). + void MarkState(TransactionState state); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/IIntegrationEventPublisher.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/IIntegrationEventPublisher.cs new file mode 100644 index 00000000..fe2929ad --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/IIntegrationEventPublisher.cs @@ -0,0 +1,15 @@ +using Ums.Domain.Events; + +namespace Ums.Application.Common.Interfaces; + +/// +/// ADR-0098 D7: puerto para publicar eventos de INTEGRACIÓN explícitos hacia el bróker +/// inter-sistema, a través del Transactional Outbox. Es la ÚNICA vía por la que un mensaje sale del +/// proceso hacia el transporte. Los eventos de dominio se manejan en proceso (MediatR, post-commit) +/// y nunca usan este puerto: publicarlos crudos convertiría el modelo interno en contrato público +/// y congelaría el dominio (D7/D9.6). +/// +public interface IIntegrationEventPublisher +{ + Task PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/ILimitadorDePeticiones.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/ILimitadorDePeticiones.cs new file mode 100644 index 00000000..195d72dd --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/ILimitadorDePeticiones.cs @@ -0,0 +1,30 @@ +namespace Ums.Application.Common.Interfaces; + +/// +/// Resultado de contar una petición contra su cupo. +/// +/// Falso cuando la petición excede el cupo de la ventana en curso. +/// Peticiones contadas en la ventana, incluida esta. +/// Lo que falta para que la ventana se renueve. Viaja en `Retry-After`. +public readonly record struct ResultadoDelLimite(bool Permitida, long Consumidas, TimeSpan EsperaSugerida); + +/// +/// Cuenta peticiones por clave dentro de una ventana fija (G-248). +/// +/// POR QUÉ EXISTE. El limitador de ASP.NET es un PartitionedRateLimiter en proceso: +/// cada réplica cuenta las suyas sin saber de las demás, así que con N réplicas el cupo efectivo +/// es N veces el declarado. Con una réplica no se notaba; al escalar, la protección se diluye justo +/// cuando más hace falta, y en silencio. +/// +/// La ventana es FIJA y no deslizante, igual que la que sustituye. Una deslizante es más +/// justa en el borde —con la fija, un cliente puede gastar el cupo al final de una ventana y otro +/// tanto al principio de la siguiente—, pero exige guardar cada marca de tiempo por clave en vez de +/// un contador. El comportamiento anterior era de ventana fija: cambiarlo aquí mezclaría dos +/// cambios en uno y haría imposible saber a cuál atribuir una diferencia de medición. +/// +public interface ILimitadorDePeticiones +{ + /// Cuenta una petición de y dice si cabe en el cupo. + Task RegistrarAsync( + string clave, int cupo, TimeSpan ventana, CancellationToken ct = default); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/ISessionRevocationStore.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/ISessionRevocationStore.cs new file mode 100644 index 00000000..464e67ba --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/ISessionRevocationStore.cs @@ -0,0 +1,35 @@ +namespace Ums.Application.Common.Interfaces; + +/// +/// Lista de sesiones cerradas, por SESIÓN y no por usuario (G-247). +/// +/// POR QUÉ EXISTE. Cerrar sesión no cerraba nada: SignOutAsync solo le pide al +/// navegador que borre la cookie, y el portador seguía siendo criptográficamente válido hasta +/// caducar. Medido en vivo: tras el logout, la misma cookie seguía devolviendo 200 — también contra +/// el mismo pod, así que no era un problema de réplicas. Quien tuviera una copia —una máquina +/// compartida, una captura de red— conservaba el acceso. +/// +/// POR QUÉ NO SE REUSÓ . Ese almacén revoca por USUARIO y +/// ventana de tiempo: sirve para «esta cuenta queda fuera» (bloqueo, borrado, cambio de +/// contraseña), y usarlo en el logout cerraría también la sesión del móvil y la del portátil del +/// mismo usuario. La decisión de producto fue la contraria: cerrar sesión cierra solo este +/// dispositivo. Son dos preguntas distintas —«¿está vetada la cuenta?» y «¿sigue viva esta +/// sesión?»— y mezclarlas en una clave obligaría a elegir una de las dos semánticas. +/// +/// La implementación va sobre IDistributedCache, que es Redis cuando está configurado: +/// una sesión cerrada en un pod queda cerrada en todos, que es lo que exige más de una réplica. +/// +public interface ISessionRevocationStore +{ + /// + /// Cierra la sesión indicada hasta . + /// + /// El plazo no es decorativo: una entrada eterna haría crecer la lista sin límite, y una + /// demasiado corta reabriría la sesión antes de que el portador caduque. Se pasa el instante en + /// que el portador deja de ser válido por sí mismo — a partir de ahí, recordarlo no aporta. + /// + Task RevocarAsync(string sessionId, DateTime cerrarHastaUtc, CancellationToken ct = default); + + /// ¿Está cerrada esta sesión? Se consulta en cada petición autenticada. + Task EstaRevocadaAsync(string sessionId, CancellationToken ct = default); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/ITransactionLocatorFactory.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/ITransactionLocatorFactory.cs new file mode 100644 index 00000000..52532c73 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/ITransactionLocatorFactory.cs @@ -0,0 +1,16 @@ +namespace Ums.Application.Common.Interfaces; + +/// +/// Acuña el localizador legible de una transacción funcional en el formato +/// TX-AAAA-NNNNNN (ADR-0096 §2.2; decisión de implementación en ADR-UMS-084: +/// secuencia PostgreSQL por año). El localizador es comunicable por voz o chat y es lo +/// que el usuario pega en el ticket de soporte cuando algo falla. +/// +public interface ITransactionLocatorFactory +{ + /// + /// Devuelve el siguiente localizador del año en curso. Cada llamada consume un valor + /// de la secuencia (escritura barata, monotónica y no transaccional). + /// + Task NextAsync(CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/IUnitOfWorkScope.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/IUnitOfWorkScope.cs index 10a6bf1e..a41f0a58 100644 --- a/src/apps/ums.api/Ums.Application/Common/Interfaces/IUnitOfWorkScope.cs +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/IUnitOfWorkScope.cs @@ -21,6 +21,15 @@ public interface IUnitOfWorkScope { /// Abre una nueva transacción de base de datos. Task BeginAsync(CancellationToken cancellationToken = default); + + /// + /// G-117: ejecuta dentro de una transacción gestionada por la + /// ExecutionStrategy del proveedor. Necesario cuando EnableRetryOnFailure está + /// activo (NpgsqlRetryingExecutionStrategy): una transacción iniciada por el usuario vía + /// lanza «does not support user-initiated transactions». La estrategia + /// hace begin+commit y reintenta el bloque completo ante fallos transitorios (con rollback previo). + /// + Task ExecuteInTransactionAsync(Func operation, CancellationToken cancellationToken = default); } /// Representa una transacción abierta; dispone (rollback) si no se hace Commit. diff --git a/src/apps/ums.api/Ums.Application/Common/Notifications/NotificationTemplates.cs b/src/apps/ums.api/Ums.Application/Common/Notifications/NotificationTemplates.cs index 7eb59ca3..eb493744 100644 --- a/src/apps/ums.api/Ums.Application/Common/Notifications/NotificationTemplates.cs +++ b/src/apps/ums.api/Ums.Application/Common/Notifications/NotificationTemplates.cs @@ -2,6 +2,61 @@ namespace Ums.Application.Common.Notifications; public static class NotificationTemplates { + /// + /// Solicitud anónima de restablecimiento. El secreto viaja SOLO por aquí —el buzón— porque + /// es justo lo que prueba la posesión de la cuenta; la respuesta HTTP no lo lleva. La cuenta + /// conserva su contraseña actual hasta que este token se canjea, de modo que una solicitud + /// ajena molesta pero no deja a nadie fuera. + /// + public static UmsNotification PasswordResetRequested( + string recipient, + string recipientName, + string resetToken, + int expiresInMinutes) => + new( + Recipient: recipient, + Subject: "Restablecimiento de Contraseña — UMS", + Body: $""" + Hola {recipientName}, + + Recibimos una solicitud para restablecer la contraseña de su cuenta. + + Código de restablecimiento: + + {resetToken} + + Ingréselo en la pantalla de recuperación del portal para definir su nueva contraseña. + El código vence en {expiresInMinutes} minutos y solo puede usarse una vez. + + Su contraseña actual sigue vigente: si no solicitó este cambio, ignore este mensaje + y no será necesario hacer nada. Si recibe estos avisos con frecuencia, avise a su + administrador. + + — Equipo UMS + """, + RecipientName: recipientName + ); + + /// + /// Aviso posterior al canje. Es la única señal que recibe el titular legítimo si alguien + /// llegó a canjear un token con su buzón comprometido. + /// + public static UmsNotification PasswordChanged(string recipient, string recipientName) => + new( + Recipient: recipient, + Subject: "Su contraseña fue actualizada — UMS", + Body: $""" + Hola {recipientName}, + + La contraseña de su cuenta acaba de ser actualizada y sus sesiones activas se cerraron. + + Si no fue usted, contacte a su administrador de inmediato. + + — Equipo UMS + """, + RecipientName: recipientName + ); + public static UmsNotification PasswordReset(string recipient, string recipientName, string temporaryPassword) => new( Recipient: recipient, @@ -93,7 +148,7 @@ La solicitud de registro de {companyName} en la plataforma UMS fue recibida. Nuestro equipo la revisará y se pondrá en contacto a la brevedad para completar el proceso de onboarding. - — Equipo BeyondNet Code + — Equipo BeyondNet """ ); @@ -198,7 +253,7 @@ Su organización {companyName} fue incorporada exitosamente a la plataforma UMS. Por seguridad, cambie la contraseña en su primer inicio de sesión. - — Equipo BeyondNet Code + — Equipo BeyondNet """ ); } diff --git a/src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs b/src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs index 8a6a408f..3cef07a1 100644 --- a/src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs +++ b/src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs @@ -25,9 +25,12 @@ public TenantScopePolicy( public Guid? ResolveQueryScope() { + // Internal admins have cross-tenant visibility: no scope filter (null = all tenants). + // When an internal admin narrows to a specific tenant, the request no longer carries + // the internal-admin flag, so it falls through to the scoped branches below. if (_tenantContext.IsInternalAdmin) { - return _tenantContext.OrganizationId; + return null; } if (_tenantContext.OrganizationId.HasValue) @@ -46,6 +49,25 @@ public async Task EnsureManagementOwnerScopeAsync(Guid targetTenantId, C return Result.Failure("AUTH_013: Tenant context is required for management access."); } + // ADR-0077 aclarado (evolith-core#18): el flag IsManagementOwner autoriza al OPERADOR + // que actúa, no al inquilino OBJETIVO de la acción. Un internal-admin (operador de + // gestión) puede ejecutar comandos mutantes sobre cualquier inquilino gestionado + // (on-behalf). Se re-apunta el scope del request al inquilino objetivo para que las + // lecturas de repositorio dentro de este comando resuelvan los datos del objetivo + // (los usuarios/roles cliente están filtrados por OrganizationId y no hacen bypass + // para el internal-admin). La escritura queda auditada por el aspecto [AuditTrail]. + if (_tenantContext.IsInternalAdmin) + { + var targetTenant = await _tenantRepository.GetByIdAsync(targetTenantId, cancellationToken); + if (targetTenant is null) + { + return Result.Failure("AUTH_002: Tenant not found."); + } + + _tenantContext.SetOrganizationId(targetTenantId); + return Result.Success(); + } + if (currentTenantId.Value != targetTenantId) { return Result.Failure( diff --git a/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/CreateAppConfigurationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/CreateAppConfigurationCommandHandler.cs index a40d2f09..fc19bcd3 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/CreateAppConfigurationCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/CreateAppConfigurationCommandHandler.cs @@ -38,7 +38,8 @@ public async Task> Handle(CreateAppConfig code.GetValue(), cancellationToken); - if (existing is not null) + // CFG-06: an Archived (scope, code) must not block creating a new configuration. + if (existing is not null && existing.Props.Status.Id != ConfigStatus.Archived.Id) { return Result.Failure("App configuration code already exists for the selected scope."); } diff --git a/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommand.cs b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommand.cs new file mode 100644 index 00000000..141f4ea0 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommand.cs @@ -0,0 +1,5 @@ +namespace Ums.Application.Configuration.AppConfiguration.Commands; + +// G-143: borrado duro de una configuración de aplicación (DELETE /app-configurations/{id}). +// A diferencia de Archive (que sólo cambia el estado), este comando ELIMINA la fila. +public sealed record DeleteAppConfigurationCommand(Guid AppConfigurationId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommandHandler.cs new file mode 100644 index 00000000..69b15a9b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommandHandler.cs @@ -0,0 +1,60 @@ + +namespace Ums.Application.Configuration.AppConfiguration.Commands; + +using Ums.Application.Configuration.Services; +using Ums.Domain.Configuration; + +public sealed class DeleteAppConfigurationCommandHandler : ICommandHandler +{ + private readonly IAppConfigurationRepository _repository; + private readonly IUserContext _userContext; + private readonly IConfigurationProvider _configProvider; + + public DeleteAppConfigurationCommandHandler( + IAppConfigurationRepository repository, + IUserContext userContext, + IConfigurationProvider configProvider) + { + _repository = repository; + _userContext = userContext; + _configProvider = configProvider; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteAppConfigurationCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required."); + } + + // Las lecturas del repositorio ya ocultan lo eliminado: si la configuración ya se borró, + // esto devuelve null y el DELETE responde «no encontrada» (mismo 404 que un id inexistente). + var appConfiguration = await _repository.GetByIdAsync(request.AppConfigurationId, cancellationToken); + if (appConfiguration is null) + { + return Result.Failure("App configuration was not found."); + } + + // Borrado LÓGICO: la fila NO se elimina, pasa al estado terminal Deleted. AppConfiguration es + // un agregado hoja (sin valores hijos), así que no hay dependientes que exigir eliminados antes. + var result = appConfiguration.Delete(ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(appConfiguration, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // La configuración deja de resolver aunque su fila siga ahí: la caché tiene que enterarse igual. + var tenantId = appConfiguration.Props.TenantId?.GetValue(); + if (tenantId.HasValue) + await _configProvider.ReloadTenantAsync(tenantId.Value, cancellationToken); + else + await _configProvider.ReloadAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Configuration/IdpConfiguration/Commands/CreateIdpConfigurationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Configuration/IdpConfiguration/Commands/CreateIdpConfigurationCommandHandler.cs index cec152da..32bb5b1a 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/IdpConfiguration/Commands/CreateIdpConfigurationCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/IdpConfiguration/Commands/CreateIdpConfigurationCommandHandler.cs @@ -5,6 +5,9 @@ namespace Ums.Application.Configuration.IdpConfiguration.Commands; using Ums.Domain.Configuration; using Ums.Domain.Enums; +using Ums.Application.Common.Aop; + +[AuthorizationAspect(ResourceCode = "idpconfiguration", ActionCode = "create")] public sealed class CreateIdpConfigurationCommandHandler : ICommandHandler { private readonly IIdpConfigurationRepository _repository; @@ -31,6 +34,19 @@ public async Task> Handle(CreateIdpConfig return Result.Failure("Invalid identity provider type."); } + // Integridad referencial: un FallbackToId debe apuntar a una IdpConfiguration existente + // del mismo TenantId/SystemSuiteId; de lo contrario se rechaza la referencia colgante. + if (request.FallbackToId is Guid fallbackToId) + { + var fallback = await _repository.GetByIdAsync(fallbackToId, cancellationToken); + if (fallback is null + || fallback.Props.TenantId.GetValue() != request.TenantId + || fallback.Props.SystemSuiteId.GetValue() != request.SystemSuiteId) + { + return Result.Failure(DomainErrors.Configuration.IdpFallbackNotFound); + } + } + var result = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration.Create( TenantId.Load(request.TenantId), SystemSuiteId.Load(request.SystemSuiteId), diff --git a/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterDefinitionCommands.cs b/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterDefinitionCommands.cs index 2a6ab59d..4c97b552 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterDefinitionCommands.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterDefinitionCommands.cs @@ -23,12 +23,12 @@ public sealed class CreateParameterDefinitionCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task> Handle(CreateParameterDefinitionCommand cmd, CancellationToken ct) + public async Task> Handle(CreateParameterDefinitionCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var count = await repo.CountByCodeAsync(cmd.Code.ToUpperInvariant(), ct); + var count = await repo.CountByCodeAsync(cmd.Code.ToUpperInvariant(), cancellationToken); if (count > 0) return Result.Failure(DomainErrors.Configuration.ParameterCodeNotUnique); @@ -46,8 +46,8 @@ public async Task> Handle(CreateParameterDefinitionCommand cmd, Can if (result.IsFailure) return Result.Failure(result.Error); - await repo.AddAsync(result.Value, ct); - await repo.SaveChangesAsync(ct); + await repo.AddAsync(result.Value, cancellationToken); + await repo.SaveChangesAsync(cancellationToken); return Result.Success(result.Value.Props.Id.GetValue()); } } @@ -71,12 +71,12 @@ public sealed class UpdateParameterDefinitionCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateParameterDefinitionCommand cmd, CancellationToken ct) + public async Task Handle(UpdateParameterDefinitionCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var definition = await repo.GetByIdAsync(cmd.Id, ct); + var definition = await repo.GetByIdAsync(cmd.Id, cancellationToken); if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); var result = definition.Update( @@ -89,8 +89,8 @@ public async Task Handle(UpdateParameterDefinitionCommand cmd, Cancellat if (result.IsFailure) return result; - await repo.UpdateAsync(definition, ct); - await repo.SaveChangesAsync(ct); + await repo.UpdateAsync(definition, cancellationToken); + await repo.SaveChangesAsync(cancellationToken); return Result.Success(); } } @@ -106,22 +106,74 @@ public sealed class ArchiveParameterDefinitionCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(ArchiveParameterDefinitionCommand cmd, CancellationToken ct) + public async Task Handle(ArchiveParameterDefinitionCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var definition = await repo.GetByIdAsync(cmd.Id, ct); + var definition = await repo.GetByIdAsync(cmd.Id, cancellationToken); if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); - var globalCount = await repo.CountGlobalValuesAsync(cmd.Id, ct); - var tenantCount = await repo.CountTenantValuesAsync(cmd.Id, ct); + // Solo bloquean los dependientes VIVOS; uno ya eliminado lógicamente dejó de ser referencia. + var globalCount = await repo.CountLiveGlobalValuesAsync(cmd.Id, cancellationToken); + var tenantCount = await repo.CountLiveTenantValuesAsync(cmd.Id, cancellationToken); var result = definition.Archive(ActorId.Create(userContext.UserId), globalCount, tenantCount); if (result.IsFailure) return result; - await repo.UpdateAsync(definition, ct); - await repo.SaveChangesAsync(ct); + await repo.UpdateAsync(definition, cancellationToken); + await repo.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} + +// ── Delete (borrado LÓGICO) ─────────────────────────────────────────────────── +// El frontend invoca DELETE /parameter-definitions/{id} y el contrato HTTP no cambia (204 en +// éxito, 409 con dependientes, 404 si no existe). Lo que cambia es que la fila NO se borra: se +// marca como eliminada y desaparece de las lecturas. Motivo: sobre esta configuración se hacen +// consultas históricas, y una fila borrada de verdad se pierde para siempre — una definición +// retirada hace un año tiene que seguir explicando por qué el sistema se comportó como se comportó. +// +// Regla transaccional (análoga al RESTRICT de una FK): no se elimina lógicamente algo con +// referencias REALES vivas. Los valores globales/de inquilino ya eliminados lógicamente NO cuentan. + +public sealed record DeleteParameterDefinitionCommand(Guid Id) : ICommand; + +public sealed class DeleteParameterDefinitionCommandHandler( + IParameterDefinitionRepository repo, + IUserContext userContext) + : ICommandHandler +{ + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteParameterDefinitionCommand cmd, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + // GetByIdAsync ya no devuelve definiciones eliminadas → un segundo DELETE responde 404. + var definition = await repo.GetByIdAsync(cmd.Id, cancellationToken); + if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); + + var globalCount = await repo.CountLiveGlobalValuesAsync(cmd.Id, cancellationToken); + var tenantCount = await repo.CountLiveTenantValuesAsync(cmd.Id, cancellationToken); + + if (globalCount > 0 || tenantCount > 0) + { + // 409 con el detalle de qué bloquea: el actor debe eliminar antes esos valores. + var deps = new List(); + if (globalCount > 0) deps.Add(new BlockingDependency("ParameterGlobalValue", "Active", globalCount)); + if (tenantCount > 0) deps.Add(new BlockingDependency("ParameterTenantValue", "Active", tenantCount)); + return Result.Failure( + BlockedOperationError.Encode(DomainErrors.Configuration.ParameterHasActiveValues, deps)); + } + + // La guarda se repite dentro del agregado: la invariante vive en el dominio, no en el handler. + var result = definition.Delete(ActorId.Create(userContext.UserId), globalCount, tenantCount); + if (result.IsFailure) return result; + + await repo.UpdateAsync(definition, cancellationToken); + await repo.SaveChangesAsync(cancellationToken); return Result.Success(); } } diff --git a/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterValueCommands.cs b/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterValueCommands.cs index 87408d9c..46a5aaa5 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterValueCommands.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterValueCommands.cs @@ -18,15 +18,15 @@ public sealed class CreateParameterGlobalValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task> Handle(CreateParameterGlobalValueCommand cmd, CancellationToken ct) + public async Task> Handle(CreateParameterGlobalValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var definition = await definitionRepo.GetByIdAsync(cmd.DefinitionId, ct); + var definition = await definitionRepo.GetByIdAsync(cmd.DefinitionId, cancellationToken); if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); - var existing = await valueRepo.GetByDefinitionIdAsync(cmd.DefinitionId, ct); + var existing = await valueRepo.GetByDefinitionIdAsync(cmd.DefinitionId, cancellationToken); if (existing is not null) return Result.Failure("A global value already exists for this parameter. Use Update instead."); @@ -38,8 +38,8 @@ public async Task> Handle(CreateParameterGlobalValueCommand cmd, Ca if (result.IsFailure) return Result.Failure(result.Error); - await valueRepo.AddAsync(result.Value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.AddAsync(result.Value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(result.Value.Props.Id.GetValue()); } } @@ -56,22 +56,22 @@ public sealed class UpdateParameterGlobalValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateParameterGlobalValueCommand cmd, CancellationToken ct) + public async Task Handle(UpdateParameterGlobalValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var value = await valueRepo.GetByIdAsync(cmd.Id, ct); + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); if (value is null) return Result.Failure(DomainErrors.Common.NotFound); - var definition = await definitionRepo.GetByIdAsync(value.ParameterDefinitionId.GetValue(), ct); + var definition = await definitionRepo.GetByIdAsync(value.ParameterDefinitionId.GetValue(), cancellationToken); var dataType = definition?.DataType ?? ParameterDataType.String; var result = value.UpdateValue(EffectiveValue.Create(cmd.Value), dataType, ActorId.Create(userContext.UserId)); if (result.IsFailure) return result; - await valueRepo.UpdateAsync(value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(); } } @@ -87,19 +87,19 @@ public sealed class PublishParameterGlobalValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(PublishParameterGlobalValueCommand cmd, CancellationToken ct) + public async Task Handle(PublishParameterGlobalValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var value = await valueRepo.GetByIdAsync(cmd.Id, ct); + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); if (value is null) return Result.Failure(DomainErrors.Common.NotFound); var result = value.Publish(ActorId.Create(userContext.UserId)); if (result.IsFailure) return result; - await valueRepo.UpdateAsync(value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(); } } @@ -115,19 +115,50 @@ public sealed class ArchiveParameterGlobalValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(ArchiveParameterGlobalValueCommand cmd, CancellationToken ct) + public async Task Handle(ArchiveParameterGlobalValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var value = await valueRepo.GetByIdAsync(cmd.Id, ct); + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); if (value is null) return Result.Failure(DomainErrors.Common.NotFound); var result = value.Archive(ActorId.Create(userContext.UserId)); if (result.IsFailure) return result; - await valueRepo.UpdateAsync(value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} + +// ── ParameterGlobalValue: Delete (borrado LÓGICO) ──────────────────────────── +// Sin esta ruta la regla transaccional del borrado de definiciones sería un callejón sin salida: +// exige eliminar antes los dependientes, y no habría forma de eliminarlos. Archivar no basta —una +// fila archivada sigue siendo una referencia real—, así que hace falta el estado terminal Deleted. + +public sealed record DeleteParameterGlobalValueCommand(Guid Id) : ICommand; + +public sealed class DeleteParameterGlobalValueCommandHandler( + IParameterGlobalValueRepository valueRepo, + IUserContext userContext) + : ICommandHandler +{ + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteParameterGlobalValueCommand cmd, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); + if (value is null) return Result.Failure(DomainErrors.Common.NotFound); + + var result = value.Delete(ActorId.Create(userContext.UserId)); + if (result.IsFailure) return result; + + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(); } } @@ -147,15 +178,15 @@ public sealed class CreateParameterTenantValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task> Handle(CreateParameterTenantValueCommand cmd, CancellationToken ct) + public async Task> Handle(CreateParameterTenantValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var definition = await definitionRepo.GetByIdAsync(cmd.DefinitionId, ct); + var definition = await definitionRepo.GetByIdAsync(cmd.DefinitionId, cancellationToken); if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); - var existing = await valueRepo.GetByTenantAndDefinitionAsync(cmd.TenantId, cmd.DefinitionId, ct); + var existing = await valueRepo.GetByTenantAndDefinitionAsync(cmd.TenantId, cmd.DefinitionId, cancellationToken); if (existing is not null) return Result.Failure("A tenant value already exists for this parameter and tenant."); @@ -169,8 +200,8 @@ public async Task> Handle(CreateParameterTenantValueCommand cmd, Ca if (result.IsFailure) return Result.Failure(result.Error); - await valueRepo.AddAsync(result.Value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.AddAsync(result.Value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(result.Value.Props.Id.GetValue()); } } @@ -187,23 +218,51 @@ public sealed class UpdateParameterTenantValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateParameterTenantValueCommand cmd, CancellationToken ct) + public async Task Handle(UpdateParameterTenantValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var value = await valueRepo.GetByIdAsync(cmd.Id, ct); + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); if (value is null) return Result.Failure(DomainErrors.Common.NotFound); - var definition = await definitionRepo.GetByIdAsync(value.ParameterDefinitionId.GetValue(), ct); + var definition = await definitionRepo.GetByIdAsync(value.ParameterDefinitionId.GetValue(), cancellationToken); var dataType = definition?.DataType ?? ParameterDataType.String; var scope = definition?.Scope ?? ParameterScope.GlobalAndTenant; var result = value.UpdateValue(OverrideValue.Create(cmd.Value), dataType, scope, ActorId.Create(userContext.UserId)); if (result.IsFailure) return result; - await valueRepo.UpdateAsync(value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} + +// ── ParameterTenantValue: Delete (borrado LÓGICO) ──────────────────────────── + +public sealed record DeleteParameterTenantValueCommand(Guid Id) : ICommand; + +public sealed class DeleteParameterTenantValueCommandHandler( + IParameterTenantValueRepository valueRepo, + IUserContext userContext) + : ICommandHandler +{ + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteParameterTenantValueCommand cmd, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); + if (value is null) return Result.Failure(DomainErrors.Common.NotFound); + + var result = value.Delete(ActorId.Create(userContext.UserId)); + if (result.IsFailure) return result; + + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(); } } diff --git a/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationAuditService.cs b/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationAuditService.cs index f4309346..a216e4cb 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationAuditService.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationAuditService.cs @@ -2,6 +2,30 @@ namespace Ums.Application.Configuration.Services; using Ums.Application.Common.Aop; +/// +/// Registra en la pista de auditoría los cambios de parámetros de configuración +/// (crear/modificar/eliminar/override), emitiéndolos por . +/// +/// +/// G-105 (residual de G-040#5) — redacción de nivel-valor por clasificación. El saneador +/// redacta por nombre de clave y NO cubre este caso: aquí +/// el valor de un parámetro aterriza bajo las claves PreviousValue/NewValue, que no son +/// nombres sensibles. Si el parámetro está clasificado como cifrado/secreto +/// (, +/// la misma bandera que cifra el valor en reposo y lo redacta por REST), su valor en claro se filtraría +/// a la traza. Es una fuga de nivel-valor gobernada por la clasificación del parámetro, +/// no por el nombre de la clave. Por eso, cuando el parámetro es secreto, su valor anterior y nuevo se +/// sustituyen por ANTES de escribirlos en la +/// metadata. La traza es append-only e inmutable (G-081): un secreto filtrado a ella no se puede borrar +/// después. +/// +/// +/// +/// Los parámetros no secretos siguen registrando su valor en claro: la auditoría de cambios de +/// configuración sigue siendo útil. Esto complementa —no duplica— al saneador por-clave: aquel +/// es por nombre de clave; éste es por clasificación de nivel-valor. +/// +/// public sealed class ConfigurationAuditService { private readonly IAuditTrailSink _auditTrailSink; @@ -11,21 +35,28 @@ public ConfigurationAuditService(IAuditTrailSink auditTrailSink) _auditTrailSink = auditTrailSink; } - public void RecordConfigurationChange( + public async Task RecordConfigurationChangeAsync( Guid userId, string parameterCode, Guid? tenantId, string? previousValue, string? newValue, string operationType, - Guid rootTenantId) + Guid rootTenantId, + bool isEncrypted, + CancellationToken cancellationToken = default) { + // G-105: el resultado (DELETED/MODIFIED) se deriva del valor ORIGINAL para preservar la + // semántica; la redacción sólo afecta a lo que se persiste como metadata, no a la clasificación + // del evento. + var auditResult = string.IsNullOrEmpty(newValue) ? "DELETED" : "MODIFIED"; + var entry = new AuditTrailEntry( WhoActed: userId, SubjectType: "AppConfiguration", WhatChanged: $"Parameter '{parameterCode}' {operationType}", EventType: operationType, - AuditResult: string.IsNullOrEmpty(newValue) ? "DELETED" : "MODIFIED", + AuditResult: auditResult, AffectedEntityId: Guid.Empty, AffectedEntityType: "AppConfiguration", RootTenantId: rootTenantId, @@ -33,29 +64,39 @@ public void RecordConfigurationChange( { ParameterCode = parameterCode, TenantId = tenantId, - PreviousValue = previousValue, - NewValue = newValue, + PreviousValue = RedactIfSecret(previousValue, isEncrypted), + NewValue = RedactIfSecret(newValue, isEncrypted), Operation = operationType })); - _auditTrailSink.TryWrite(entry); + await _auditTrailSink.PublishAsync(entry, cancellationToken); } - public void RecordParameterOverride( + public Task RecordParameterOverrideAsync( Guid userId, string parameterCode, Guid tenantId, string? previousValue, string? newValue, - Guid rootTenantId) - { - RecordConfigurationChange( + Guid rootTenantId, + bool isEncrypted, + CancellationToken cancellationToken = default) + => RecordConfigurationChangeAsync( userId, parameterCode, tenantId, previousValue, newValue, "OVERRIDE", - rootTenantId); - } -} \ No newline at end of file + rootTenantId, + isEncrypted, + cancellationToken); + + // G-105: si el parámetro está clasificado como cifrado/secreto, su valor no debe aterrizar en claro + // en la traza inmutable → se redacta. Un valor nulo/vacío no tiene nada que ocultar (y redactarlo + // fingiría que existía un valor), así que se deja tal cual; sólo se redacta lo que sí es contenido. + private static string? RedactIfSecret(string? value, bool isEncrypted) + => isEncrypted && !string.IsNullOrEmpty(value) + ? AuditMetadataSanitizer.RedactionPlaceholder + : value; +} diff --git a/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationValues.cs b/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationValues.cs index b44e670e..1b686518 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationValues.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationValues.cs @@ -20,6 +20,9 @@ public ConfigurationValues(IConfigurationProvider provider, Guid? tenantId = nul public int MaxLoginAttempts => _provider.GetValueAs(AppConfigurationCodes.MaxLoginAttempts, _tenantId, AppConfigurationDefaults.MaxLoginAttempts); + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos (jerarquía Global>Suite>Tenant>Module). + public int AccountLockoutDurationMinutes => _provider.GetValueAs(AppConfigurationCodes.AccountLockoutDurationMinutes, _tenantId, AppConfigurationDefaults.AccountLockoutDurationMinutes); + public int AccessTokenDurationMs => _provider.GetValueAs(AppConfigurationCodes.AccessTokenDurationMs, _tenantId, AppConfigurationDefaults.AccessTokenDurationMs); public int RefreshTokenDurationMs => _provider.GetValueAs(AppConfigurationCodes.RefreshTokenDurationMs, _tenantId, AppConfigurationDefaults.RefreshTokenDurationMs); diff --git a/src/apps/ums.api/Ums.Application/DependencyInjection.cs b/src/apps/ums.api/Ums.Application/DependencyInjection.cs index b51927ec..2d64a411 100644 --- a/src/apps/ums.api/Ums.Application/DependencyInjection.cs +++ b/src/apps/ums.api/Ums.Application/DependencyInjection.cs @@ -5,6 +5,7 @@ namespace Ums.Application; using Microsoft.Extensions.DependencyInjection; using Ums.Application.Common.Behaviors; using Ums.Application.Common.Interfaces; +using Ums.Application.IGA.Services; public static class DependencyInjection { @@ -18,6 +19,10 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddScoped(); services.AddScoped(); + // IGA (ADR-UMS-093): heurística versionada de RiskScore. La infraestructura puede sustituirla + // por una implementación respaldada por el grafo de autorización (ADR-UMS-088) sin tocar handlers. + services.AddScoped(); + return services; } } diff --git a/src/apps/ums.api/Ums.Application/IGA/Common/IgaHandlerGuards.cs b/src/apps/ums.api/Ums.Application/IGA/Common/IgaHandlerGuards.cs new file mode 100644 index 00000000..c538ea0f --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/Common/IgaHandlerGuards.cs @@ -0,0 +1,57 @@ +namespace Ums.Application.IGA.Common; + +/// +/// Guardas compartidas por los handlers del contexto acotado IGA (ADR-UMS-093): +/// autenticación, acotación por inquilino y segregación de funciones (SoD) a nivel de aplicación. +/// La SoD también es invariante de dominio (INV-RPR3); aquí se hace cumplir antes de tocar +/// el agregado para devolver un fallo temprano y legible, cerrando el hueco que la auditoría halló +/// en Aprobaciones (el actor no puede aprobar/ejecutar/verificar su propia promoción). +/// +internal static class IgaHandlerGuards +{ + /// Exige un usuario autenticado y devuelve su identificador como . + public static Result RequireAuthenticatedUser(IUserContext userContext) + { + if (string.IsNullOrWhiteSpace(userContext.UserId)) + { + return Result.Failure("Se requiere un usuario autenticado para operar sobre una promoción de rol."); + } + + if (!Guid.TryParse(userContext.UserId, out var userId)) + { + return Result.Failure("El identificador del usuario autenticado no es válido."); + } + + return Result.Success(userId); + } + + /// + /// Verifica que el inquilino de la solicitud esté dentro del alcance visible del solicitante. + /// Un administrador interno (alcance nulo = multi-inquilino) puede operar sobre cualquier inquilino; + /// un usuario regular sólo sobre el suyo. + /// + public static Result EnsureTenantInScope(ITenantScopePolicy tenantScopePolicy, Guid tenantId) + { + var scope = tenantScopePolicy.ResolveQueryScope(); + if (scope is null || scope.Value == tenantId) + { + return Result.Success(); + } + + return Result.Failure("La solicitud de promoción pertenece a otro inquilino y está fuera de su alcance."); + } + + /// SoD: el actor no puede coincidir con ninguno de los sujetos indicados (objetivo, aprobador, etc.). + public static Result EnsureSegregation(Guid actorId, params (Guid? Subject, string Message)[] conflicts) + { + foreach (var (subject, message) in conflicts) + { + if (subject is not null && subject.Value == actorId) + { + return Result.Failure(message); + } + } + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/DTOs/CreateRolePromotionRequestResponse.cs b/src/apps/ums.api/Ums.Application/IGA/DTOs/CreateRolePromotionRequestResponse.cs new file mode 100644 index 00000000..e97a4cda --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/DTOs/CreateRolePromotionRequestResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.DTOs; + +/// Respuesta a la creación de una solicitud de promoción de rol: el identificador generado. +public sealed record CreateRolePromotionRequestResponse(Guid RolePromotionRequestId); diff --git a/src/apps/ums.api/Ums.Application/IGA/DTOs/RoleMaturityStatusDto.cs b/src/apps/ums.api/Ums.Application/IGA/DTOs/RoleMaturityStatusDto.cs new file mode 100644 index 00000000..eaa46eaa --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/DTOs/RoleMaturityStatusDto.cs @@ -0,0 +1,19 @@ +namespace Ums.Application.IGA.DTOs; + +/// Proyección de lectura del estado de madurez de un usuario en un rol (IGA, ADR-UMS-093). +public sealed record RoleMaturityStatusDto( + Guid Id, + Guid TenantId, + Guid UserId, + Guid RoleId, + string CurrentMaturityLevel, + string? NextEligibleMaturityLevel, + DateTime AssignedAt, + DateTime CurrentLevelSince, + DateTime? EligibleForPromotionAt, + int CompletedCertificationsCount, + int CompletedTrainingsCount, + decimal PerformanceScore, + bool HasNoComplianceIssues, + string? BlockingFactor, + DateTime? LastReviewedAt); diff --git a/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionMapper.cs b/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionMapper.cs new file mode 100644 index 00000000..2bc5fe85 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionMapper.cs @@ -0,0 +1,42 @@ +namespace Ums.Application.IGA.DTOs; + +using Ums.Domain.IGA.RoleMaturityStatus; +using Ums.Domain.IGA.RolePromotionRequest; + +/// Proyecciones de lectura de los agregados IGA a sus DTO (ADR-UMS-093). +internal static class RolePromotionMapper +{ + public static RolePromotionRequestDto ToDto(RolePromotionRequest request) => + new( + request.GetId().GetValue(), + request.TenantId.GetValue(), + request.TargetUserId.GetValue(), + request.RequesterId.GetValue(), + request.CurrentRoleId.GetValue(), + request.TargetRoleId.GetValue(), + request.Status.Name, + request.RiskScore?.GetValue(), + request.ApproverId?.GetValue(), + request.SecurityReviewerId?.GetValue(), + request.ExecutorId?.GetValue(), + request.VerifierId?.GetValue(), + request.DecisionReason); + + public static RoleMaturityStatusDto ToDto(RoleMaturityStatus status) => + new( + status.GetId().GetValue(), + status.TenantId.GetValue(), + status.UserId.GetValue(), + status.RoleId.GetValue(), + status.CurrentMaturityLevel.ToString(), + status.NextEligibleMaturityLevel?.ToString(), + status.AssignedAt, + status.CurrentLevelSince, + status.EligibleForPromotionAt, + status.CompletedCertificationsCount, + status.CompletedTrainingsCount, + status.PerformanceScore, + status.HasNoComplianceIssues, + status.BlockingFactor?.GetValue(), + status.LastReviewedAt); +} diff --git a/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionRequestDto.cs b/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionRequestDto.cs new file mode 100644 index 00000000..1f18056b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionRequestDto.cs @@ -0,0 +1,17 @@ +namespace Ums.Application.IGA.DTOs; + +/// Proyección de lectura de una solicitud de promoción de rol (IGA, ADR-UMS-093). +public sealed record RolePromotionRequestDto( + Guid Id, + Guid TenantId, + Guid TargetUserId, + Guid RequesterId, + Guid CurrentRoleId, + Guid TargetRoleId, + string Status, + int? RiskScore, + Guid? ApproverId, + Guid? SecurityReviewerId, + Guid? ExecutorId, + Guid? VerifierId, + string? DecisionReason); diff --git a/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQuery.cs b/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQuery.cs new file mode 100644 index 00000000..db791bcf --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQuery.cs @@ -0,0 +1,11 @@ +namespace Ums.Application.IGA.RoleMaturity.Queries; + +using Ums.Application.IGA.DTOs; + +/// +/// Obtiene el/los estado(s) de madurez de un usuario, acotado por inquilino. Si se indica +/// devuelve el estado de ese rol; en caso contrario, todos los del usuario. +/// IGA, ADR-UMS-093, FR-062. +/// +public sealed record GetRoleMaturityStatusByUserQuery(Guid TenantId, Guid UserId, Guid? RoleId) + : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQueryHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQueryHandler.cs new file mode 100644 index 00000000..e5bc52ec --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQueryHandler.cs @@ -0,0 +1,49 @@ +namespace Ums.Application.IGA.RoleMaturity.Queries; + +using Ums.Application.IGA.Common; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; + +public sealed class GetRoleMaturityStatusByUserQueryHandler + : IQueryHandler> +{ + private readonly IRoleMaturityStatusRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public GetRoleMaturityStatusByUserQueryHandler( + IRoleMaturityStatusRepository repository, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + } + + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task>> Handle( + GetRoleMaturityStatusByUserQuery request, + CancellationToken cancellationToken) + { + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, request.TenantId); + if (scope.IsFailure) + { + return Result>.Failure(scope.Error); + } + + if (request.RoleId is not null) + { + var single = await _repository.GetByUserAndRoleAsync( + request.TenantId, request.UserId, request.RoleId.Value, cancellationToken); + var singleList = single is null + ? new List() + : new List { RolePromotionMapper.ToDto(single) }; + return Result>.Success(singleList); + } + + IReadOnlyList items = + await _repository.GetByUserAsync(request.TenantId, request.UserId, cancellationToken); + + var dtos = items.Select(RolePromotionMapper.ToDto).ToList(); + return Result>.Success(dtos); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommand.cs new file mode 100644 index 00000000..15e8aeca --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// Draft → Cancelled (el solicitante cancela antes de enviar). IGA, ADR-UMS-093, FR-060. +public sealed record CancelRolePromotionCommand(Guid RolePromotionRequestId, string Reason) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommandHandler.cs new file mode 100644 index 00000000..10a39ba4 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommandHandler.cs @@ -0,0 +1,61 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class CancelRolePromotionCommandHandler : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public CancelRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(CancelRolePromotionCommand request, CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // Sólo el solicitante cancela su propia solicitud (ADR-UMS-093, §Máquina de estados). + if (actor.Value != entity.RequesterId.GetValue()) + { + return Result.Failure("Sólo el solicitante puede cancelar la solicitud de promoción."); + } + + var result = entity.Cancel(request.Reason, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommand.cs new file mode 100644 index 00000000..b1edd6c1 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// +/// PendingEligibilityCheck → PendingManagerApproval (elegible) o → Rejected (no elegible, fail-closed). +/// Orquesta RoleMaturityStatus.EvaluateEligibility y aplica el resultado en +/// RolePromotionRequest.ConfirmEligibility (IGA, ADR-UMS-093, FR-062, INV-RPR4). +/// +public sealed record ConfirmRolePromotionEligibilityCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommandHandler.cs new file mode 100644 index 00000000..bfc8832b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommandHandler.cs @@ -0,0 +1,89 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +/// +/// Handler de confirmación de elegibilidad (fail-closed). Consulta el RoleMaturityStatus +/// del usuario objetivo en su rol actual y ejecuta EvaluateEligibility. Cualquier condición +/// no cumplida —incluida la ausencia del estado de madurez— se traduce en no elegible, con +/// lo que la promoción pasa a Rejected y nunca avanza (INV-RPR4). +/// El instante de evaluación se inyecta desde la aplicación (DateTime.UtcNow). +/// +public sealed class ConfirmRolePromotionEligibilityCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly IRoleMaturityStatusRepository _maturityRepository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public ConfirmRolePromotionEligibilityCommandHandler( + IRolePromotionRequestRepository repository, + IRoleMaturityStatusRepository maturityRepository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _maturityRepository = maturityRepository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + ConfirmRolePromotionEligibilityCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + var actorId = ActorId.Create(_userContext.UserId); + + // Fuente de verdad de elegibilidad: el RoleMaturityStatus del objetivo en su rol actual. + var maturity = await _maturityRepository.GetByUserAndRoleAsync( + entity.TenantId.GetValue(), + entity.TargetUserId.GetValue(), + entity.CurrentRoleId.GetValue(), + cancellationToken); + + var isEligible = false; + if (maturity is not null) + { + // EvaluateEligibility muta el agregado sólo en éxito; sólo entonces se persiste. + var eligibility = maturity.EvaluateEligibility(DateTime.UtcNow, actorId); + isEligible = eligibility.IsSuccess; + if (isEligible) + { + await _maturityRepository.UpdateAsync(maturity, cancellationToken); + } + } + + var confirmResult = entity.ConfirmEligibility(isEligible, actorId); + if (confirmResult.IsFailure) + { + return confirmResult; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommand.cs new file mode 100644 index 00000000..31c7eb1c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommand.cs @@ -0,0 +1,13 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.DTOs; + +/// +/// Crea una solicitud de promoción de rol en estado Draft (IGA, ADR-UMS-093, FR-060). +/// El solicitante es el usuario autenticado; no puede coincidir con el usuario objetivo (SoD). +/// +public sealed record CreateRolePromotionRequestCommand( + Guid TenantId, + Guid TargetUserId, + Guid CurrentRoleId, + Guid TargetRoleId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommandHandler.cs new file mode 100644 index 00000000..077c8dc3 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommandHandler.cs @@ -0,0 +1,69 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using Ums.Domain.IGA.RolePromotionRequest; + +public sealed class CreateRolePromotionRequestCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public CreateRolePromotionRequestCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task> Handle( + CreateRolePromotionRequestCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, request.TenantId); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el solicitante no puede promover(se) a sí mismo. + if (actor.Value == request.TargetUserId) + { + return Result.Failure( + "El solicitante no puede ser el usuario objetivo de la promoción (segregación de funciones)."); + } + + var createResult = RolePromotionRequest.Create( + TenantId.Load(request.TenantId), + UserId.Load(request.TargetUserId), + UserId.Load(actor.Value), + RoleId.Load(request.CurrentRoleId), + RoleId.Load(request.TargetRoleId), + ActorId.Create(_userContext.UserId)); + + if (createResult.IsFailure) + { + return Result.Failure(createResult.Error); + } + + await _repository.AddAsync(createResult.Value, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success( + new CreateRolePromotionRequestResponse(createResult.Value.GetId().GetValue())); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommand.cs new file mode 100644 index 00000000..15d45a2c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// Approved → Executed (aplica el cambio de rol). El ejecutor es el usuario autenticado. IGA, ADR-UMS-093, INV-RPR5. +public sealed record ExecuteRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommandHandler.cs new file mode 100644 index 00000000..c3369c67 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommandHandler.cs @@ -0,0 +1,90 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.Events; +using Ums.Domain.IGA; + +public sealed class ExecuteRolePromotionCommandHandler : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + private readonly IIntegrationEventPublisher _integrationEventPublisher; + + public ExecuteRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext, + IIntegrationEventPublisher integrationEventPublisher) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + _integrationEventPublisher = integrationEventPublisher; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(ExecuteRolePromotionCommand request, CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3 endurecida, ADR-UMS-096): el ejecutor no puede ser el objetivo, el aprobador + // ni el revisor de seguridad (espejo de VerifyRolePromotionCommandHandler). + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El ejecutor no puede ser el usuario objetivo (segregación de funciones)."), + (entity.ApproverId?.GetValue(), "El ejecutor no puede ser el aprobador (segregación de funciones)."), + (entity.SecurityReviewerId?.GetValue(), "El ejecutor no puede ser el revisor de seguridad (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.Execute(UserId.Load(actor.Value), ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + + // G-094: el EFECTO de la promoción (reasignar el rol del perfil objetivo) se entrega con + // GARANTÍA por el Transactional Outbox de MassTransit, no por un handoff in-process + // best-effort (G-093). Se publica el evento de integración ANTES de SaveEntitiesAsync: bajo + // kind/prod el bus-outbox EF estaciona el mensaje en el MISMO change set del agregado y lo + // confirma atómicamente con el cambio a Executed; el servicio de entrega lo despacha + // POST-commit al RolePromotionRoleAssignmentConsumer (reintentos + dead-letter). En dev/tests + // (bus en memoria, sin outbox EF) el publish entrega directo al consumidor en proceso. Así el + // efecto nunca se pierde en silencio si el consumidor falla. + await _integrationEventPublisher.PublishAsync( + new RolePromotionExecutedIntegrationEvent( + entity.TenantId.GetValue(), + entity.GetId().GetValue(), + entity.TargetUserId.GetValue(), + entity.CurrentRoleId.GetValue(), + entity.TargetRoleId.GetValue(), + actor.Value), + cancellationToken); + + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommand.cs new file mode 100644 index 00000000..14c6436b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommand.cs @@ -0,0 +1,7 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// +/// PendingManagerApproval → PendingSecurityReview (RiskScore ≥ umbral) o → Approved (RiskScore < umbral). +/// El aprobador es el usuario autenticado (IGA, ADR-UMS-093, FR-060). +/// +public sealed record ManagerApproveRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommandHandler.cs new file mode 100644 index 00000000..653e8cd7 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommandHandler.cs @@ -0,0 +1,72 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; +using Ums.Domain.IGA.RolePromotionRequest; + +public sealed class ManagerApproveRolePromotionCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public ManagerApproveRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + ManagerApproveRolePromotionCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el aprobador no puede ser el objetivo ni el solicitante. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El aprobador no puede ser el usuario objetivo (segregación de funciones)."), + (entity.RequesterId.GetValue(), "El aprobador no puede ser el solicitante (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.ManagerApprove( + UserId.Load(actor.Value), + ActorId.Create(_userContext.UserId), + RolePromotionRequest.DefaultHighRiskThreshold); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommand.cs new file mode 100644 index 00000000..a6acb65c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// PendingManagerApproval → Rejected (el gerente rechaza con motivo). IGA, ADR-UMS-093, FR-060. +public sealed record ManagerRejectRolePromotionCommand(Guid RolePromotionRequestId, string Reason) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommandHandler.cs new file mode 100644 index 00000000..a6307aea --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommandHandler.cs @@ -0,0 +1,68 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class ManagerRejectRolePromotionCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public ManagerRejectRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + ManagerRejectRolePromotionCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): quien decide el rechazo no puede ser el objetivo ni el solicitante. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El aprobador no puede ser el usuario objetivo (segregación de funciones)."), + (entity.RequesterId.GetValue(), "El aprobador no puede ser el solicitante (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.ManagerReject(UserId.Load(actor.Value), request.Reason, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommand.cs new file mode 100644 index 00000000..120f0452 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// PendingSecurityReview → Approved. El revisor es el usuario autenticado. IGA, ADR-UMS-093, FR-060. +public sealed record SecurityApproveRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommandHandler.cs new file mode 100644 index 00000000..f8bda8bb --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommandHandler.cs @@ -0,0 +1,69 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class SecurityApproveRolePromotionCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public SecurityApproveRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + SecurityApproveRolePromotionCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el revisor de seguridad no puede ser el objetivo, el solicitante ni el aprobador. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El revisor de seguridad no puede ser el usuario objetivo (segregación de funciones)."), + (entity.RequesterId.GetValue(), "El revisor de seguridad no puede ser el solicitante (segregación de funciones)."), + (entity.ApproverId?.GetValue(), "El revisor de seguridad no puede ser el aprobador (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.SecurityApprove(UserId.Load(actor.Value), ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommand.cs new file mode 100644 index 00000000..fb855f1b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// PendingSecurityReview → Rejected (seguridad rechaza con motivo). IGA, ADR-UMS-093, FR-060. +public sealed record SecurityRejectRolePromotionCommand(Guid RolePromotionRequestId, string Reason) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommandHandler.cs new file mode 100644 index 00000000..8f8992cb --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommandHandler.cs @@ -0,0 +1,69 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class SecurityRejectRolePromotionCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public SecurityRejectRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + SecurityRejectRolePromotionCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el revisor de seguridad no puede ser el objetivo, el solicitante ni el aprobador. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El revisor de seguridad no puede ser el usuario objetivo (segregación de funciones)."), + (entity.RequesterId.GetValue(), "El revisor de seguridad no puede ser el solicitante (segregación de funciones)."), + (entity.ApproverId?.GetValue(), "El revisor de seguridad no puede ser el aprobador (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.SecurityReject(UserId.Load(actor.Value), request.Reason, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommand.cs new file mode 100644 index 00000000..2e54e09f --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommand.cs @@ -0,0 +1,7 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// +/// Draft → PendingEligibilityCheck (IGA, ADR-UMS-093, FR-061). Calcula el RiskScore vía +/// IRiskScoreCalculator y lo congela en el agregado (INV-RPR2). +/// +public sealed record SubmitRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommandHandler.cs new file mode 100644 index 00000000..10a68ca1 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommandHandler.cs @@ -0,0 +1,86 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Application.IGA.Services; +using Ums.Domain.IGA; + +public sealed class SubmitRolePromotionCommandHandler : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly IRiskScoreCalculator _riskScoreCalculator; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public SubmitRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + IRiskScoreCalculator riskScoreCalculator, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _riskScoreCalculator = riskScoreCalculator; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(SubmitRolePromotionCommand request, CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD: quien envía (calcula y congela el riesgo) no puede ser el usuario objetivo. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El usuario objetivo no puede enviar su propia promoción (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var assessment = await _riskScoreCalculator.CalculateAsync( + new RolePromotionRiskContext( + entity.TenantId.GetValue(), + entity.TargetUserId.GetValue(), + entity.CurrentRoleId.GetValue(), + entity.TargetRoleId.GetValue()), + cancellationToken); + if (assessment.IsFailure) + { + return Result.Failure(assessment.Error); + } + + var riskScoreResult = RiskScore.Create(assessment.Value.Score); + if (riskScoreResult.IsFailure) + { + return Result.Failure(riskScoreResult.Error); + } + + var submitResult = entity.Submit(riskScoreResult.Value, ActorId.Create(_userContext.UserId)); + if (submitResult.IsFailure) + { + return submitResult; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommand.cs new file mode 100644 index 00000000..43f455bf --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// Executed → Verified (verificación post-ejecución por un auditor). IGA, ADR-UMS-093, INV-RPR5. +public sealed record VerifyRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommandHandler.cs new file mode 100644 index 00000000..3866f5e0 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommandHandler.cs @@ -0,0 +1,66 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class VerifyRolePromotionCommandHandler : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public VerifyRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(VerifyRolePromotionCommand request, CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el verificador no puede ser el objetivo, el ejecutor ni el aprobador. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El verificador no puede ser el usuario objetivo (segregación de funciones)."), + (entity.ExecutorId?.GetValue(), "El verificador no puede ser el ejecutor (segregación de funciones)."), + (entity.ApproverId?.GetValue(), "El verificador no puede ser el aprobador (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.Verify(UserId.Load(actor.Value), ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQuery.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQuery.cs new file mode 100644 index 00000000..5ac20d42 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQuery.cs @@ -0,0 +1,6 @@ +namespace Ums.Application.IGA.RolePromotion.Queries; + +using Ums.Application.IGA.DTOs; + +/// Obtiene una solicitud de promoción de rol por su identificador, acotada por inquilino. IGA, ADR-UMS-093. +public sealed record GetRolePromotionRequestByIdQuery(Guid RolePromotionRequestId) : IQuery; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQueryHandler.cs new file mode 100644 index 00000000..0d9ed50a --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQueryHandler.cs @@ -0,0 +1,40 @@ +namespace Ums.Application.IGA.RolePromotion.Queries; + +using Ums.Application.IGA.Common; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; + +public sealed class GetRolePromotionRequestByIdQueryHandler + : IQueryHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public GetRolePromotionRequestByIdQueryHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + } + + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task> Handle( + GetRolePromotionRequestByIdQuery request, + CancellationToken cancellationToken) + { + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + return Result.Success(RolePromotionMapper.ToDto(entity)); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQuery.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQuery.cs new file mode 100644 index 00000000..902038d1 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQuery.cs @@ -0,0 +1,10 @@ +namespace Ums.Application.IGA.RolePromotion.Queries; + +using Ums.Application.IGA.DTOs; + +/// +/// Lista solicitudes de promoción de rol acotadas por inquilino y, opcionalmente, por estado +/// (nombre de RolePromotionStatus). IGA, ADR-UMS-093. +/// +public sealed record ListRolePromotionRequestsQuery(Guid? TenantId, string? Status) + : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQueryHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQueryHandler.cs new file mode 100644 index 00000000..dab0c990 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQueryHandler.cs @@ -0,0 +1,49 @@ +namespace Ums.Application.IGA.RolePromotion.Queries; + +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using Ums.Domain.IGA.RolePromotionRequest; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +public sealed class ListRolePromotionRequestsQueryHandler + : IQueryHandler> +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public ListRolePromotionRequestsQueryHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + } + + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task>> Handle( + ListRolePromotionRequestsQuery request, + CancellationToken cancellationToken) + { + // Acotación por inquilino: un usuario regular queda ceñido a su inquilino; un administrador + // interno (alcance nulo) puede consultar el inquilino solicitado o, en su defecto, todos. + var scope = _tenantScopePolicy.ResolveQueryScope(); + var effectiveTenantId = scope ?? request.TenantId; + + IReadOnlyList items; + if (effectiveTenantId is null) + { + items = await _repository.GetAllAsync(null, cancellationToken); + } + else if (!string.IsNullOrWhiteSpace(request.Status)) + { + items = await _repository.GetByTenantAndStatusAsync(effectiveTenantId.Value, request.Status!, cancellationToken); + } + else + { + items = await _repository.GetByTenantIdAsync(effectiveTenantId.Value, cancellationToken); + } + + var dtos = items.Select(RolePromotionMapper.ToDto).ToList(); + return Result>.Success(dtos); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/Services/HeuristicRiskScoreCalculator.cs b/src/apps/ums.api/Ums.Application/IGA/Services/HeuristicRiskScoreCalculator.cs new file mode 100644 index 00000000..b5319009 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/Services/HeuristicRiskScoreCalculator.cs @@ -0,0 +1,90 @@ +namespace Ums.Application.IGA.Services; + +using Ums.Domain.Authorization; + +/// +/// Implementación heurística versionada (iga-risk-heuristic-v1) del cálculo del RiskScore +/// de una promoción de rol (FR-061, ADR-UMS-093). +/// +/// Fórmula v1 (determinista, acotada a [0, 100]): +/// base (10) +/// + escalación jerárquica = max(0, nivelObjetivo − nivelActual) · 15 (proxy de permisos nuevos) +/// + salto de orden de promoción = min(3, ordenObjetivo − ordenActual) · 10 +/// + sensibilidad del rol objetivo por banda de nivel jerárquico (≥4:30, 3:20, 2:10, resto:5) +/// +/// Limitación conocida (registrada como seguimiento de ADR-UMS-093): esta v1 usa metadatos del rol +/// (nivel jerárquico y orden de promoción) como proxy. Aún NO calcula el diferencial real de +/// permisos nuevos/removidos ni las combinaciones tóxicas (conflictos SoD con los perfiles/roles +/// vigentes del usuario), que requieren el grafo de autorización (ADR-UMS-088). La infraestructura puede +/// sustituir por una implementación respaldada por el grafo. +/// +public sealed class HeuristicRiskScoreCalculator : IRiskScoreCalculator +{ + public const string ModelVersion = "iga-risk-heuristic-v1"; + + private const int BaseWeight = 10; + private const int EscalationWeightPerLevel = 15; + private const int PromotionOrderWeightPerStep = 10; + private const int MaxPromotionOrderSteps = 3; + + private readonly IRoleRepository _roleRepository; + + public HeuristicRiskScoreCalculator(IRoleRepository roleRepository) + { + _roleRepository = roleRepository; + } + + public async Task> CalculateAsync( + RolePromotionRiskContext context, + CancellationToken cancellationToken = default) + { + var currentRole = await _roleRepository.GetByIdAsync(context.CurrentRoleId, cancellationToken); + if (currentRole is null) + { + return Result.Failure("No se encontró el rol actual para calcular el RiskScore."); + } + + var targetRole = await _roleRepository.GetByIdAsync(context.TargetRoleId, cancellationToken); + if (targetRole is null) + { + return Result.Failure("No se encontró el rol objetivo para calcular el RiskScore."); + } + + var factors = new List(); + + factors.Add(new RiskFactor("base", BaseWeight, "Riesgo base de toda promoción de rol.")); + + var escalation = Math.Max(0, targetRole.HierarchyLevel - currentRole.HierarchyLevel); + var escalationWeight = escalation * EscalationWeightPerLevel; + factors.Add(new RiskFactor( + "escalacion_jerarquica", + escalationWeight, + $"Escalación de {escalation} nivel(es) jerárquico(s) (proxy de permisos nuevos).")); + + var promotionSteps = Math.Min(MaxPromotionOrderSteps, Math.Max(0, targetRole.PromotionOrder - currentRole.PromotionOrder)); + var promotionWeight = promotionSteps * PromotionOrderWeightPerStep; + factors.Add(new RiskFactor( + "salto_orden_promocion", + promotionWeight, + $"Salto de {promotionSteps} paso(s) en el orden de promoción.")); + + var sensitivityWeight = SensitivityWeightFor(targetRole.HierarchyLevel); + factors.Add(new RiskFactor( + "sensibilidad_rol_objetivo", + sensitivityWeight, + $"Sensibilidad del rol objetivo por banda de nivel {targetRole.HierarchyLevel}.")); + + var raw = factors.Sum(f => f.Weight); + var score = Math.Clamp(raw, RiskScore.Min, RiskScore.Max); + + return Result.Success(new RiskAssessment(score, ModelVersion, factors)); + } + + private static int SensitivityWeightFor(int hierarchyLevel) => hierarchyLevel switch + { + >= 4 => 30, + 3 => 20, + 2 => 10, + _ => 5, + }; +} diff --git a/src/apps/ums.api/Ums.Application/IGA/Services/IRiskScoreCalculator.cs b/src/apps/ums.api/Ums.Application/IGA/Services/IRiskScoreCalculator.cs new file mode 100644 index 00000000..8307f520 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/Services/IRiskScoreCalculator.cs @@ -0,0 +1,33 @@ +namespace Ums.Application.IGA.Services; + +/// +/// Contexto de cálculo del RiskScore de una promoción de rol (FR-061, ADR-UMS-093). +/// Identifica el inquilino, el usuario objetivo y el par de roles (actual → objetivo) +/// cuyo salto de privilegio se evalúa. +/// +public sealed record RolePromotionRiskContext( + Guid TenantId, + Guid TargetUserId, + Guid CurrentRoleId, + Guid TargetRoleId); + +/// Factor individual que contribuye al RiskScore, con su peso y una descripción trazable. +public sealed record RiskFactor(string Code, int Weight, string Description); + +/// +/// Evaluación de riesgo congelable de una promoción: la puntuación [0, 100], la versión del modelo +/// (riskModelVersion) para trazar cambios de fórmula y el desglose de factores. +/// +public sealed record RiskAssessment(int Score, string RiskModelVersion, IReadOnlyList Factors); + +/// +/// Puerto de cálculo del RiskScore de impacto tóxico de una promoción de rol (FR-061, ADR-UMS-093). +/// Se invoca una única vez, al hacer Submit (Draft → PendingEligibilityCheck), y su resultado +/// se congela en el agregado (INV-RPR2). La implementación por defecto es una heurística versionada +/// (); la infraestructura puede sustituirla por una +/// implementación respaldada por el grafo de autorización (ADR-UMS-088) sin tocar los handlers. +/// +public interface IRiskScoreCalculator +{ + Task> CalculateAsync(RolePromotionRiskContext context, CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs index 976bbfcf..46f840b6 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs @@ -1,7 +1,10 @@ using Ums.Application.Configuration.Services; +using Ums.Domain.Configuration; using Ums.Domain.Configuration.AppConfiguration; +using Ums.Domain.Configuration.IdpConfiguration; using Ums.Domain.Identity; using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; namespace Ums.Application.Identity.Auth; @@ -14,23 +17,38 @@ namespace Ums.Application.Identity.Auth; /// - false → AuthMethod.Local() /// - true + active IDP → AuthMethod.Idp(provider) /// - true + no active IDP: -/// - ExternalApi → Result.Failure("AUTH_011") +/// - ExternalApi → AuthMethod.Local() (G-049) /// - InternalPreview → AuthMethod(Type = IDP, Provider = null) +/// +/// FR-042 (ADR-UMS-097, slice 2a): en modo IDP el proveedor se elige por el motor de reglas +/// ( sobre las IdpConfiguration del inquilino), por +/// prioridad/suite/dominio (desempate por versión), en lugar del antiguo FirstOrDefault(IsActive). +/// La configuración ganadora se reconcilia con el IdentityProvider que consume el adaptador +/// (puente por estrategia ↔ tipo de proveedor). Si el inquilino no tiene ninguna IdpConfiguration +/// que gobierne la selección, se conserva el comportamiento previo (único proveedor activo). +/// No hay fallback encadenado: esto es slice 2a; un fallo de credenciales es terminal (§2.3). /// public sealed class AuthMethodResolverService : IAuthMethodResolver { - private readonly IConfigurationProvider _config; - private readonly ITenantRepository _tenantRepo; + private readonly IConfigurationProvider _config; + private readonly ITenantRepository _tenantRepo; + private readonly IIdpConfigurationRepository _idpConfigRepo; - public AuthMethodResolverService(IConfigurationProvider config, ITenantRepository tenantRepo) + public AuthMethodResolverService( + IConfigurationProvider config, + ITenantRepository tenantRepo, + IIdpConfigurationRepository idpConfigRepo) { - _config = config; - _tenantRepo = tenantRepo; + _config = config; + _tenantRepo = tenantRepo; + _idpConfigRepo = idpConfigRepo; } public async Task> ResolveAsync( Guid tenantId, AuthAccessScope scope, + Guid? systemSuiteId = null, + string? emailDomain = null, CancellationToken cancellationToken = default) { if (scope == AuthAccessScope.PortalManagement) @@ -54,24 +72,63 @@ public async Task> ResolveAsync( if (!useExternalIdp) return Result.Success(AuthMethod.Local()); - // IDP mode — find the active identity provider for this tenant + // IDP mode — select the identity provider for this tenant by the FR-042 rules. var tenant = await _tenantRepo.GetByIdAsync(tenantId, cancellationToken); if (tenant is null) return Result.Failure($"AUTH_002: Tenant {tenantId} not found."); - var activeIdp = tenant.IdentityProviders.FirstOrDefault(p => p.IsActive); - if (activeIdp is null) + // FR-042 (ADR-UMS-097 §2.2): procedencia de la suite pre-autenticación. La suite viene del + // AccessScope que el login transporta (systemSuiteId); si el scope no la fija, se usa el + // default del inquilino. Null ⇒ inquilino de suite única ⇒ el selector omite el filtro por suite. + var effectiveSuiteId = systemSuiteId ?? tenant.DefaultSystemSuiteId?.GetValue(); + + var provider = await SelectProviderAsync(tenant, tenantId, effectiveSuiteId, emailDomain, cancellationToken); + + if (provider is null) { if (scope == AuthAccessScope.InternalPreview) { return Result.Success(new AuthMethod(AuthMethodType.IDP)); } - return Result.Failure( - "AUTH_011: Tenant is configured for external IDP authentication " + - "but has no active Identity Provider. Configure and activate an IDP first."); + // Fallback a Local según G-049 + return Result.Success(AuthMethod.Local()); + } + + return Result.Success(AuthMethod.Idp(provider)); + } + + /// + /// Elige el IdentityProvider aplicando la regla de selección FR-042 sobre las + /// IdpConfiguration del inquilino y reconciliándola con el proveedor que consume el adaptador. + /// + private async Task SelectProviderAsync( + Ums.Domain.Identity.Tenant.Tenant tenant, + Guid tenantId, + Guid? systemSuiteId, + string? emailDomain, + CancellationToken cancellationToken) + { + var configurations = await _idpConfigRepo.GetByTenantIdAsync(tenantId, cancellationToken); + var selection = IdpConfigurationSelector.Select(configurations, systemSuiteId, emailDomain, providerType: null); + + if (selection is null) + { + // Ninguna IdpConfiguration gobierna la selección → se conserva el comportamiento previo + // (único proveedor activo). No se rompe a los inquilinos aún no migrados a reglas. + return tenant.GetActiveIdentityProvider(); } - return Result.Success(AuthMethod.Idp(activeIdp)); + // Puente reglas ↔ dominio (ADR-UMS-097 §2.5): la configuración ganadora determina el + // IdentityProvider activo cuyo IdpStrategy corresponde al ProviderType de la regla. + // ProviderType e IdpStrategy son enumeraciones paralelas por Id (mismo par 1..10, verificado + // por la persistencia que las guarda por Id), lo que da un puente 1-a-1 sin Code/Name en el DTO. + var winnerProviderTypeId = selection.Value.Configuration.ProviderType.Id; + var bridged = tenant.IdentityProviders + .FirstOrDefault(ip => ip.IsActive && ip.Strategy.Id == winnerProviderTypeId); + + // Si la regla eligió un proveedor que el inquilino no tiene activo, NO se sustituye por otro + // (autenticar contra un IdP no elegido sería incorrecto): se trata como «sin proveedor usable». + return bridged; } } diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs index 18b61dc4..35489db5 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs @@ -6,6 +6,12 @@ namespace Ums.Application.Identity.Auth.Commands; /// /// Authenticate a user by tenant code + credentials. /// Returns a complete AuthorizationGraph on success. +/// +/// FR-042 (ADR-UMS-097 §2.2): transporta la suite del contexto de +/// resolución del IdP. Hoy el no fija suite y el inquilino no tiene +/// suite por defecto, así que queda null (sin filtro de suite); se enhebra ahora para que +/// una fuente de suite (slice posterior) la pueble sin volver a tocar el contrato. El dominio de +/// email se deriva del en el handler. No cambia el contrato REST de /auth/login. /// public sealed record AuthenticateUserCommand( string TenantCode, @@ -13,7 +19,15 @@ public sealed record AuthenticateUserCommand( string Password, string ClientIp, AuthAccessScope AccessScope, - bool RememberMe = false) : ICommand; + bool RememberMe = false, + Guid? SystemSuiteId = null, + // ADR-0156 §3.2 — código del sistema que pide el grafo, OPCIONAL. Presente, el grafo se acota + // a los perfiles del usuario en ese sistema; ausente, viajan todos (portal multiproducto). + // + // NO se reutiliza `SystemSuiteId` para esto: es el contexto de resolución de IdP de FR-042, + // viaja como identificador y hoy siempre llega null. Mezclar ambas semánticas en un campo + // ataría el enrutado de IdP al filtro de perfiles, que son problemas distintos (§3.3). + string? SystemCode = null) : ICommand; /// Result of a successful authentication — graph + raw JWT. public sealed record AuthenticateUserResult( diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommandHandler.cs index b7989101..767b5784 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommandHandler.cs @@ -25,9 +25,8 @@ public sealed class AuthenticateUserCommandHandler private readonly IUserAccountRepository _userRepo; private readonly IAuthMethodResolver _methodResolver; private readonly ILocalAuthStrategy _localStrategy; - private readonly IIdpAuthStrategy _idpStrategy; + private readonly IIdpChainAuthenticator _chainAuthenticator; private readonly IAuthorizationGraphBuilder _graphBuilder; - private readonly IAuthGraphFormatProvider _formatProvider; private readonly IAuthorizationGraphSerializer _defaultSerializer; private readonly IAuthAuditService _auditService; private readonly IConfigurationProvider _configProvider; @@ -37,23 +36,21 @@ public AuthenticateUserCommandHandler( IUserAccountRepository userRepo, IAuthMethodResolver methodResolver, ILocalAuthStrategy localStrategy, - IIdpAuthStrategy idpStrategy, + IIdpChainAuthenticator chainAuthenticator, IAuthorizationGraphBuilder graphBuilder, - IAuthGraphFormatProvider formatProvider, IAuthorizationGraphSerializer defaultSerializer, IAuthAuditService auditService, IConfigurationProvider configProvider) { - _tenantRepo = tenantRepo; - _userRepo = userRepo; - _methodResolver = methodResolver; - _localStrategy = localStrategy; - _idpStrategy = idpStrategy; - _graphBuilder = graphBuilder; - _formatProvider = formatProvider; - _defaultSerializer = defaultSerializer; - _auditService = auditService; - _configProvider = configProvider; + _tenantRepo = tenantRepo; + _userRepo = userRepo; + _methodResolver = methodResolver; + _localStrategy = localStrategy; + _chainAuthenticator = chainAuthenticator; + _graphBuilder = graphBuilder; + _defaultSerializer = defaultSerializer; + _auditService = auditService; + _configProvider = configProvider; } public async Task> Handle( @@ -86,9 +83,13 @@ await RecordFailureAsync(tenantId, userId, command, "AUTH_003: Tenant inactive", tenantId = tenant.Props.Id.GetValue(); // ── 2. Auth method resolution ────────────────────────────────────── + // FR-042 (ADR-UMS-097 §2.2): se enhebra el contexto de resolución (suite del comando + + // dominio de email derivado del Username) hacia el motor de reglas del resolver. var methodResult = await _methodResolver.ResolveAsync( tenantId, command.AccessScope, + command.SystemSuiteId, + ExtractEmailDomain(command.Username), cancellationToken); if (methodResult.IsFailure) { @@ -109,7 +110,7 @@ await RecordFailureAsync(tenantId, userId, command, methodResult.Error, else { return await AuthenticateIdpAsync( - command, tenant, tenantId, authMethod, methodName, cancellationToken); + command, tenant, tenantId, methodName, cancellationToken); } } catch (Exception ex) @@ -131,10 +132,15 @@ private async Task> AuthenticateLocalAsync( string methodName, CancellationToken cancellationToken) { - var user = await _userRepo.GetByEmailAsync( - Email.Create(command.Username), cancellationToken); + // G-168: la búsqueda se acota al inquilino. `GetByEmailAsync` consulta sin filtro de + // inquilino y sin `ORDER BY`: con el mismo correo dado de alta en dos inquilinos —la + // unicidad es (TenantId, Email), así que es un estado válido— devolvía una fila + // arbitraria, y qué fila ganaba dependía del plan de ejecución. El usuario legítimo + // podía quedarse fuera de su propio inquilino sin explicación posible en el log. + var user = await _userRepo.GetByTenantAndEmailAsync( + tenantId, Email.Create(command.Username), cancellationToken: cancellationToken); - if (user is null || user.Props.TenantId.GetValue() != tenantId) + if (user is null) { await RecordFailureAsync(tenantId, Guid.Empty, command, "AUTH_006: Invalid credentials", methodName, cancellationToken); @@ -149,11 +155,30 @@ await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, "AUTH_005: User account is not active. Contact your administrator."); } + // ADR-UMS-095: política de bloqueo temporal. El instante se resuelve aquí (aplicación) y se + // inyecta en el dominio, que es determinista. La misma fuente de «ahora» rige la comprobación + // y el registro del intento. maxAttempts/lockoutMinutes salen de la config efectiva (cascada + // Global>Suite>Tenant>Module). El bloqueo administrativo permanente ya se rechazó arriba (AUTH_005). + var cfg = _configProvider.ForTenant(tenantId); + var now = DateTimeOffset.UtcNow; + var maxLoginAttempts = cfg.MaxLoginAttempts; + var lockoutMinutes = cfg.AccountLockoutDurationMinutes; + + // ADR-UMS-095: enforcement ANTES de validar credenciales y sin revelar su validez. + // AUTH_017 = bloqueo temporal de cuenta (AUTH_012 ya está tomado: «no IDP adapter registered»). + if (user.IsLockedOut(now)) + { + await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, + "AUTH_017: Account temporarily locked", methodName, cancellationToken); + return Result.Failure( + "AUTH_017: Account temporarily locked due to failed login attempts."); + } + var authResult = _localStrategy.Authenticate(user, command.Password); if (authResult.IsFailure) { - user.RecordAuthenticationAttempt(false, authResult.Error, - command.ClientIp, ActorId.Create("auth:system")); + user.RecordAuthenticationAttempt(false, now, maxLoginAttempts, lockoutMinutes, + authResult.Error, command.ClientIp, ActorId.Create("auth:system")); await _userRepo.UpdateAsync(user, cancellationToken); await _userRepo.UnitOfWork.SaveEntitiesAsync(cancellationToken); @@ -170,8 +195,8 @@ await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, return Result.Failure(mfaCheck.Error); } - user.RecordAuthenticationAttempt(true, "Login successful", - command.ClientIp, ActorId.Create("auth:system")); + user.RecordAuthenticationAttempt(true, now, maxLoginAttempts, lockoutMinutes, + "Login successful", command.ClientIp, ActorId.Create("auth:system")); await _userRepo.UpdateAsync(user, cancellationToken); await _userRepo.UnitOfWork.SaveEntitiesAsync(cancellationToken); @@ -186,23 +211,36 @@ private async Task> AuthenticateIdpAsync( AuthenticateUserCommand command, Ums.Domain.Identity.Tenant.Tenant tenant, Guid tenantId, - AuthMethod authMethod, string methodName, CancellationToken cancellationToken) { - var idpResult = await _idpStrategy.AuthenticateAsync( - tenantId, command.Password, authMethod.Provider!, cancellationToken); - - if (idpResult.IsFailure) + // FR-042 (ADR-UMS-097 §2.3/§2.4, slice 2b): la autenticación federada recorre la cadena de + // fallback FallbackToId partiendo de la config ganadora del selector (2a). El avance de la + // cadena SOLO ocurre por indisponibilidad de infraestructura; un fallo de credenciales es + // TERMINAL (anti credential-spraying). El orquestador clasifica fail-closed y audita por intento. + var chainResult = await _chainAuthenticator.AuthenticateAsync( + tenant, command.Password, command.SystemSuiteId, + ExtractEmailDomain(command.Username), command.ClientIp, cancellationToken); + + if (chainResult.IsFailure) { await RecordFailureAsync(tenantId, Guid.Empty, command, - idpResult.Error, methodName, cancellationToken); - return Result.Failure(idpResult.Error); + chainResult.Error, methodName, cancellationToken); + return Result.Failure(chainResult.Error); } - var externalId = idpResult.Value; - var user = await _userRepo.GetByEmailAsync( - Email.Create(externalId.Email), cancellationToken); + var externalId = chainResult.Value.Identity; + // El proveedor que EFECTIVAMENTE autenticó puede ser uno de respaldo (distinto al primario): + // el grafo y la auditoría de éxito se construyen con ese proveedor, no con el ganador inicial. + var resolvedMethod = AuthMethod.Idp(chainResult.Value.Provider); + + // G-168: acotado al inquilino, igual que la rama local. Aquí el defecto era peor: no + // existía NINGUNA comprobación posterior de `TenantId`, de modo que una cuenta de otro + // inquilino que compartiera el correo devuelto por el IdP pasaba a construir el grafo + // con el inquilino solicitado. Eso no es una denegación mal puesta: es un cruce de + // frontera de inquilino en la emisión de permisos. + var user = await _userRepo.GetByTenantAndEmailAsync( + tenantId, Email.Create(externalId.Email), cancellationToken: cancellationToken); if (user is null) { @@ -228,14 +266,17 @@ await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, return Result.Failure(mfaCheck.Error); } - user.RecordAuthenticationAttempt(true, "IDP login successful", - command.ClientIp, ActorId.Create("auth:system")); + // ADR-UMS-095: en el flujo IDP el bloqueo por contraseña no aplica; el registro exitoso + // resetea cualquier contador previo. maxAttempts/lockoutMinutes salen de la config efectiva. + var idpCfg = _configProvider.ForTenant(tenantId); + user.RecordAuthenticationAttempt(true, DateTimeOffset.UtcNow, idpCfg.MaxLoginAttempts, idpCfg.AccountLockoutDurationMinutes, + "IDP login successful", command.ClientIp, ActorId.Create("auth:system")); await _userRepo.UpdateAsync(user, cancellationToken); await _userRepo.UnitOfWork.SaveEntitiesAsync(cancellationToken); return await BuildResultAsync( user, tenantId, tenant.Props.Code.GetValue(), - authMethod, command, methodName, cancellationToken); + resolvedMethod, command, methodName, cancellationToken); } // ── Shared Result Builder ───────────────────────────────────────────────── @@ -249,7 +290,10 @@ private async Task> BuildResultAsync( string methodName, CancellationToken cancellationToken) { - var graphResult = await _graphBuilder.BuildAsync(user, tenantId, authMethod, cancellationToken); + // ADR-0156 §4: el código de sistema del comando acota el grafo a los perfiles del usuario + // en ese sistema. Ausente —el caso del portal multiproducto— no acota nada. + var graphResult = await _graphBuilder.BuildAsync( + user, tenantId, authMethod, command.SystemCode, cancellationToken); if (graphResult.IsFailure) { await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, @@ -257,9 +301,20 @@ await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, return Result.Failure(graphResult.Error); } - var graph = graphResult.Value; - var format = await _formatProvider.GetDefaultFormatAsync(tenantId, cancellationToken); + var graph = graphResult.Value; + + // G-176: se declara el formato que REALMENTE se produjo, no el preferido del inquilino. + // Antes se anunciaba el formato por defecto del inquilino mientras se serializaba + // siempre con el serializador inyectado (JSON): un inquilino con XML por defecto recibía + // la cabecera `X-Graph-Format: XML` sobre un cuerpo JSON, que es peor que no soportar XML. + // El endpoint de cliente sigue re-serializando cuando el llamante pide otro formato por + // `?format` o `Accept`. Honrar automáticamente la preferencia del inquilino exige + // resolver el serializador por fábrica en este punto: queda anotado en G-176. var serialized = _defaultSerializer.Serialize(graph); + // Defensivo: un serializador sin extensión declarada no debe tumbar un login. + var format = string.IsNullOrWhiteSpace(_defaultSerializer.FileExtension) + ? "JSON" + : _defaultSerializer.FileExtension.ToUpperInvariant(); await _auditService.RecordAuthEventAsync(new AuthAuditEvent( UserId: user.Props.Id.GetValue(), @@ -301,6 +356,21 @@ private Result CheckMfaPolicy( return Result.Success(); } + // FR-042 (ADR-UMS-097 §2.2): el dominio de email para el routing por dominio sale del identificador + // de login cuando es un email; si no lo es, se devuelve null y el motor omite el filtro por dominio. + private static string? ExtractEmailDomain(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + return null; + } + + var atIndex = username.IndexOf('@'); + return atIndex >= 0 && atIndex < username.Length - 1 + ? username[(atIndex + 1)..] + : null; + } + private async Task RecordFailureAsync( Guid tenantId, Guid userId, diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommand.cs index 1d71dbad..bfa00dff 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommand.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommand.cs @@ -3,7 +3,9 @@ namespace Ums.Application.Identity.Auth.Commands; public sealed record ForgotPasswordCommand(string TenantCode, string Email) : ICommand; -public sealed record ForgotPasswordResponse( - string Message, - string? SimulatedTemporaryPassword // visible in dev/simulated mode only -); +/// +/// Respuesta del flujo anónimo. Lleva un ÚNICO campo, y siempre con el mismo valor: cualquier +/// dato que dependa de si la cuenta existe convierte el endpoint en un oráculo de enumeración. +/// El secreto de restablecimiento no viaja aquí en ningún entorno; sale solo por el buzón. +/// +public sealed record ForgotPasswordResponse(string Message); diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommandHandler.cs index 22d5c08d..03653538 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommandHandler.cs @@ -1,95 +1,107 @@ -using System.Security.Cryptography; +using System.Diagnostics; using Ums.Application.Common.Interfaces; using Ums.Application.Common.Notifications; namespace Ums.Application.Identity.Auth.Commands; +/// +/// Solicitud anónima de restablecimiento (G-188). +/// +/// Lo que este flujo NO hace, y por qué: no cambia la contraseña ni devuelve +/// credencial alguna. Conocer un correo no prueba poseer el buzón, así que actuar sobre la +/// credencial ante una petición anónima permitía a cualquiera dejar fuera al titular —o entrar +/// en su lugar si la contraseña nueva viajaba en la respuesta—. Lo único que hace es emitir un +/// secreto de un solo uso y vida corta y mandarlo por el buzón; el cambio ocurre en el canje +/// (), que es donde ya hay prueba de posesión. +/// +/// Indistinguibilidad: la respuesta es literalmente la misma instancia lógica para +/// cuenta existente e inexistente —un solo campo con texto fijo— y el tiempo se nivela con +/// , porque el camino «existe» escribe y notifica mientras +/// el otro retorna en seco. +/// public sealed class ForgotPasswordCommandHandler : ICommandHandler { + /// + /// Texto único de la respuesta. Es constante y deliberadamente vago: no confirma ni desmiente + /// que el correo esté registrado. + /// + private const string AmbiguousMessage = + "Si el correo está registrado, recibirá instrucciones para restablecer su contraseña."; + private readonly ITenantRepository _tenantRepository; private readonly IUserAccountRepository _userAccountRepository; - private readonly IPasswordHashingService _passwordHashingService; + private readonly IPasswordResetTokenStore _resetTokenStore; private readonly INotificationService _notificationService; + private readonly IResponseTimingNormalizer _timingNormalizer; public ForgotPasswordCommandHandler( ITenantRepository tenantRepository, IUserAccountRepository userAccountRepository, - IPasswordHashingService passwordHashingService, - INotificationService notificationService) + IPasswordResetTokenStore resetTokenStore, + INotificationService notificationService, + IResponseTimingNormalizer timingNormalizer) { _tenantRepository = tenantRepository; _userAccountRepository = userAccountRepository; - _passwordHashingService = passwordHashingService; + _resetTokenStore = resetTokenStore; _notificationService = notificationService; + _timingNormalizer = timingNormalizer; } public async Task> Handle(ForgotPasswordCommand request, CancellationToken cancellationToken) { - var tenant = await _tenantRepository.GetByCodeAsync(request.TenantCode, cancellationToken); - if (tenant is null) - return Ambiguous(); - - var email = Email.Create(request.Email); - var userAccount = await _userAccountRepository.GetByEmailAsync(email, cancellationToken); + var startedAt = Stopwatch.GetTimestamp(); - // Always return success to avoid user enumeration - var tenantId = tenant.Props.Id.GetValue(); - if (userAccount is null - || userAccount.Props.TenantId.GetValue() != tenantId - || userAccount.IdentityReference is not null) - return Ambiguous(); + // El resultado del intento se descarta a propósito: nada de lo que ocurra aquí dentro + // puede alterar lo que ve el llamante. + await TryIssueResetTokenAsync(request, cancellationToken).ConfigureAwait(false); + await _timingNormalizer.NormalizeAsync(startedAt, cancellationToken).ConfigureAwait(false); - var tempPassword = GenerateTemporaryPassword(); - var hash = _passwordHashingService.Hash(tempPassword); - var actor = ActorId.Create("00000000-0000-0000-0000-000000000001"); // system actor + return Result.Success(new ForgotPasswordResponse(AmbiguousMessage)); + } - var result = userAccount.AddPassword(PasswordHash.Create(hash), actor); - if (result.IsFailure) - return Ambiguous(); + private async Task TryIssueResetTokenAsync(ForgotPasswordCommand request, CancellationToken cancellationToken) + { + var tenant = await _tenantRepository.GetByCodeAsync(request.TenantCode, cancellationToken).ConfigureAwait(false); + if (tenant is null) + return; - await _userAccountRepository.UpdateAsync(userAccount, cancellationToken); - await _userAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + var userAccount = await _userAccountRepository + .GetByEmailAsync(Email.Create(request.Email), cancellationToken) + .ConfigureAwait(false); - await _notificationService.SendAsync( - NotificationTemplates.PasswordReset( - recipient: userAccount.Email.GetValue(), - recipientName: userAccount.Email.GetValue().Split('@')[0], - temporaryPassword: tempPassword), - cancellationToken); + if (userAccount is null || userAccount.Props.TenantId.GetValue() != tenant.Props.Id.GetValue()) + return; - return Result.Success(new ForgotPasswordResponse( - Message: "Si el correo está registrado, recibirá instrucciones para restablecer su contraseña.", - SimulatedTemporaryPassword: tempPassword - )); - } + // Las cuentas federadas no tienen contraseña local que restablecer, y las bloqueadas o + // borradas no deben recuperar acceso por esta vía: en ambos casos el desenlace de cara + // al llamante es idéntico al de un correo desconocido. + if (userAccount.IdentityReference is not null) + return; - private static Result Ambiguous() => - Result.Success(new ForgotPasswordResponse( - Message: "Si el correo está registrado, recibirá instrucciones para restablecer su contraseña.", - SimulatedTemporaryPassword: null - )); + if (userAccount.Status == UserStatus.Blocked + || userAccount.Status == UserStatus.Deleted + || userAccount.Status == UserStatus.Denied) + return; - private static string GenerateTemporaryPassword() - { - const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; - const string lower = "abcdefghjkmnpqrstuvwxyz"; - const string digits = "23456789"; - const string special = "!@#$%&"; - const string all = upper + lower + digits + special; + var token = PasswordResetToken.Generate(); + var issuedAt = DateTime.UtcNow; - var chars = new char[16]; - chars[0] = upper[RandomNumberGenerator.GetInt32(upper.Length)]; - chars[1] = lower[RandomNumberGenerator.GetInt32(lower.Length)]; - chars[2] = digits[RandomNumberGenerator.GetInt32(digits.Length)]; - chars[3] = special[RandomNumberGenerator.GetInt32(special.Length)]; - for (var i = 4; i < chars.Length; i++) - chars[i] = all[RandomNumberGenerator.GetInt32(all.Length)]; + await _resetTokenStore.IssueAsync( + tenant.Props.Id.GetValue(), + userAccount.Props.Id.GetValue(), + PasswordResetToken.Hash(token), + issuedAt, + issuedAt.Add(PasswordResetToken.Lifetime), + cancellationToken).ConfigureAwait(false); - for (var i = chars.Length - 1; i > 0; i--) - { - var j = RandomNumberGenerator.GetInt32(i + 1); - (chars[i], chars[j]) = (chars[j], chars[i]); - } - return new string(chars); + var email = userAccount.Email.GetValue(); + await _notificationService.SendAsync( + NotificationTemplates.PasswordResetRequested( + recipient: email, + recipientName: email.Split('@')[0], + resetToken: token, + expiresInMinutes: (int)PasswordResetToken.Lifetime.TotalMinutes), + cancellationToken).ConfigureAwait(false); } } diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommand.cs new file mode 100644 index 00000000..f96024c8 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommand.cs @@ -0,0 +1,25 @@ +using Ums.Domain.Authorization.Graph; + +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Renueva una sesión a partir de un refresh token (ADR-UMS-091 / FR-015). +/// +/// Regenera el grafo completo desde el estado actual (cierra la brecha de +/// latencia de permisos), rota el token (rotación + detección de reuso) y aplica la +/// postura fail-closed: si el inquilino no tiene la capacidad activa, la renovación +/// se rechaza. +/// +public sealed record RefreshAuthenticationCommand( + string RefreshToken, + string ClientIp) : ICommand; + +/// Resultado de una renovación exitosa — grafo recién regenerado + nuevo refresh. +public sealed record RefreshAuthenticationResult( + AuthorizationGraph Graph, + string SerializedGraph, + string GraphFormat, + int ExpiresIn, // vida del access token, en segundos + DateTime IssuedAt, + string? NewRefreshToken, // null si la política no rota (el cliente conserva el suyo) + int RefreshExpiresIn); // vida del refresh, en segundos diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommandHandler.cs new file mode 100644 index 00000000..278f3570 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommandHandler.cs @@ -0,0 +1,245 @@ +using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Application.Identity.Auth; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity.Auth; + +namespace Ums.Application.Identity.Auth.Commands; + +/// Códigos de error de la renovación (ADR-UMS-091). +public static class RefreshErrorCodes +{ + public const string Invalid = "AUTH_REFRESH_001"; // no existe / hash desconocido + public const string ReuseDetected = "AUTH_REFRESH_002"; // token ya rotado presentado ⇒ familia invalidada + public const string Revoked = "AUTH_REFRESH_003"; // token/familia ya revocados + public const string Expired = "AUTH_REFRESH_004"; // vencido + public const string Disabled = "AUTH_REFRESH_005"; // capacidad no activa (fail-closed) + public const string MaxRenewals = "AUTH_REFRESH_006"; // tope de renovaciones alcanzado + public const string PrincipalGone = "AUTH_REFRESH_007"; // inquilino o usuario inactivo/inexistente +} + +/// +/// Renovación de sesión por refresh token (ADR-UMS-091 / FR-015/016). Orquesta: +/// 1. Buscar el token por hash (SHA-256); nunca se compara el plaintext. +/// 2. Detección de reuso: presentar un token ya rotado/usado invalida la familia entera. +/// 3. Fail-closed: si la política del inquilino está apagada, no se renueva. +/// 4. Validar vigencia, tope de renovaciones, y que inquilino y usuario sigan activos +/// (un usuario bloqueado o suspendido no puede renovar → revocación efectiva). +/// 5. Regenerar el grafo COMPLETO desde el estado actual (IAuthorizationGraphBuilder). +/// 6. Rotar el token (nuevo Active en la familia; el anterior queda Rotated). +/// 7. Auditar el evento. +/// +public sealed class RefreshAuthenticationCommandHandler + : ICommandHandler +{ + private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenPolicyProvider _policyProvider; + private readonly ITenantRepository _tenantRepo; + private readonly IUserAccountRepository _userRepo; + private readonly IAuthMethodResolver _methodResolver; + private readonly IAuthorizationGraphBuilder _graphBuilder; + private readonly IAuthGraphFormatProvider _formatProvider; + private readonly IAuthorizationGraphSerializer _serializer; + private readonly IAuthAuditService _auditService; + + public RefreshAuthenticationCommandHandler( + IRefreshTokenStore store, + IRefreshTokenPolicyProvider policyProvider, + ITenantRepository tenantRepo, + IUserAccountRepository userRepo, + IAuthMethodResolver methodResolver, + IAuthorizationGraphBuilder graphBuilder, + IAuthGraphFormatProvider formatProvider, + IAuthorizationGraphSerializer serializer, + IAuthAuditService auditService) + { + _store = store; + _policyProvider = policyProvider; + _tenantRepo = tenantRepo; + _userRepo = userRepo; + _methodResolver = methodResolver; + _graphBuilder = graphBuilder; + _formatProvider = formatProvider; + _serializer = serializer; + _auditService = auditService; + } + + public async Task> Handle( + RefreshAuthenticationCommand command, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.RefreshToken)) + { + return Result.Failure($"{RefreshErrorCodes.Invalid}: Refresh token is required."); + } + + var hash = RefreshTokenHasher.Hash(command.RefreshToken); + var snapshot = await _store.FindByHashAsync(hash, cancellationToken); + + if (snapshot is null) + { + await AuditFailureAsync(Guid.Empty, Guid.Empty, command.ClientIp, + RefreshErrorCodes.Invalid, "Unknown refresh token", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.Invalid}: Invalid refresh token."); + } + + var now = DateTime.UtcNow; + + // ── Detección de reuso / estado no renovable ────────────────────────────── + if (snapshot.Status == RefreshTokenStatuses.Revoked) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.Revoked, "Revoked refresh token presented", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.Revoked}: Refresh token has been revoked."); + } + + if (snapshot.Status is not (RefreshTokenStatuses.Active)) + { + // Rotated | Used ⇒ el token ya fue canjeado; presentarlo de nuevo es reuso. + var policyForReuse = _policyProvider.Resolve(snapshot.TenantId); + if (policyForReuse.DetectReuse) + { + await _store.RevokeFamilyAsync(snapshot.FamilyId, "reuse_detected", now, cancellationToken); + } + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.ReuseDetected, "Reuse of a rotated refresh token", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.ReuseDetected}: Refresh token reuse detected; the session family was revoked."); + } + + if (snapshot.ExpiresAtUtc <= now) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.Expired, "Expired refresh token", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.Expired}: Refresh token has expired."); + } + + // ── Fail-closed: la capacidad debe seguir activa para el inquilino ──────── + var policy = _policyProvider.Resolve(snapshot.TenantId); + if (!policy.Enabled) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.Disabled, "Refresh capability disabled for tenant", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.Disabled}: Refresh capability is not enabled for this tenant."); + } + + // ── Tope de renovaciones (0 = sin tope) ─────────────────────────────────── + if (policy.MaxRenewals > 0 && snapshot.RenewalCount >= policy.MaxRenewals) + { + await _store.RevokeFamilyAsync(snapshot.FamilyId, "max_renewals", now, cancellationToken); + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.MaxRenewals, "Max renewals reached", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.MaxRenewals}: Maximum number of renewals reached; please sign in again."); + } + + // ── El inquilino y el usuario deben seguir vigentes ─────────────────────── + var tenant = await _tenantRepo.GetByIdAsync(snapshot.TenantId, cancellationToken); + if (tenant is null || tenant.Props.Status != Domain.Enums.TenantStatus.Active) + { + await _store.RevokeFamilyAsync(snapshot.FamilyId, "tenant_inactive", now, cancellationToken); + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.PrincipalGone, "Tenant inactive or missing", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.PrincipalGone}: The tenant is no longer active."); + } + + var user = await _userRepo.GetByIdAsync(snapshot.UserId, cancellationToken); + if (user is null + || user.Props.TenantId.GetValue() != snapshot.TenantId + || user.Props.Status != Domain.Enums.UserStatus.Active) + { + // Usuario bloqueado/suspendido/eliminado ⇒ revocación efectiva de la sesión. + await _store.RevokeFamilyAsync(snapshot.FamilyId, "user_inactive", now, cancellationToken); + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.PrincipalGone, "User inactive or missing", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.PrincipalGone}: The user account is no longer active."); + } + + // ── Regeneración COMPLETA del grafo (ADR-UMS-091, decisión 1) ──────────────── + var methodResult = await _methodResolver.ResolveAsync( + snapshot.TenantId, AuthAccessScope.PortalManagement, cancellationToken: cancellationToken); + if (methodResult.IsFailure) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.PrincipalGone, methodResult.Error, cancellationToken); + return Result.Failure(methodResult.Error); + } + + var authMethod = methodResult.Value; + // Sin acotar por sistema, por el mismo motivo que `RefreshSessionCommandHandler`: es el + // refresco de la sesión del portal, que es multiproducto (ADR-0156 §4). + var graphResult = await _graphBuilder.BuildAsync( + user, snapshot.TenantId, authMethod, systemCode: null, cancellationToken); + if (graphResult.IsFailure) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.PrincipalGone, graphResult.Error, cancellationToken); + return Result.Failure(graphResult.Error); + } + + var graph = graphResult.Value; + var format = await _formatProvider.GetDefaultFormatAsync(snapshot.TenantId, cancellationToken); + var serialized = _serializer.Serialize(graph); + + // ── Rotación del refresh token ──────────────────────────────────────────── + string? newRefreshPlaintext = null; + int refreshExpiresIn; + if (policy.Rotate) + { + newRefreshPlaintext = RefreshTokenGenerator.Generate(); + var newTokenId = Guid.NewGuid(); + var expiresAt = now.AddMinutes(policy.LifetimeMinutes); + await _store.RotateAsync( + snapshot, + newTokenId, + RefreshTokenHasher.Hash(newRefreshPlaintext), + now, + expiresAt, + cancellationToken); + refreshExpiresIn = policy.LifetimeMinutes * 60; + } + else + { + // Sin rotación: el cliente conserva su token; solo se renueva el acceso. + refreshExpiresIn = (int)Math.Max(0, (snapshot.ExpiresAtUtc - now).TotalSeconds); + } + + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: snapshot.UserId, + TenantId: snapshot.TenantId, + TenantCode: tenant.Props.Code.GetValue(), + AuthMethod: authMethod.Type.ToString(), + EventType: "Auth.Refresh.Success", + Succeeded: true, + ClientIp: command.ClientIp), cancellationToken); + + return Result.Success(new RefreshAuthenticationResult( + Graph: graph, + SerializedGraph: serialized, + GraphFormat: format, + ExpiresIn: graph.EffectiveConfig.AccessTokenDurationMs / 1000, + IssuedAt: graph.GeneratedAt, + NewRefreshToken: newRefreshPlaintext, + RefreshExpiresIn: refreshExpiresIn)); + } + + private async Task AuditFailureAsync( + Guid userId, Guid tenantId, string clientIp, + string code, string reason, CancellationToken cancellationToken) + { + try + { + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: userId, + TenantId: tenantId, + TenantCode: string.Empty, + AuthMethod: "Refresh", + EventType: "Auth.Refresh.Failure", + Succeeded: false, + ClientIp: clientIp, + FailureReason: $"{code}: {reason}"), cancellationToken); + } + catch + { + // La auditoría no debe enmascarar el resultado de la renovación. + } + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommand.cs new file mode 100644 index 00000000..6a4720e5 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommand.cs @@ -0,0 +1,25 @@ +using Ums.Domain.Authorization.Graph; + +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Renueva una sesión de PORTAL a partir del principal ya autenticado por cookie +/// (D-019 / ADR-UMS-091). A diferencia de —que +/// canjea un refresh token OPACO (ADR-UMS-091/FR-015)—, este comando parte de la cookie de +/// sesión viva: recibe el userId y el tenantId resueltos de los claims y +/// regenera el grafo de autorización COMPLETO desde el estado actual, en espejo del +/// login (). +/// +/// Cierra la brecha de latencia de permisos del refresh deslizante: el JWT emitido tras la +/// renovación porta el modelo de permisos vigente, no un grafo vacío. +/// +public sealed record RefreshSessionCommand( + Guid UserId, + Guid TenantId, + string ClientIp) : ICommand; + +/// Resultado de una renovación de sesión por cookie — grafo recién regenerado. +public sealed record RefreshSessionResult( + AuthorizationGraph Graph, + string GraphFormat, + int ExpiresIn); // vida del access token, en segundos (derivada del grafo) diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommandHandler.cs new file mode 100644 index 00000000..252e995f --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommandHandler.cs @@ -0,0 +1,128 @@ +using Ums.Application.Authorization.Graph; +using Ums.Application.Identity.Auth; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity.Auth; + +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Renovación de la sesión de portal por cookie (D-019 / ADR-UMS-091). Espeja el login: +/// 1. Verifica que el inquilino y el usuario del principal sigan vigentes (activos). +/// 2. Resuelve el método de autenticación de portal (local, ADR-0077). +/// 3. Regenera el grafo COMPLETO desde el estado actual (IAuthorizationGraphBuilder), +/// cerrando la brecha de latencia de permisos del refresh deslizante. +/// 4. Audita el evento de renovación (ADR-UMS-091, invariante de auditoría append-only). +/// +/// No toca el flujo de refresh token OPACO (): +/// aquí la credencial es la cookie de sesión viva, no un token opaco, por lo que no hay store, +/// rotación ni detección de reuso — solo la regeneración del grafo que faltaba. +/// +public sealed class RefreshSessionCommandHandler + : ICommandHandler +{ + private readonly ITenantRepository _tenantRepo; + private readonly IUserAccountRepository _userRepo; + private readonly IAuthMethodResolver _methodResolver; + private readonly IAuthorizationGraphBuilder _graphBuilder; + private readonly IAuthGraphFormatProvider _formatProvider; + private readonly IAuthAuditService _auditService; + + public RefreshSessionCommandHandler( + ITenantRepository tenantRepo, + IUserAccountRepository userRepo, + IAuthMethodResolver methodResolver, + IAuthorizationGraphBuilder graphBuilder, + IAuthGraphFormatProvider formatProvider, + IAuthAuditService auditService) + { + _tenantRepo = tenantRepo; + _userRepo = userRepo; + _methodResolver = methodResolver; + _graphBuilder = graphBuilder; + _formatProvider = formatProvider; + _auditService = auditService; + } + + public async Task> Handle( + RefreshSessionCommand command, + CancellationToken cancellationToken) + { + // ── El inquilino debe seguir activo ─────────────────────────────────────── + var tenant = await _tenantRepo.GetByIdAsync(command.TenantId, cancellationToken); + if (tenant is null || tenant.Props.Status != Domain.Enums.TenantStatus.Active) + { + await AuditFailureAsync(command, "Tenant inactive or missing", cancellationToken); + return Result.Failure("AUTH_007: The tenant is no longer active."); + } + + // ── El usuario debe seguir activo y pertenecer al inquilino ─────────────── + var user = await _userRepo.GetByIdAsync(command.UserId, cancellationToken); + if (user is null + || user.Props.TenantId.GetValue() != command.TenantId + || user.Props.Status != Domain.Enums.UserStatus.Active) + { + // Usuario bloqueado/suspendido/eliminado ⇒ no se renueva (corte en caliente). + await AuditFailureAsync(command, "User inactive or missing", cancellationToken); + return Result.Failure("AUTH_007: The user account is no longer active."); + } + + // ── Método de autenticación de portal (ADR-0077: siempre local) ─────────── + var methodResult = await _methodResolver.ResolveAsync( + command.TenantId, AuthAccessScope.PortalManagement, cancellationToken: cancellationToken); + if (methodResult.IsFailure) + { + await AuditFailureAsync(command, methodResult.Error, cancellationToken); + return Result.Failure(methodResult.Error); + } + + // ── Regeneración COMPLETA del grafo (ADR-UMS-091, decisión 1) ──────────────── + // Sin acotar por sistema: este refresco sirve a la sesión de COOKIE del portal, que es + // multiproducto por definición. El satélite no pasa por aquí — revalida con su portador + // contra `GET /client/graph`, que sí conserva el sistema (ADR-0156 §4). + var graphResult = await _graphBuilder.BuildAsync( + user, command.TenantId, methodResult.Value, systemCode: null, cancellationToken); + if (graphResult.IsFailure) + { + await AuditFailureAsync(command, graphResult.Error, cancellationToken); + return Result.Failure(graphResult.Error); + } + + var graph = graphResult.Value; + var format = await _formatProvider.GetDefaultFormatAsync(command.TenantId, cancellationToken); + + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: command.UserId, + TenantId: command.TenantId, + TenantCode: tenant.Props.Code.GetValue(), + AuthMethod: methodResult.Value.Type.ToString(), + EventType: "Auth.Refresh.Success", + Succeeded: true, + ClientIp: command.ClientIp), cancellationToken); + + return Result.Success(new RefreshSessionResult( + Graph: graph, + GraphFormat: format, + ExpiresIn: graph.EffectiveConfig.AccessTokenDurationMs / 1000)); + } + + private async Task AuditFailureAsync( + RefreshSessionCommand command, string reason, CancellationToken cancellationToken) + { + try + { + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: command.UserId, + TenantId: command.TenantId, + TenantCode: string.Empty, + AuthMethod: "Refresh", + EventType: "Auth.Refresh.Failure", + Succeeded: false, + ClientIp: command.ClientIp, + FailureReason: reason), cancellationToken); + } + catch + { + // La auditoría no debe enmascarar el resultado de la renovación. + } + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommand.cs new file mode 100644 index 00000000..c64e739e --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommand.cs @@ -0,0 +1,13 @@ +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Canje del secreto emitido por . +/// +/// No lleva código de inquilino ni correo a propósito: el token ya identifica al usuario y +/// a su inquilino. Pedir además el correo no añadiría seguridad —el token es el secreto— y sí +/// abriría un segundo punto donde una respuesta distinta podría delatar qué cuentas existen. +/// +public sealed record ResetPasswordCommand(string Token, string NewPassword) + : ICommand; + +public sealed record ResetPasswordResponse(string Message); diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandHandler.cs new file mode 100644 index 00000000..8e3ba945 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandHandler.cs @@ -0,0 +1,105 @@ +using Ums.Application.Common.Interfaces; +using Ums.Application.Common.Notifications; + +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Canje del token de restablecimiento: aquí —y solo aquí— cambia la contraseña (G-188). +/// +/// El token es la prueba de posesión del buzón, así que este es el primer punto del flujo +/// donde tocar la credencial es legítimo. Todo fallo colapsa en un único error: distinguir +/// «token inexistente» de «ya usado», «vencido» o «cuenta federada» reabriría por la puerta del +/// canje la enumeración que el flujo anónimo cierra. +/// +public sealed class ResetPasswordCommandHandler : ICommandHandler +{ + /// Único error del canje. Ver la nota de clase: la ambigüedad es intencional. + public const string InvalidTokenError = + "AUTH_019: El enlace de restablecimiento no es válido o ha expirado."; + + private readonly IPasswordResetTokenStore _resetTokenStore; + private readonly IUserAccountRepository _userAccountRepository; + private readonly IPasswordHashingService _passwordHashingService; + private readonly IRefreshTokenStore _refreshTokenStore; + private readonly INotificationService _notificationService; + + public ResetPasswordCommandHandler( + IPasswordResetTokenStore resetTokenStore, + IUserAccountRepository userAccountRepository, + IPasswordHashingService passwordHashingService, + IRefreshTokenStore refreshTokenStore, + INotificationService notificationService) + { + _resetTokenStore = resetTokenStore; + _userAccountRepository = userAccountRepository; + _passwordHashingService = passwordHashingService; + _refreshTokenStore = refreshTokenStore; + _notificationService = notificationService; + } + + public async Task> Handle(ResetPasswordCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Token)) + return Invalid(); + + var now = DateTime.UtcNow; + var snapshot = await _resetTokenStore + .FindByHashAsync(PasswordResetToken.Hash(request.Token), cancellationToken) + .ConfigureAwait(false); + + if (snapshot is null + || snapshot.Status != PasswordResetTokenStatuses.Active + || snapshot.ExpiresAtUtc <= now) + return Invalid(); + + var userAccount = await _userAccountRepository.GetByIdAsync(snapshot.UserId, cancellationToken).ConfigureAwait(false); + if (userAccount is null + || userAccount.Props.TenantId.GetValue() != snapshot.TenantId + || userAccount.IdentityReference is not null + || userAccount.Status == UserStatus.Blocked + || userAccount.Status == UserStatus.Deleted + || userAccount.Status == UserStatus.Denied) + { + // La cuenta cambió de estado entre la emisión y el canje: el token deja de servir y + // se cierra, para que no quede vivo esperando a que la cuenta se reactive. + await _resetTokenStore.ConsumeAsync(snapshot.Id, now, cancellationToken).ConfigureAwait(false); + return Invalid(); + } + + // El actor del cambio es el propio titular: el restablecimiento lo ejecuta quien probó + // poseer el buzón, no un administrador ni el sistema. + var actor = ActorId.Create(snapshot.UserId.ToString()); + var hash = _passwordHashingService.Hash(request.NewPassword); + var applied = userAccount.AddPassword(PasswordHash.Create(hash), actor); + if (applied.IsFailure) + return Result.Failure(applied.Error); + + await _userAccountRepository.UpdateAsync(userAccount, cancellationToken).ConfigureAwait(false); + await _userAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken).ConfigureAwait(false); + + // Un solo uso: el token se gasta y cualquier otro que siguiera vivo para el mismo usuario + // muere con él. El orden importa —primero la contraseña— para que un fallo al persistir + // no deje al titular sin credencial nueva Y sin token para reintentar. + await _resetTokenStore.ConsumeAsync(snapshot.Id, now, cancellationToken).ConfigureAwait(false); + await _resetTokenStore.InvalidateActiveForUserAsync( + snapshot.TenantId, snapshot.UserId, "password-reset", now, cancellationToken).ConfigureAwait(false); + + // Cambiar la contraseña sin cerrar las sesiones vivas dejaría dentro a quien la robó. + // Solo se revocan los refresh tokens: la revocación de access tokens es por usuario y + // ventana temporal (ITokenRevocationStore), así que aplicarla aquí impediría al titular + // volver a entrar justo después de restablecer. + await _refreshTokenStore.RevokeAllForUserAsync( + snapshot.TenantId, snapshot.UserId, "password-reset", now, cancellationToken).ConfigureAwait(false); + + var email = userAccount.Email.GetValue(); + await _notificationService.SendAsync( + NotificationTemplates.PasswordChanged(email, email.Split('@')[0]), + cancellationToken).ConfigureAwait(false); + + return Result.Success(new ResetPasswordResponse( + "Su contraseña fue actualizada. Ya puede iniciar sesión.")); + } + + private static Result Invalid() => + Result.Failure(InvalidTokenError); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandValidator.cs new file mode 100644 index 00000000..c477d58d --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandValidator.cs @@ -0,0 +1,24 @@ +namespace Ums.Application.Identity.Auth.Commands; + +using Ums.Application.Configuration.Services; + +/// +/// La longitud mínima se toma de la configuración global —la misma que ya rige el alta de +/// contraseñas— para que el restablecimiento no sea la puerta trasera por la que entra una +/// credencial más débil de lo que la política permite. +/// +public sealed class ResetPasswordCommandValidator : AbstractValidator +{ + public ResetPasswordCommandValidator(IConfigurationProvider configProvider) + { + var minLength = configProvider.Global().MinPasswordLength; + + RuleFor(x => x.Token) + .NotEmpty().WithMessage("El código de restablecimiento es obligatorio."); + + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("La nueva contraseña es obligatoria.") + .MinimumLength(minLength).WithMessage($"La contraseña debe tener al menos {minLength} caracteres.") + .MaximumLength(128).WithMessage("La contraseña no puede superar 128 caracteres."); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/SwitchProfileCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/SwitchProfileCommand.cs new file mode 100644 index 00000000..d309fe27 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/SwitchProfileCommand.cs @@ -0,0 +1,121 @@ +namespace Ums.Application.Identity.Auth.Commands; + +using Ums.Application.Common.Interfaces; +using Ums.Domain.Authorization; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; + +/// +/// Cambia el perfil vigente de una sesión ya autenticada. +/// +/// Un perfil ata un usuario, un rol y —por el rol— un sistema. Un usuario con varios perfiles +/// tenía que cerrar sesión y volver a entrar para cambiarse de sombrero, y ni siquiera sabía que +/// tenía otros: el grafo elegía uno y descartaba el resto sin dejar rastro (G-177). +/// +/// La identidad NO cambia: no se piden credenciales otra vez. Lo que cambia es el conjunto de +/// permisos con el que se opera, y por eso el resultado es un grafo nuevo completo, no un parche +/// sobre el anterior. +/// +public sealed record SwitchProfileCommand( + Guid ProfileId, + Guid UserId, + Guid TenantId, + string ClientIp, + // ADR-0156 §2.5 — acota el bloque `profiles` del grafo resultante al sistema del llamante. + // No elige el perfil vigente, que llega en `ProfileId`. Lo envía el carril de satélite, + // tomándolo del claim `sys_suite` de su propio portador; el portal lo deja en null, porque + // es multiproducto por definición. + string? SystemCode = null) : ICommand; + +public sealed class SwitchProfileCommandHandler : ICommandHandler +{ + private readonly IUserAccountRepository _userRepo; + private readonly IProfileRepository _profileRepo; + private readonly IAuthorizationGraphBuilder _graphBuilder; + private readonly IAuthAuditService _auditService; + + public SwitchProfileCommandHandler( + IUserAccountRepository userRepo, + IProfileRepository profileRepo, + IAuthorizationGraphBuilder graphBuilder, + IAuthAuditService auditService) + { + _userRepo = userRepo; + _profileRepo = profileRepo; + _graphBuilder = graphBuilder; + _auditService = auditService; + } + + public async Task> Handle( + SwitchProfileCommand request, + CancellationToken cancellationToken) + { + var user = await _userRepo.GetByIdAsync(request.UserId, cancellationToken); + if (user is null || user.Props.Status != Domain.Enums.UserStatus.Active) + { + return Result.Failure( + "AUTH_005: The account is not active."); + } + + var profile = await _profileRepo.GetByIdAsync(request.ProfileId, cancellationToken); + if (profile is null) + { + return Result.Failure("AUTH_020: Profile not found."); + } + + // El identificador del perfil lo envía el CLIENTE: nunca se confía en él. Sin estas dos + // comprobaciones, este endpoint sería una escalada de privilegios de una línea — bastaría + // enviar el id del perfil de un administrador. + if (profile.Props.UserId.GetValue() != request.UserId || + profile.Props.TenantId.GetValue() != request.TenantId) + { + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: request.UserId, + TenantId: request.TenantId, + TenantCode: string.Empty, + AuthMethod: "Local", + EventType: "Auth.Profile.SwitchDenied", + Succeeded: false, + ClientIp: request.ClientIp, + IdpProvider: null), cancellationToken); + + // Mismo mensaje que «no existe»: distinguirlos permitiría a un atacante enumerar + // perfiles ajenos preguntando por identificadores. + return Result.Failure("AUTH_020: Profile not found."); + } + + if (!profile.IsActive) + { + return Result.Failure("AUTH_021: Profile is inactive."); + } + + var graphResult = await _graphBuilder.BuildForProfileAsync( + user, request.TenantId, request.ProfileId, AuthMethod.Local(), + request.SystemCode, cancellationToken); + + if (graphResult.IsFailure) + { + return Result.Failure(graphResult.Error); + } + + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: request.UserId, + TenantId: request.TenantId, + TenantCode: graphResult.Value.Context.Tenant.Code, + AuthMethod: "Local", + EventType: "Auth.Profile.Switch", + Succeeded: true, + ClientIp: request.ClientIp, + IdpProvider: null), cancellationToken); + + return Result.Success(new AuthenticateUserResult( + Graph: graphResult.Value, + Token: string.Empty, // lo emite la capa de presentación, como en el login + TokenType: "Bearer", + ExpiresIn: graphResult.Value.EffectiveConfig.AccessTokenDurationMs / 1000, + IssuedAt: graphResult.Value.GeneratedAt, + SerializedGraph: string.Empty, + GraphFormat: "JSON")); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IPasswordResetTokenStore.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IPasswordResetTokenStore.cs new file mode 100644 index 00000000..f54f2045 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IPasswordResetTokenStore.cs @@ -0,0 +1,81 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Estados de un token de restablecimiento. Active es el único canjeable; el resto +/// existen para que el canje distinga «nunca emitido» de «ya gastado» en la auditoría, sin +/// que esa distinción llegue jamás al llamante. +/// +public static class PasswordResetTokenStatuses +{ + public const string Active = "Active"; + public const string Used = "Used"; + public const string Invalidated = "Invalidated"; +} + +/// +/// Instantánea de un token de restablecimiento persistido. Nunca contiene el plaintext: +/// la búsqueda es por hash, igual que con los refresh tokens. +/// +public sealed record PasswordResetTokenSnapshot( + Guid Id, + Guid TenantId, + Guid UserId, + string Status, + DateTime IssuedAtUtc, + DateTime ExpiresAtUtc); + +/// +/// Puerto de persistencia de tokens de restablecimiento de contraseña. +/// +/// Existe porque la petición anónima de «olvidé mi contraseña» no debe cambiar la +/// credencial: quien conoce un correo no prueba con ello poseer el buzón. El único efecto +/// legítimo de esa petición es emitir un secreto de un solo uso y vida corta hacia el canal +/// de notificación; la contraseña anterior sigue siendo válida hasta que ese secreto se canjea. +/// +/// Se guarda ÚNICAMENTE el hash SHA-256 del token: un volcado de la tabla no permite +/// tomar ninguna cuenta. +/// +public interface IPasswordResetTokenStore +{ + /// + /// Emite un token activo. La implementación invalida primero los tokens vivos del mismo + /// usuario: solo un secreto de restablecimiento puede estar pendiente a la vez, de modo que + /// pedir el restablecimiento otra vez anula el enlace anterior. + /// + Task IssueAsync( + Guid tenantId, + Guid userId, + string tokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Busca un token por su hash, sea cual sea su estado. Devolver también los no activos es + /// intencional: permite que el canje trate «reutilizado» y «revocado» exactamente igual que + /// «inexistente» de cara al llamante, sin perder la traza interna. + /// + Task FindByHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + /// + /// Marca el token como gastado. Debe invocarse en el canje y solo una vez: es lo que + /// convierte el enlace en «de un solo uso». + /// + Task ConsumeAsync( + Guid tokenId, + DateTime consumedAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Invalida todos los tokens vivos del usuario. Se usa tras un canje exitoso y ante un + /// cambio de credencial por otra vía. Idempotente. + /// + Task InvalidateActiveForUserAsync( + Guid tenantId, + Guid userId, + string reason, + DateTime invalidatedAtUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenPolicyProvider.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenPolicyProvider.cs new file mode 100644 index 00000000..acb03a7b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenPolicyProvider.cs @@ -0,0 +1,12 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Resuelve la efectiva de un inquilino desde la +/// configuración jerárquica (Global > Suite > Tenant > Module), aplicando +/// una postura fail-closed: ante ausencia, error o valor inválido, la política +/// resultante está deshabilitada (ADR-UMS-091 / FR-015). +/// +public interface IRefreshTokenPolicyProvider +{ + RefreshTokenPolicy Resolve(Guid? tenantId, Guid? suiteId = null, Guid? moduleId = null); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenStore.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenStore.cs new file mode 100644 index 00000000..c314e9f0 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenStore.cs @@ -0,0 +1,79 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Instantánea de un refresh token persistido, expuesta a la capa de aplicación sin +/// filtrar la entidad de EF. Nunca contiene el plaintext — la búsqueda es por hash. +/// +public sealed record RefreshTokenSnapshot( + Guid Id, + Guid TenantId, + Guid UserId, + Guid FamilyId, + string Status, + DateTime IssuedAtUtc, + DateTime ExpiresAtUtc, + int RenewalCount); + +/// +/// Puerto de persistencia de refresh tokens (ADR-UMS-091 / FR-015/016). Guarda solo el +/// hash del token; el plaintext nunca cruza esta frontera hacia el almacén. +/// Aislado por inquilino. Cubre emisión, renovación (rotación) y revocación. +/// +public interface IRefreshTokenStore +{ + /// Emite (persiste) un refresh token activo. es el SHA-256 del plaintext. + Task IssueAsync( + Guid tenantId, + Guid userId, + Guid familyId, + string tokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Busca un token por su hash, sea cual sea su estado. Devuelve null si no existe. + /// Devolver tokens no-activos es intencional: permite detectar el reuso de un + /// token ya rotado/revocado y responder invalidando la familia. + /// + Task FindByHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + /// + /// Rotación atómica: marca el token como Rotated + /// (apuntando a ) y persiste el nuevo token Active + /// en la misma familia, con el contador de renovaciones incrementado en uno. + /// + Task RotateAsync( + RefreshTokenSnapshot current, + Guid newTokenId, + string newTokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Revoca toda la familia (todos los tokens no revocados con ese + /// ). Se usa en la detección de reuso y en la revocación + /// explícita (logout, bloqueo, cambio crítico de permisos). + /// + Task RevokeFamilyAsync( + Guid familyId, + string reason, + DateTime revokedAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Revoca todas las familias vivas de un usuario dentro de un inquilino + /// (todos sus tokens no revocados). Es la revocación explícita del logout: cierra + /// la sesión de refresh sin necesitar el familyId, que el logout no conoce. + /// Idempotente: si no hay tokens activos, no hace nada. + /// + Task RevokeAllForUserAsync( + Guid tenantId, + Guid userId, + string reason, + DateTime revokedAtUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IResponseTimingNormalizer.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IResponseTimingNormalizer.cs new file mode 100644 index 00000000..f25d087b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IResponseTimingNormalizer.cs @@ -0,0 +1,43 @@ +namespace Ums.Application.Identity.Auth; + +using System.Diagnostics; + +/// +/// Nivelador del tiempo de respuesta de los flujos anónimos que deben ser indistinguibles. +/// +/// Un cuerpo idéntico no basta para no filtrar la existencia de una cuenta: si el camino +/// «existe» escribe en la base y notifica, y el camino «no existe» retorna de inmediato, el reloj +/// dice lo que el mensaje calla. Este puerto acolcha la respuesta hasta un presupuesto fijo, +/// de modo que ambos caminos tarden lo mismo desde fuera. +/// +public interface IResponseTimingNormalizer +{ + /// + /// Espera lo que falte para completar el presupuesto desde + /// (obtenido con ). Si el trabajo real ya lo excedió, retorna + /// sin esperar. + /// + Task NormalizeAsync(long startingTimestamp, CancellationToken cancellationToken = default); +} + +/// +/// Implementación por reloj: acolcha hasta . +/// +public sealed class ResponseTimingNormalizer : IResponseTimingNormalizer +{ + /// + /// Presupuesto fijo. Debe superar con holgura el camino «cuenta existe» (lectura + escritura + + /// notificación simulada) sin degradar la experiencia ni volverse una palanca de saturación: + /// el endpoint es anónimo y cada petición retiene una conexión durante este tiempo. + /// + public static readonly TimeSpan Budget = TimeSpan.FromMilliseconds(400); + + public async Task NormalizeAsync(long startingTimestamp, CancellationToken cancellationToken = default) + { + var remaining = Budget - Stopwatch.GetElapsedTime(startingTimestamp); + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IdpChainAuthenticator.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IdpChainAuthenticator.cs new file mode 100644 index 00000000..58ba9402 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IdpChainAuthenticator.cs @@ -0,0 +1,267 @@ +using Ums.Application.Common.Interfaces; +using Ums.Application.Configuration.Services; +using Ums.Domain.Configuration; +using Ums.Domain.Configuration.IdpConfiguration; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +namespace Ums.Application.Identity.Auth; + +/// +/// Implementación del fallback encadenado de FR-042 (ADR-UMS-097 §2.3/§2.4, slice 2b). +/// +/// Disparador (irrenunciable): el avance por FallbackToId ocurre EXCLUSIVAMENTE +/// ante (indisponibilidad de infraestructura). Un +/// DETIENE el recorrido y devuelve el error (nunca se +/// prueba la credencial contra el siguiente IdP: anti credential spraying). La clasificación es +/// fail-closed (). +/// +/// Recorrido: comienza en la configuración ganadora del selector 2a +/// (, por prioridad/suite/dominio) y sigue FallbackToId +/// con detección de ciclos (conjunto de visitados) y tope de saltos configurable. Si la cadena se +/// agota por indisponibilidad devuelve (503, no 401). +/// +/// Puente reglas ↔ dominio (ADR-UMS-097 §2.5): cada configuración se resuelve al +/// del inquilino cuya estrategia corresponde a su ProviderType +/// (ProviderType.IdIdpStrategy.Id, enumeraciones paralelas). A diferencia de la +/// selección de método (2a), aquí no se exige IsActive: el inquilino solo puede tener un +/// proveedor activo, pero la cadena la gobierna IdpConfiguration.Status; los proveedores de los +/// eslabones de respaldo están registrados aunque no sean el activo primario. +/// +/// Auditoría (ADR-UMS-097 §2.4): un evento append-only por proveedor intentado, acotado por +/// inquilino, con {config intentada, resultado, motivo}. Nunca se registra la credencial ni secretos +/// ([G-040]#5): solo se audita el código/motivo del resultado, que no contiene material sensible. +/// +public sealed class IdpChainAuthenticator : IIdpChainAuthenticator +{ + /// + /// Cadena de fallback agotada por indisponibilidad ⇒ 503 (ADR-UMS-097 §2.3), no 401. Código local + /// al motor de auth (mismo patrón que OidcAuthErrors AUTH_020..034; no vive en el catálogo SDK). + /// + public const string ChainExhaustedError = + "AUTH_018: El servicio de autenticación federada no está disponible temporalmente (cadena de fallback agotada)."; + + /// + /// Guarda defensiva: modo IdP sin ningún proveedor utilizable. No debería alcanzarse tras la + /// resolución 2a en el flujo externo; se conserva por robustez. Se clasifica como infra a nivel HTTP + /// (AUTH_012 ⇒ 503) porque significa que el adaptador/proveedor no está disponible, no que la credencial sea inválida. + /// + private const string NoUsableProviderError = + "AUTH_012: No hay un proveedor de identidad utilizable para el inquilino."; + + /// + /// Tope de saltos configurable (cascada Global>Suite>Tenant>Module); default seguro = 5. + /// No se siembra como parámetro obligatorio: ausente ⇒ default. La detección de ciclos ya acota el + /// recorrido con independencia de este tope (es una segunda red de seguridad). + /// + public const string MaxHopsConfigCode = "AUTH_IDP_FALLBACK_MAX_HOPS"; + private const int DefaultMaxHops = 5; + + private const string IdpAttemptEventType = "Auth.Login.IdpAttempt"; + + private readonly IIdpConfigurationRepository _idpConfigRepo; + private readonly IIdpAuthStrategy _idpStrategy; + private readonly IAuthAuditService _auditService; + private readonly IConfigurationProvider _config; + + public IdpChainAuthenticator( + IIdpConfigurationRepository idpConfigRepo, + IIdpAuthStrategy idpStrategy, + IAuthAuditService auditService, + IConfigurationProvider config) + { + _idpConfigRepo = idpConfigRepo; + _idpStrategy = idpStrategy; + _auditService = auditService; + _config = config; + } + + public async Task> AuthenticateAsync( + TenantAggregate tenant, + string credential, + Guid? systemSuiteId, + string? emailDomain, + string clientIp, + CancellationToken cancellationToken = default) + { + var tenantId = tenant.Props.Id.GetValue(); + var tenantCode = tenant.Props.Code.GetValue(); + + // FR-042 (ADR-UMS-097 §2.2): misma procedencia de suite que el resolver 2a — si el AccessScope no + // fija suite, se usa el default del inquilino. Mantiene coherente el eslabón inicial de la cadena + // 2b con el proveedor que eligió la resolución del método (2a). + var effectiveSuiteId = systemSuiteId ?? tenant.DefaultSystemSuiteId?.GetValue(); + + var configurations = await _idpConfigRepo.GetByTenantIdAsync(tenantId, cancellationToken); + var selection = IdpConfigurationSelector.Select(configurations, effectiveSuiteId, emailDomain, providerType: null); + + // ── Legacy: ninguna IdpConfiguration gobierna la selección → intento único contra el proveedor + // activo del inquilino, conservando el comportamiento previo a 2b (sin cadena ni 503). + if (selection is null) + { + var legacyProvider = tenant.GetActiveIdentityProvider(); + if (legacyProvider is null) + { + return Result.Failure(NoUsableProviderError); + } + + return await AttemptLegacyAsync(tenantId, tenantCode, credential, clientIp, legacyProvider, cancellationToken); + } + + // ── Cadena gobernada por reglas: recorrido de FallbackToId con ciclos + tope de saltos. + var byId = BuildIndex(configurations); + var maxHops = Math.Max(1, _config.GetValueAs(MaxHopsConfigCode, tenantId, DefaultMaxHops)); + var visited = new HashSet(); + + var current = selection.Value.Configuration; + var hops = 0; + + while (current is not null && current.Status == IdpConfigStatus.Active) + { + var configId = current.Props.Id.GetValue(); + + // Detección de ciclos: un Id ya visitado corta el recorrido (sin bucle infinito). + if (!visited.Add(configId)) + { + break; + } + + // Tope de saltos: red de seguridad secundaria a la detección de ciclos. + if (hops >= maxHops) + { + break; + } + + hops++; + + var provider = BridgeToProvider(tenant, current); + if (provider is null) + { + // El adaptador/proveedor de esta configuración no está registrado → indisponibilidad de + // infraestructura de ESTE eslabón (§2.3 «adaptador no disponible/no registrado»). No se + // prueba ninguna credencial, así que avanzar no abre spraying. + await AuditAttemptAsync(tenantId, tenantCode, clientIp, DescribeConfig(current), + IdpAuthOutcome.InfraUnavailable, "Proveedor no registrado para la estrategia de la configuración.", + cancellationToken); + current = NextInChain(current, byId); + continue; + } + + var attempt = await _idpStrategy.AuthenticateAsync(tenantId, credential, provider, cancellationToken); + var outcome = IdpAuthOutcomeClassifier.Classify(attempt); + + await AuditAttemptAsync(tenantId, tenantCode, clientIp, DescribeConfig(current), outcome, + outcome == IdpAuthOutcome.Success ? "Autenticación exitosa." : attempt.Error, cancellationToken); + + switch (outcome) + { + case IdpAuthOutcome.Success: + return Result.Success(new IdpChainOutcome(attempt.Value, provider)); + + case IdpAuthOutcome.CredentialTerminal: + // TERMINAL: el IdP rechazó (o el fallo es no clasificable como infra). NO se avanza al + // siguiente proveedor — encadenar aquí permitiría credential spraying cross-IdP (§2.3). + return Result.Failure(attempt.Error); + + default: + // Indisponibilidad de infraestructura (IdpAuthOutcome.InfraUnavailable): avanzar por FallbackToId. + current = NextInChain(current, byId); + break; + } + } + + // Cadena agotada (todos los eslabones indisponibles, o ciclo, o tope) → 503, NUNCA 401. + return Result.Failure(ChainExhaustedError); + } + + /// Intento único (sin cadena) para inquilinos legados sin IdpConfiguration gobernante. + private async Task> AttemptLegacyAsync( + Guid tenantId, + string tenantCode, + string credential, + string clientIp, + IdentityProvider provider, + CancellationToken cancellationToken) + { + var attempt = await _idpStrategy.AuthenticateAsync(tenantId, credential, provider, cancellationToken); + var outcome = IdpAuthOutcomeClassifier.Classify(attempt); + + await AuditAttemptAsync(tenantId, tenantCode, clientIp, + $"legacy:{provider.Strategy.Name}", outcome, + outcome == IdpAuthOutcome.Success ? "Autenticación exitosa." : attempt.Error, cancellationToken); + + // Sin cadena no hay fallback: cualquier fallo es terminal y devuelve el error tal cual (comportamiento + // previo a 2b; no se convierte en 503, que es una semántica propia del recorrido de cadena). + return outcome == IdpAuthOutcome.Success + ? Result.Success(new IdpChainOutcome(attempt.Value, provider)) + : Result.Failure(attempt.Error); + } + + /// + /// Puente configuración → proveedor por estrategia (ADR-UMS-097 §2.5). No exige IsActive: ver + /// nota de clase. Devuelve null si el inquilino no registró un proveedor de esa estrategia. + /// + private static IdentityProvider? BridgeToProvider(TenantAggregate tenant, IdpConfigurationAggregate config) + => tenant.IdentityProviders.FirstOrDefault(ip => ip.Strategy.Id == config.ProviderType.Id); + + private static IdpConfigurationAggregate? NextInChain( + IdpConfigurationAggregate current, + IReadOnlyDictionary byId) + { + var fallbackToId = current.Props.FallbackToId; + if (!fallbackToId.HasValue) + { + return null; + } + + return byId.TryGetValue(fallbackToId.Value, out var next) ? next : null; + } + + private static IReadOnlyDictionary BuildIndex( + IReadOnlyList configurations) + { + var index = new Dictionary(configurations.Count); + foreach (var configuration in configurations) + { + index[configuration.Props.Id.GetValue()] = configuration; + } + + return index; + } + + /// Etiqueta de traza del intento: tipo de proveedor + Id de la configuración (no secreto). + private static string DescribeConfig(IdpConfigurationAggregate config) + => $"{config.ProviderType.Name}#{config.Props.Id.GetValue()}"; + + private async Task AuditAttemptAsync( + Guid tenantId, + string tenantCode, + string clientIp, + string idpLabel, + IdpAuthOutcome outcome, + string reason, + CancellationToken cancellationToken) + { + // Motivo auditado sin material sensible: es el código/mensaje del resultado, nunca la credencial + // ni el token ([G-040]#5). La credencial jamás entra en estos textos. + var resultLabel = outcome switch + { + IdpAuthOutcome.Success => "success", + IdpAuthOutcome.InfraUnavailable => "infra->advance", + _ => "credential->terminal", + }; + + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: Guid.Empty, + TenantId: tenantId, + TenantCode: tenantCode, + AuthMethod: "IDP", + EventType: IdpAttemptEventType, + Succeeded: outcome == IdpAuthOutcome.Success, + ClientIp: clientIp, + FailureReason: $"{resultLabel}: {reason}", + IdpProvider: idpLabel), cancellationToken); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/PasswordResetToken.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/PasswordResetToken.cs new file mode 100644 index 00000000..5eac7ce7 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/PasswordResetToken.cs @@ -0,0 +1,43 @@ +namespace Ums.Application.Identity.Auth; + +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; + +/// +/// Secreto de restablecimiento de contraseña: generación del plaintext y hash de persistencia. +/// +/// El plaintext se entrega UNA sola vez y por el canal del buzón (correo), nunca en la +/// respuesta HTTP de la petición anónima: si viajara en el cuerpo, quien conoce el correo ya +/// tendría el secreto y la prueba de posesión del buzón —única razón de ser del flujo— no +/// probaría nada. +/// +/// Codificado en Base64Url para poder viajar tal cual dentro de un enlace sin escapes. +/// +public static class PasswordResetToken +{ + /// + /// Vida del secreto. Corta a propósito: el token es equivalente a la contraseña durante su + /// vigencia, así que la ventana de exposición de un buzón comprometido debe ser mínima. + /// + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15); + + /// 256 bits de entropía criptográfica: inadivinable por fuerza bruta en línea o fuera de ella. + public static string Generate() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64Url.EncodeToString(bytes); + } + + /// + /// SHA-256 hex en minúsculas del plaintext. Se persiste el hash, nunca el token: quien lea + /// la tabla no puede canjear nada. No lleva sal ni coste porque el secreto ya tiene 256 bits + /// de entropía — el ataque de diccionario que justifica BCrypt en contraseñas no aplica. + /// + public static string Hash(string plaintext) + { + ArgumentException.ThrowIfNullOrEmpty(plaintext); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(plaintext))); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenGenerator.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenGenerator.cs new file mode 100644 index 00000000..1d0d028d --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenGenerator.cs @@ -0,0 +1,20 @@ +namespace Ums.Application.Identity.Auth; + +using System.Security.Cryptography; + +/// +/// Genera el plaintext de un refresh token opaco (ADR-UMS-091): 64 bytes aleatorios +/// criptográficos en base64. Espeja el formato de JwtTokenService.GenerateRefreshToken, +/// pero vive en la capa de aplicación para que la rotación (que persiste el hash) no +/// dependa de la capa de presentación. El plaintext se devuelve una sola vez al cliente; +/// solo su hash SHA-256 se persiste (). +/// +public static class RefreshTokenGenerator +{ + public static string Generate() + { + var bytes = new byte[64]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenHasher.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenHasher.cs new file mode 100644 index 00000000..5bbff7d9 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenHasher.cs @@ -0,0 +1,19 @@ +namespace Ums.Application.Identity.Auth; + +using System.Security.Cryptography; +using System.Text; + +/// +/// Hash de refresh tokens para persistencia (ADR-UMS-091). Se guarda el hash, nunca el +/// plaintext — como una contraseña. La búsqueda en renovación/revocación se hace +/// hasheando el token presentado y comparando. SHA-256 hex en minúsculas. +/// +public static class RefreshTokenHasher +{ + public static string Hash(string plaintext) + { + ArgumentException.ThrowIfNullOrEmpty(plaintext); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(plaintext)); + return Convert.ToHexStringLower(bytes); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicy.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicy.cs new file mode 100644 index 00000000..553c4f3e --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicy.cs @@ -0,0 +1,19 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Política efectiva de refresh token de un inquilino, resuelta desde la +/// configuración jerárquica (ADR-UMS-091 / FR-015). Fail-closed: si la capacidad no +/// está habilitada, es false y el resto es irrelevante +/// (se conserva el modelo de ADR-UMS-088: solo validUntil). +/// +public sealed record RefreshTokenPolicy( + bool Enabled, + int LifetimeMinutes, + bool Rotate, + bool DetectReuse, + int MaxRenewals) +{ + /// Política deshabilitada — comportamiento por defecto y fail-closed. + public static readonly RefreshTokenPolicy Disabled = + new(Enabled: false, LifetimeMinutes: 0, Rotate: false, DetectReuse: false, MaxRenewals: 0); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicyProvider.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicyProvider.cs new file mode 100644 index 00000000..ab46180d --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicyProvider.cs @@ -0,0 +1,61 @@ +namespace Ums.Application.Identity.Auth; + +using Ums.Application.Configuration.Services; +using Ums.Domain.Configuration.AppConfiguration; + +/// +/// Implementación fail-closed de : lee los +/// códigos de por la cascada jerárquica del +/// . Cualquier lectura ausente cae al default +/// seguro (deshabilitado). No emite ni persiste nada — solo resuelve la política. +/// +public sealed class RefreshTokenPolicyProvider : IRefreshTokenPolicyProvider +{ + private readonly IConfigurationProvider _config; + + public RefreshTokenPolicyProvider(IConfigurationProvider config) + { + _config = config; + } + + public RefreshTokenPolicy Resolve(Guid? tenantId, Guid? suiteId = null, Guid? moduleId = null) + { + var enabled = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenEnabled, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenEnabled); + + if (!enabled) + { + return RefreshTokenPolicy.Disabled; + } + + var lifetimeMinutes = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenLifetimeMinutes, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenLifetimeMinutes); + + // Vida no positiva con la capacidad activada: configuración inválida ⇒ fail-closed. + if (lifetimeMinutes <= 0) + { + return RefreshTokenPolicy.Disabled; + } + + var rotate = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenRotate, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenRotate); + + var detectReuse = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenDetectReuse, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenDetectReuse); + + var maxRenewals = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenMaxRenewals, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenMaxRenewals); + + return new RefreshTokenPolicy( + Enabled: true, + LifetimeMinutes: lifetimeMinutes, + Rotate: rotate, + DetectReuse: detectReuse, + MaxRenewals: maxRenewals < 0 ? 0 : maxRenewals); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenStatuses.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenStatuses.cs new file mode 100644 index 00000000..02c65f32 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenStatuses.cs @@ -0,0 +1,22 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Estados canónicos del ciclo de vida de un refresh token (ADR-UMS-091). +/// +/// +/// Active: vigente; el único que renueva. +/// Rotated: fue canjeado y reemplazado por otro de su familia (rotación normal). +/// Used: consumido sin rotación (política sin rotación). +/// Revoked: invalidado (logout, bloqueo, cambio de permisos o detección de reuso). +/// +/// +/// Definidos en la capa de aplicación para que el resolutor y el almacén (infraestructura) +/// compartan la misma verdad sin que aplicación dependa de infraestructura. +/// +public static class RefreshTokenStatuses +{ + public const string Active = "Active"; + public const string Rotated = "Rotated"; + public const string Revoked = "Revoked"; + public const string Used = "Used"; +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommand.cs new file mode 100644 index 00000000..f2b1cea5 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommand.cs @@ -0,0 +1,17 @@ +using Ums.Application.Identity.Tenant.Branch.DTOs; + +namespace Ums.Application.Identity.Tenant.Branch.Commands; + +/// +/// Cierre DEFINITIVO de una sucursal (ADR-0164 §2.1). Sustituye a RemoveBranchCommand, que +/// borraba la fila: el nombre cambia con la semántica porque dejar vivo el vocabulario del borrado +/// físico es la vía más rápida a que alguien lo reintroduzca. +/// +/// +/// Motivo declarado del cierre. Va a la bitácora, no al estado: dentro de dos años la pregunta no +/// será si la sucursal está cerrada —eso se ve— sino por qué se cerró y quién lo decidió. +/// +public sealed record CloseBranchCommand( + Guid TenantId, + Guid BranchId, + string? Reason = null) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandHandler.cs new file mode 100644 index 00000000..d6b0014b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandHandler.cs @@ -0,0 +1,100 @@ +using Ums.Application.Identity.Tenant.Branch.DTOs; +using Ums.Domain.Authorization; + +namespace Ums.Application.Identity.Tenant.Branch.Commands; + +/// +/// Cierra definitivamente una sucursal (ADR-0164). Antes esto la BORRABA de la base. +/// +/// Guarda de cascada. Se cuentan las referencias VIVAS a la sucursal en los dos +/// agregados que la apuntan —cuentas y perfiles— y, si hay alguna, se rechaza con 409 nombrando +/// cuántas de cada clase. Es imprescindible que la haga la aplicación: ni Profiles.BranchId +/// ni UserAccounts.BranchId tienen clave ajena contra TenantBranches, así que la base +/// nunca habría dicho nada — de hecho el borrado físico anterior huerfanizaba esas filas en +/// silencio. +/// +/// Lo ya eliminado no bloquea: los dos recuentos miran solo lo activo (§2.2). +/// +public sealed class CloseBranchCommandHandler : ICommandHandler +{ + private readonly ITenantRepository _tenantRepository; + private readonly IUserAccountRepository _userAccountRepository; + private readonly IProfileRepository _profileRepository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public CloseBranchCommandHandler( + ITenantRepository tenantRepository, + IUserAccountRepository userAccountRepository, + IProfileRepository profileRepository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _tenantRepository = tenantRepository; + _userAccountRepository = userAccountRepository; + _profileRepository = profileRepository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task> Handle( + CloseBranchCommand request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to close a branch."); + } + + var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken); + if (tenant is null) + { + return Result.Failure("Tenant was not found."); + } + + var scopeResult = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(request.TenantId, cancellationToken); + if (scopeResult.IsFailure) + { + return Result.Failure(scopeResult.Error); + } + + // ── Guarda de cascada: referencias vivas a la sucursal (ADR-0164 §2.2) ── + var activeUserCount = await _userAccountRepository.CountActiveByBranchAsync( + request.BranchId, cancellationToken); + var activeProfileCount = await _profileRepository.CountActiveByBranchAsync( + request.BranchId, cancellationToken); + + if (activeUserCount > 0 || activeProfileCount > 0) + { + // Se enumeran las DOS clases en la misma respuesta, no la primera que aparece: quien + // opera necesita saber todo lo que tiene que resolver antes de reintentar. + var deps = new List(); + if (activeUserCount > 0) deps.Add(new BlockingDependency("UserAccount", "Active", activeUserCount)); + if (activeProfileCount > 0) deps.Add(new BlockingDependency("Profile", "Active", activeProfileCount)); + + return Result.Failure( + BlockedOperationError.Encode(DomainErrors.Tenant.BranchHasLiveReferences, deps)); + } + + // El dominio revalida los recuentos: la guarda de arriba existe para poder adjuntar el + // desglose, no para sustituir a la invariante. + var result = tenant.CloseBranch( + IdValueObject.Load(request.BranchId), + ActorId.Create(_userContext.UserId), + activeUserCount, + activeProfileCount, + request.Reason); + + if (result.IsFailure) + { + return Result.Failure(result.Error); + } + + await _tenantRepository.UpdateAsync(tenant, cancellationToken); + await _tenantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(new CloseBranchResponse(request.TenantId)); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandValidator.cs new file mode 100644 index 00000000..f1e803c6 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandValidator.cs @@ -0,0 +1,21 @@ +namespace Ums.Application.Identity.Tenant.Branch.Commands; + +using FluentValidation; + +public sealed class CloseBranchCommandValidator : AbstractValidator +{ + public CloseBranchCommandValidator() + { + RuleFor(command => command.TenantId) + .NotEmpty(); + + RuleFor(command => command.BranchId) + .NotEmpty(); + + // El motivo es opcional, pero si viene debe caber en la columna de la bitácora: mejor un 400 + // explicable que un truncamiento silencioso del único texto que explica el cierre. + RuleFor(command => command.Reason) + .MaximumLength(500) + .When(command => command.Reason is not null); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/DeactivateBranchCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/DeactivateBranchCommandHandler.cs index c59ec08a..f15ef3c7 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/DeactivateBranchCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/DeactivateBranchCommandHandler.cs @@ -5,15 +5,18 @@ namespace Ums.Application.Identity.Tenant.Branch.Commands; public sealed class DeactivateBranchCommandHandler : ICommandHandler { private readonly ITenantRepository _tenantRepository; + private readonly IUserAccountRepository _userAccountRepository; private readonly IUserContext _userContext; private readonly ITenantScopePolicy _tenantScopePolicy; public DeactivateBranchCommandHandler( ITenantRepository tenantRepository, + IUserAccountRepository userAccountRepository, IUserContext userContext, ITenantScopePolicy tenantScopePolicy) { _tenantRepository = tenantRepository; + _userAccountRepository = userAccountRepository; _userContext = userContext; _tenantScopePolicy = tenantScopePolicy; } @@ -41,6 +44,20 @@ public async Task> Handle( return Result.Failure(scopeResult.Error); } + // ── Dependency guard: active users bound to the branch (G-046) ──────── + var activeUserCount = await _userAccountRepository.CountActiveByBranchAsync( + request.BranchId, cancellationToken); + + if (activeUserCount > 0) + { + var deps = new List + { + new("UserAccount", "Active", activeUserCount), + }; + return Result.Failure( + BlockedOperationError.Encode(DomainErrors.Tenant.BranchHasActiveUsers, deps)); + } + var result = tenant.DeactivateBranch( IdValueObject.Load(request.BranchId), ActorId.Create(_userContext.UserId)); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommand.cs deleted file mode 100644 index 766488a2..00000000 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Ums.Application.Identity.Tenant.Branch.DTOs; - - - -namespace Ums.Application.Identity.Tenant.Branch.Commands; - - -public sealed record RemoveBranchCommand( - Guid TenantId, - Guid BranchId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandValidator.cs deleted file mode 100644 index 3db94b04..00000000 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Ums.Application.Identity.Tenant.Branch.Commands; - -using FluentValidation; - -public sealed class RemoveBranchCommandValidator : AbstractValidator -{ - public RemoveBranchCommandValidator() - { - RuleFor(command => command.TenantId) - .NotEmpty(); - - RuleFor(command => command.BranchId) - .NotEmpty(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommand.cs new file mode 100644 index 00000000..411f2ce3 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.Identity.Tenant.Branch.Commands; + +// FS-26 (G-024): actualiza los datos editables de una sucursal. El Code es inmutable. +public sealed record UpdateBranchCommand( + Guid TenantId, + Guid BranchId, + string Name, + string? GeofencingMetadata) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommandHandler.cs similarity index 62% rename from src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandHandler.cs rename to src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommandHandler.cs index 62655fdc..2130d6f3 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommandHandler.cs @@ -1,14 +1,12 @@ -using Ums.Application.Identity.Tenant.Branch.DTOs; - namespace Ums.Application.Identity.Tenant.Branch.Commands; -public sealed class RemoveBranchCommandHandler : ICommandHandler +public sealed class UpdateBranchCommandHandler : ICommandHandler { private readonly ITenantRepository _tenantRepository; private readonly IUserContext _userContext; private readonly ITenantScopePolicy _tenantScopePolicy; - public RemoveBranchCommandHandler( + public UpdateBranchCommandHandler( ITenantRepository tenantRepository, IUserContext userContext, ITenantScopePolicy tenantScopePolicy) @@ -20,39 +18,39 @@ public RemoveBranchCommandHandler( [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task> Handle( - RemoveBranchCommand request, - CancellationToken cancellationToken) + public async Task Handle(UpdateBranchCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) { - return Result.Failure("Authenticated user is required to remove a branch."); + return Result.Failure("Authenticated user is required to update a branch."); } var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken); if (tenant is null) { - return Result.Failure("Tenant was not found."); + return Result.Failure("Tenant was not found."); } var scopeResult = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(request.TenantId, cancellationToken); if (scopeResult.IsFailure) { - return Result.Failure(scopeResult.Error); + return Result.Failure(scopeResult.Error); } - var result = tenant.RemoveBranch( + var result = tenant.UpdateBranch( IdValueObject.Load(request.BranchId), + Name.Create(request.Name), + string.IsNullOrWhiteSpace(request.GeofencingMetadata) ? null : Value.Create(request.GeofencingMetadata), ActorId.Create(_userContext.UserId)); if (result.IsFailure) { - return Result.Failure(result.Error); + return result; } await _tenantRepository.UpdateAsync(tenant, cancellationToken); await _tenantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(new RemoveBranchResponse(request.TenantId)); + return Result.Success(); } } diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchDto.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchDto.cs index 3cd9fa2e..5d6a1978 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchDto.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchDto.cs @@ -1,8 +1,14 @@ namespace Ums.Application.Identity.Tenant.Branch.DTOs; +/// +/// Cierre definitivo (ADR-0164). Se expone —y no solo se filtra— porque quien pida explícitamente +/// las cerradas debe poder distinguirlas de las meramente desactivadas: son dos cosas distintas. +/// public sealed record BranchDto( Guid BranchId, string Code, string Name, bool IsActive, - string? GeofencingMetadata); + string? GeofencingMetadata, + bool IsClosed = false, + DateTime? ClosedAtUtc = null); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchLifecycleEntryDto.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchLifecycleEntryDto.cs new file mode 100644 index 00000000..b931a641 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchLifecycleEntryDto.cs @@ -0,0 +1,15 @@ +namespace Ums.Application.Identity.Tenant.Branch.DTOs; + +/// +/// Un asiento de la bitácora de una sucursal, tal y como sale por el API (ADR-0164). +/// El episodio viaja como NOMBRE ("Opened", "Deactivated", "Reactivated", "Closed") y no como +/// número: quien lee una auditoría no debería tener que consultar una tabla de equivalencias. +/// +public sealed record BranchLifecycleEntryDto( + Guid EntryId, + string Episode, + DateTime OccurredAtUtc, + string ActorId, + string NameSnapshot, + string? GeofencingSnapshot, + string? Reason); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/CloseBranchResponse.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/CloseBranchResponse.cs new file mode 100644 index 00000000..2aa495ca --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/CloseBranchResponse.cs @@ -0,0 +1,3 @@ +namespace Ums.Application.Identity.Tenant.Branch.DTOs; + +public sealed record CloseBranchResponse(Guid TenantId); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/RemoveBranchResponse.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/RemoveBranchResponse.cs deleted file mode 100644 index 244a2dcb..00000000 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/RemoveBranchResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Identity.Tenant.Branch.DTOs; - -public sealed record RemoveBranchResponse(Guid TenantId); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQuery.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQuery.cs new file mode 100644 index 00000000..8fdb51f6 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQuery.cs @@ -0,0 +1,12 @@ +using Ums.Application.Identity.Tenant.Branch.DTOs; + +namespace Ums.Application.Identity.Tenant.Branch.Queries; + +/// +/// Bitácora de una sucursal: sus episodios en orden cronológico (ADR-0164). +/// Responde a la pregunta que el estado no puede responder: «¿cómo estaba esta sucursal cuando salió +/// aquel despacho, y quién decidió cada cambio?». +/// +public sealed record GetBranchLifecycleQuery( + Guid TenantId, + Guid BranchId) : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQueryHandler.cs new file mode 100644 index 00000000..4d760034 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQueryHandler.cs @@ -0,0 +1,52 @@ +using Ums.Application.Identity.Tenant.Branch.DTOs; +using Ums.Domain.Identity.Tenant; + +namespace Ums.Application.Identity.Tenant.Branch.Queries; + +public sealed class GetBranchLifecycleQueryHandler : IQueryHandler> +{ + private readonly ITenantRepository _tenantRepository; + + public GetBranchLifecycleQueryHandler(ITenantRepository tenantRepository) + { + _tenantRepository = tenantRepository; + } + + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task>> Handle( + GetBranchLifecycleQuery request, + CancellationToken cancellationToken) + { + var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken); + if (tenant is null) + { + return Result>.Failure("Tenant not found."); + } + + // La resolución por id SÍ devuelve las cerradas (ADR-0164): preguntar por el pasado de una + // sucursal cerrada es exactamente para lo que existe la bitácora, así que aquí no se filtra + // por `IsClosed`. Lo que se comprueba es que la sucursal PERTENEZCA a este inquilino, para + // que el identificador de otro no sirva de sonda. + var branch = tenant.Branches.FirstOrDefault(b => b.GetId().GetValue() == request.BranchId); + if (branch is null) + { + return Result>.Failure(DomainErrors.Tenant.BranchNotFound); + } + + var entries = await _tenantRepository.GetBranchLifecycleAsync( + request.TenantId, request.BranchId, cancellationToken); + + var dtos = entries + .Select(e => new BranchLifecycleEntryDto( + e.Id, + e.Episode.Name, + e.OccurredAtUtc, + e.ActorId, + e.NameSnapshot, + e.GeofencingSnapshot, + e.Reason)) + .ToList(); + + return Result>.Success(dtos.AsReadOnly()); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQuery.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQuery.cs index 490f41aa..0aaee353 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQuery.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQuery.cs @@ -2,4 +2,11 @@ namespace Ums.Application.Identity.Tenant.Branch.Queries; -public sealed record GetBranchesByTenantIdQuery(Guid TenantId) : IQuery>; +/// +/// ADR-0164: por defecto el listado NO trae las sucursales cerradas definitivamente — para quien +/// opera hoy ya no existen. Se puede pedir que las traiga para revisar el histórico, que es la +/// diferencia entre «oculto» y «borrado»: lo borrado no se puede pedir. +/// +public sealed record GetBranchesByTenantIdQuery( + Guid TenantId, + bool IncludeClosed = false) : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQueryHandler.cs index e8e255a8..30ca32dd 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQueryHandler.cs @@ -25,13 +25,20 @@ public async Task>> Handle( return Result>.Failure("Tenant not found."); } + // ADR-0164: el filtro de las cerradas vive AQUÍ, en la lectura, y no en un filtro global de + // EF. Con un filtro global la sucursal cerrada desaparecería también de la vía de escritura y + // de la resolución por id —el grafo de autorización resuelve la sucursal de un perfil por su + // id—, y el borrado lógico habría acabado siendo tan opaco como el físico que sustituye. var branches = tenant.Branches + .Where(b => request.IncludeClosed || !b.IsClosed) .Select(b => new BranchDto( b.GetId().GetValue(), b.Code.GetValue(), b.Name.GetValue(), b.IsActive, - b.GeofencingMetadata?.GetValue())) + b.GeofencingMetadata?.GetValue(), + b.IsClosed, + b.ClosedAtUtc)) .ToList(); return Result>.Success(branches.AsReadOnly()); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/CreateTenantCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/CreateTenantCommandHandler.cs index 93dbad96..d8a0b4e3 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/CreateTenantCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/CreateTenantCommandHandler.cs @@ -35,12 +35,39 @@ public async Task> Handle( return Result.Failure("Tenant code already exists."); } - var type = DomainEnumerationParser.FromName(request.Type)!; + // G-045/G-037: la propiedad de gestión (management owner) es única en todo el sistema. + // Existe un índice parcial único en BD (IX_Tenants_SingleManagementOwner). Sin este + // chequeo explícito, crear un segundo tenant con IsManagementOwner=true provoca una + // violación de índice en SaveEntitiesAsync que termina en 500. Verificamos la unicidad + // ANTES de persistir y devolvemos un Result.Failure que el mapeo de presentación + // resuelve a 409 (análogo a SetManagementOwnerCommandHandler). + if (request.IsManagementOwner) + { + var allTenants = await _tenantRepository.GetAllAsync(null, cancellationToken); + var existingOwner = allTenants.FirstOrDefault(candidate => candidate.IsManagementOwner); + if (existingOwner is not null) + { + return Result.Failure(DomainErrors.Tenant.ManagementOwnerAlreadyExists); + } + } + + // G-045: red de defensa en profundidad. El validador ya rechaza un OrganizationType + // desconocido, pero no debemos depender de que el pipeline de validación esté cableado: + // un enum inválido debe resolver Result.Failure (→ 400) y nunca propagar null hasta una + // NullReferenceException que colapsaría a 500 (sin excepciones para control de flujo). + var type = DomainEnumerationParser.FromName(request.Type); + if (type is null) + { + return Result.Failure(DomainErrors.Common.Invalid); + } + var idpStrategy = DomainEnumerationParser.FromName(request.IdpStrategy) ?? IdpStrategy.InternalBcrypt; var companyReference = string.IsNullOrWhiteSpace(request.CompanyReference) ? null : CompanyReference.Create(request.CompanyReference); - var parentTenantId = TenantId.Load(Guid.NewGuid()); + // G-046: a tenant created through this endpoint is a top-level tenant with no parent. + // Never fabricate a random parent id — that produced a dangling, non-existent reference. + TenantId? parentTenantId = null; var tenantResult = Tenant.Create( code, diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/SetManagementOwnerCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/SetManagementOwnerCommandHandler.cs index a4dd2e65..a8f818aa 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/SetManagementOwnerCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/SetManagementOwnerCommandHandler.cs @@ -28,6 +28,24 @@ public async Task Handle(SetManagementOwnerCommand request, Cancellation return Result.Failure("Tenant was not found."); } + // G-045: la propiedad de gestión (management owner) es única en todo el sistema. + // Existe un índice parcial único en BD (IX_Tenants_SingleManagementOwner). Sin este + // chequeo explícito, otorgarla a un segundo tenant provoca una violación de índice + // en SaveEntitiesAsync que se traduce en 500. Verificamos la unicidad ANTES de + // persistir y devolvemos un Result.Failure que el mapeo de presentación resuelve a 409. + if (request.Value) + { + var allTenants = await _tenantRepository.GetAllAsync(null, cancellationToken); + var existingOwner = allTenants.FirstOrDefault(candidate => + candidate.IsManagementOwner && + candidate.Props.Id.GetValue() != request.TenantId); + + if (existingOwner is not null) + { + return Result.Failure(DomainErrors.Tenant.ManagementOwnerAlreadyExists); + } + } + var result = tenant.SetManagementOwner(request.Value, ActorId.Create(_userContext.UserId)); if (result.IsFailure) { diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommand.cs new file mode 100644 index 00000000..1abbc894 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.Identity.Tenant.Commands; + +// FS-26 (G-024): actualiza los datos generales editables del tenant. El Code es inmutable. +public sealed record UpdateTenantCommand( + Guid TenantId, + string Name, + string Type, + string? CompanyReference) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommandHandler.cs new file mode 100644 index 00000000..911aff93 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommandHandler.cs @@ -0,0 +1,68 @@ +namespace Ums.Application.Identity.Tenant.Commands; + +using Ums.Domain.Identity.Tenant; + +public sealed class UpdateTenantCommandHandler : ICommandHandler +{ + private readonly ITenantRepository _tenantRepository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public UpdateTenantCommandHandler( + ITenantRepository tenantRepository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _tenantRepository = tenantRepository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(UpdateTenantCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to update a tenant."); + } + + var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken); + if (tenant is null) + { + return Result.Failure("Tenant was not found."); + } + + var scopeResult = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(request.TenantId, cancellationToken); + if (scopeResult.IsFailure) + { + return Result.Failure(scopeResult.Error); + } + + var type = DomainEnumerationParser.FromName(request.Type); + if (type is null) + { + return Result.Failure("Invalid organization type."); + } + + var companyReference = string.IsNullOrWhiteSpace(request.CompanyReference) + ? null + : CompanyReference.Create(request.CompanyReference); + + var result = tenant.UpdateGeneralData( + Name.Create(request.Name), + type, + companyReference, + ActorId.Create(_userContext.UserId)); + + if (result.IsFailure) + { + return result; + } + + await _tenantRepository.UpdateAsync(tenant, cancellationToken); + await _tenantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQuery.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQuery.cs index 5cc4334f..249f7d82 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQuery.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQuery.cs @@ -6,7 +6,9 @@ public sealed record GetAllTenantsQuery( int Page = 1, int PageSize = 20, string? Search = null, - string Criteria = "name", + // Campo de búsqueda (`criteria`). Null = no especificado: el repositorio cae a SortBy por + // compatibilidad (antes el campo de búsqueda se derivaba de SortBy). Ver GetAllTenantsQueryHandler. + string? Criteria = null, string Status = "all", string SortBy = "name", string SortOrder = "asc") : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQueryHandler.cs index 4a49f31b..12a4d060 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQueryHandler.cs @@ -27,7 +27,6 @@ public async Task>> Handle( { var page = NormalizePage(request.Page); var pageSize = NormalizePageSize(request.PageSize); - var criteria = NormalizeText(request.Criteria, "name").ToLowerInvariant(); var status = NormalizeText(request.Status, "all"); var sortBy = NormalizeText(request.SortBy, "name").ToLowerInvariant(); var sortOrder = NormalizeText(request.SortOrder, "asc").ToLowerInvariant(); @@ -37,8 +36,12 @@ public async Task>> Handle( // REC-12: Push filtering/sorting/pagination to the repository so SQL // implementations use DB-level Skip/Take instead of loading all rows. + // searchField (parámetro `criteria` del API) determina el campo de búsqueda. Se pasa el valor + // CRUDO (no el normalizado con default "name"): si el cliente no envía `criteria`, va null y el + // repositorio cae a `sortBy` por compatibilidad hacia atrás (antes el campo de búsqueda se + // derivaba de sortBy). Si el cliente sí envía `criteria` (p. ej. "code"), este manda. var (tenants, totalItems) = await _tenantRepository.GetPagedAsync( - page, pageSize, search, status, sortBy, sortOrder, effectiveTenantId, cancellationToken); + page, pageSize, search, status, sortBy, sortOrder, effectiveTenantId, cancellationToken, searchField: request.Criteria); var items = tenants.Select(t => new TenantDto( t.Props.Id.GetValue(), diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetTenantByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetTenantByIdQueryHandler.cs index a424a9d2..72967187 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetTenantByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetTenantByIdQueryHandler.cs @@ -1,3 +1,4 @@ +using Ums.Application.Common.Interfaces; using Ums.Application.Identity.Tenant.DTOs; using Ums.Domain.Identity.Tenant; @@ -6,10 +7,12 @@ namespace Ums.Application.Identity.Tenant.Queries; public sealed class GetTenantByIdQueryHandler : IQueryHandler { private readonly ITenantRepository _tenantRepository; + private readonly ITenantScopePolicy _tenantScopePolicy; - public GetTenantByIdQueryHandler(ITenantRepository tenantRepository) + public GetTenantByIdQueryHandler(ITenantRepository tenantRepository, ITenantScopePolicy tenantScopePolicy) { _tenantRepository = tenantRepository; + _tenantScopePolicy = tenantScopePolicy; } [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] @@ -25,6 +28,17 @@ public async Task> Handle( return Result.Failure("Tenant not found."); } + // TS04/F3 (aislamiento cross-tenant): el agregado Tenant es su propia identidad, así que el + // global query filter (que aísla entidades con columna TenantId) NO lo cubre. Sin este chequeo, + // cualquier usuario autenticado leería la identidad de cualquier tenant por id (fuga cross-tenant). + // ResolveQueryScope() devuelve null para internal-admin (cross-tenant) y el OrganizationId propio + // para un usuario regular. Se devuelve "not found" (404, no 403) para no filtrar la existencia. + var scope = _tenantScopePolicy.ResolveQueryScope(); + if (scope.HasValue && tenant.Props.Id.GetValue() != scope.Value) + { + return Result.Failure("Tenant not found."); + } + return Result.Success(new TenantDto( tenant.Props.Id.GetValue(), tenant.Props.Code.GetValue(), diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/SignupRequests/Commands/ApproveTenantSignupCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/SignupRequests/Commands/ApproveTenantSignupCommandHandler.cs index 94473af4..4c3d43e8 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/SignupRequests/Commands/ApproveTenantSignupCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/SignupRequests/Commands/ApproveTenantSignupCommandHandler.cs @@ -112,6 +112,12 @@ public async Task> Handle(ApproveTenantSignu return Result.Failure(approveResult.Error); } + // TODO(D-016): excepción JUSTIFICADA a "un agregado por transacción" (ADR-0098 D2): + // crea Tenant + UserAccount + actualiza SignupRequest en la misma tx. El arranque de un + // inquilino con su primer admin es indivisible (crearlo a medias deja un inquilino inusable), + // por lo que se permite consistencia inmediata bajo ADR-0098 D5. Ver DECISIONS.md D-016 (E1) y GAPS.md G-066. + // Patrón e interpretación: KB-TXN-001 (Base de Conocimiento de Arquitectura, evolith-core). + // Al progresar a microservicios, separar vía saga de aprovisionamiento. await _tenantRepository.AddAsync(tenant, cancellationToken); await _userAccountRepository.AddAsync(adminUser, cancellationToken); await _requestRepository.UpdateAsync(signupRequest, cancellationToken); diff --git a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/AddPasswordCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/AddPasswordCommandHandler.cs index 1eeea8a7..5120bf0b 100644 --- a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/AddPasswordCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/AddPasswordCommandHandler.cs @@ -40,6 +40,9 @@ public async Task> Handle(AddPasswordCommand request await _userAccountRepository.UpdateAsync(userAccount, cancellationToken); await _userAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(new AddPasswordResponse(credential.Id.GetValue())); + // AT06/F1: devolver la identidad canónica (Props.Id, la que se persiste y con la que después + // se activa/remueve la credencial), NO el Id base de Entity<> —regenerado aleatorio en cada + // construcción— que jamás casaría en ActivatePassword/RemovePassword tras recargar. + return Result.Success(new AddPasswordResponse(credential.GetId().GetValue())); } } diff --git a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/RecordAuthenticationAttemptCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/RecordAuthenticationAttemptCommandHandler.cs index a4bb1294..4027313d 100644 --- a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/RecordAuthenticationAttemptCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/RecordAuthenticationAttemptCommandHandler.cs @@ -1,5 +1,6 @@ namespace Ums.Application.Identity.UserAccount.Commands; +using Ums.Application.Configuration.Services; using Ums.Domain.Identity; public sealed class RecordAuthenticationAttemptCommandHandler @@ -7,13 +8,16 @@ public sealed class RecordAuthenticationAttemptCommandHandler { private readonly IUserAccountRepository _repository; private readonly IUserContext _userContext; + private readonly IConfigurationProvider _configurationProvider; public RecordAuthenticationAttemptCommandHandler( IUserAccountRepository repository, - IUserContext userContext) + IUserContext userContext, + IConfigurationProvider configurationProvider) { _repository = repository; _userContext = userContext; + _configurationProvider = configurationProvider; } [AuditTrail] @@ -29,8 +33,14 @@ public async Task Handle( if (entity is null) return Result.Failure("User account not found."); var actor = ActorId.Create(_userContext.UserId); + // ADR-UMS-095: el instante y los parámetros de política se resuelven en la aplicación y se + // inyectan en el dominio determinista (cascada de config Global>Suite>Tenant>Module). + var cfg = _configurationProvider.ForTenant(entity.Props.TenantId.GetValue()); var result = entity.RecordAuthenticationAttempt( request.Success, + DateTimeOffset.UtcNow, + cfg.MaxLoginAttempts, + cfg.AccountLockoutDurationMinutes, request.Reason, request.IpAddress, actor); diff --git a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetAllUserAccountsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetAllUserAccountsQueryHandler.cs index fca4cbf4..8e47476b 100644 --- a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetAllUserAccountsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetAllUserAccountsQueryHandler.cs @@ -29,7 +29,6 @@ public async Task>> Handle( { var page = NormalizePage(request.Page); var pageSize = NormalizePageSize(request.PageSize); - var criteria = NormalizeText(request.Criteria, "email").ToLowerInvariant(); var status = NormalizeText(request.Status, "all"); var sortBy = NormalizeText(request.SortBy, "email").ToLowerInvariant(); var sortOrder = NormalizeText(request.SortOrder, "asc").ToLowerInvariant(); diff --git a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetUserAccountMfaEnrollmentsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetUserAccountMfaEnrollmentsQueryHandler.cs index 228258c5..2b767b7f 100644 --- a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetUserAccountMfaEnrollmentsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetUserAccountMfaEnrollmentsQueryHandler.cs @@ -27,7 +27,7 @@ public async Task>> Handle( var dtos = userAccount.MfaEnrollments .Select(e => new MfaEnrollmentDto( - e.Id.GetValue(), + e.GetId().GetValue(), e.Method.Name, e.Status.Name, e.Props.Audit.GetValue().CreatedAt)) diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommand.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommand.cs new file mode 100644 index 00000000..d53a5815 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +/// +/// PendingApproval → Active (delegación, ADR-UMS-086, espejo de ADR-UMS-093 FR-060). +/// Resuelve la solicitud de aprobación aprobándola y activa la delegación. +/// El aprobador es el usuario autenticado. +/// +public sealed record ApproveDelegationCommand(Guid DelegationId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandHandler.cs new file mode 100644 index 00000000..534b9192 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandHandler.cs @@ -0,0 +1,58 @@ + +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +public sealed class ApproveDelegationCommandHandler : ICommandHandler +{ + private readonly IUserManagementDelegationRepository _repository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public ApproveDelegationCommandHandler( + IUserManagementDelegationRepository repository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(ApproveDelegationCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to approve a delegation."); + } + + var delegation = await _repository.GetByIdAsync(request.DelegationId, cancellationToken); + if (delegation is null) + { + return Result.Failure("Delegation not found."); + } + + // Autorización + aislamiento por inquilino (G-148/G-149): aprobar una delegación es una + // operación administrativa (concede autoridad sobre cuentas del inquilino), así que se + // exige el mismo gate de management-owner que Block/Create/ForcePasswordReset de UserAccount + // (ADR-UMS-086 endurecido). Cierra: solo management-owner/internal-admin aprueba, y nunca + // de forma cruzada entre inquilinos. Sin fallback de acceso delegado a propósito: un + // administrador delegado no puede aprobar delegaciones (evita escalada). + var ownerScope = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(delegation.TenantId.GetValue(), cancellationToken); + if (ownerScope.IsFailure) + { + return ownerScope; + } + + var result = delegation.Approve(ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(delegation, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandValidator.cs new file mode 100644 index 00000000..6c48b8bf --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandValidator.cs @@ -0,0 +1,12 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +using FluentValidation; + +public sealed class ApproveDelegationCommandValidator : AbstractValidator +{ + public ApproveDelegationCommandValidator() + { + RuleFor(command => command.DelegationId) + .NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommand.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommand.cs new file mode 100644 index 00000000..7be79e30 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +/// +/// PendingApproval → Rejected (delegación, ADR-UMS-086, espejo de ADR-UMS-093 FR-060). +/// Resuelve la solicitud de aprobación rechazándola con un motivo obligatorio. +/// El revisor es el usuario autenticado. +/// +public sealed record RejectDelegationCommand(Guid DelegationId, string Reason) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandHandler.cs new file mode 100644 index 00000000..bda991ea --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandHandler.cs @@ -0,0 +1,55 @@ + +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +public sealed class RejectDelegationCommandHandler : ICommandHandler +{ + private readonly IUserManagementDelegationRepository _repository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public RejectDelegationCommandHandler( + IUserManagementDelegationRepository repository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(RejectDelegationCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to reject a delegation."); + } + + var delegation = await _repository.GetByIdAsync(request.DelegationId, cancellationToken); + if (delegation is null) + { + return Result.Failure("Delegation not found."); + } + + // Autorización + aislamiento por inquilino (G-148/G-149): mismo gate de management-owner + // que Approve. Rechazar una solicitud pendiente es una decisión de gobernanza reservada al + // management-owner/internal-admin; no se admite de forma cruzada entre inquilinos. + var ownerScope = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(delegation.TenantId.GetValue(), cancellationToken); + if (ownerScope.IsFailure) + { + return ownerScope; + } + + var result = delegation.Reject(request.Reason, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(delegation, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandValidator.cs new file mode 100644 index 00000000..740c4035 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandValidator.cs @@ -0,0 +1,16 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +using FluentValidation; + +public sealed class RejectDelegationCommandValidator : AbstractValidator +{ + public RejectDelegationCommandValidator() + { + RuleFor(command => command.DelegationId) + .NotEmpty(); + + RuleFor(command => command.Reason) + .NotEmpty() + .MaximumLength(500); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommand.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommand.cs new file mode 100644 index 00000000..2a24be57 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommand.cs @@ -0,0 +1,9 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +/// +/// Draft → PendingApproval (delegación, ADR-UMS-086, espejo de ADR-UMS-093 FR-060). +/// Genera y congela el identificador de la solicitud de aprobación en el agregado +/// (SubmitForApproval). La delegación orquesta su propia máquina de aprobación +/// dentro de su agregado — un-agregado-por-transacción, sin ApprovalRequest genérico. +/// +public sealed record SubmitDelegationForApprovalCommand(Guid DelegationId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandHandler.cs new file mode 100644 index 00000000..610cc239 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandHandler.cs @@ -0,0 +1,62 @@ + +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +public sealed class SubmitDelegationForApprovalCommandHandler : ICommandHandler +{ + private readonly IUserManagementDelegationRepository _repository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public SubmitDelegationForApprovalCommandHandler( + IUserManagementDelegationRepository repository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(SubmitDelegationForApprovalCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to submit a delegation for approval."); + } + + var delegation = await _repository.GetByIdAsync(request.DelegationId, cancellationToken); + if (delegation is null) + { + return Result.Failure("Delegation not found."); + } + + // Autorización + aislamiento por inquilino (G-148/G-149): enviar a aprobación es una + // operación administrativa sobre la delegación; se exige el mismo gate de management-owner + // que el resto de comandos de gestión y se veda la operación cruzada entre inquilinos. + var ownerScope = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(delegation.TenantId.GetValue(), cancellationToken); + if (ownerScope.IsFailure) + { + return ownerScope; + } + + // Espejo de IGA RolePromotion (ADR-UMS-093): la delegación orquesta su propia máquina de + // aprobación dentro de su agregado, sin materializar un ApprovalRequest genérico (cuyo + // contrato Create exige workflow/sistema/rol ajenos a una delegación). El identificador de + // la solicitud es una correlación generada aquí y congelada en el agregado (INV: inmutable + // tras Submit). Esto preserva un-agregado-por-transacción (ADR-UMS-086). + var approvalRequestId = Guid.NewGuid(); + + var result = delegation.SubmitForApproval(approvalRequestId, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(delegation, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandValidator.cs new file mode 100644 index 00000000..547c44f3 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandValidator.cs @@ -0,0 +1,12 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +using FluentValidation; + +public sealed class SubmitDelegationForApprovalCommandValidator : AbstractValidator +{ + public SubmitDelegationForApprovalCommandValidator() + { + RuleFor(command => command.DelegationId) + .NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Ums.Application.csproj b/src/apps/ums.api/Ums.Application/Ums.Application.csproj index 6a1730e3..cefb6482 100644 --- a/src/apps/ums.api/Ums.Application/Ums.Application.csproj +++ b/src/apps/ums.api/Ums.Application/Ums.Application.csproj @@ -18,6 +18,15 @@ + + diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/ApprovalsConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/ApprovalsConsumerTests.cs new file mode 100644 index 00000000..c26f91b7 --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/ApprovalsConsumerTests.cs @@ -0,0 +1,207 @@ +using PactNet.Matchers; +using System.Net.Http.Json; + +namespace Ums.ContractTest.Consumers; + +/// +/// G-082: Consumer contract tests for the Approvals API (ApprovalRequest). +/// +/// Perspectiva de ums-web-app (consumer) → ums-api (provider). Sólo importa la FORMA +/// HTTP: lista paginada, obtención por id, no-encontrado (404) y el comando de creación con datos +/// inválidos (400 de validación). Rutas reales: /api/v1/approval-requests. +/// +public sealed class ApprovalsConsumerTests : IDisposable +{ + private readonly IPactBuilderV4 _pactBuilder; + + private static readonly string PactsDir = + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "pacts"); + + private const string SampleGuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + + public ApprovalsConsumerTests(ITestOutputHelper output) + { + var config = new PactConfig + { + PactDir = PactsDir, + Outputters = [new XunitOutput(output)], + LogLevel = PactLogLevel.Warn, + }; + + _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/approval-requests + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetApprovalRequests_ReturnsPagedList() + { + _pactBuilder + .UponReceiving("a paginated list request for approval requests") + .Given("at least one approval request exists") + .WithRequest(HttpMethod.Get, "/api/v1/approval-requests") + .WithQuery("page", Match.Equality("1")) + .WithQuery("pageSize", Match.Equality("10")) + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + items = Match.MinType(new + { + approvalRequestId = Match.Type(SampleGuid), + workflowId = Match.Type(SampleGuid), + targetUserId = Match.Type(SampleGuid), + status = Match.Type("Pending"), + requestedSystemId = Match.Type(SampleGuid), + requestedRoleId = Match.Type(SampleGuid), + }, 1), + totalItems = Match.Type(1), + page = Match.Type(1), + pageSize = Match.Type(10), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync("/api/v1/approval-requests?page=1&pageSize=10"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "response should have 'items'"); + Assert.True(json.TryGetProperty("totalItems", out _), "response should have 'totalItems'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/approval-requests/{id} — found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetApprovalRequestById_WhenFound_Returns200() + { + const string id = "a1a1a1a1-0000-0000-0000-000000000001"; + + _pactBuilder + .UponReceiving("a request for a specific approval request that exists") + .Given($"an approval request with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/approval-requests/{id}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + approvalRequestId = Match.Type(id), + workflowId = Match.Type(SampleGuid), + targetUserId = Match.Type(SampleGuid), + status = Match.Type("Pending"), + requestedSystemId = Match.Type(SampleGuid), + requestedRoleId = Match.Type(SampleGuid), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/approval-requests/{id}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("approvalRequestId", out _), "response should have 'approvalRequestId'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/approval-requests/{id} — not found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetApprovalRequestById_WhenNotFound_Returns404() + { + const string missingId = "00000000-0000-0000-0000-0000000000a1"; + + _pactBuilder + .UponReceiving("a request for an approval request that does not exist") + .Given($"no approval request with id {missingId} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/approval-requests/{missingId}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.NotFound) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(404), + title = Match.Type("Not Found"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/approval-requests/{missingId}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/approval-requests — invalid body → 400 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateApprovalRequest_WithInvalidData_Returns400() + { + const string emptyGuid = "00000000-0000-0000-0000-000000000000"; + + _pactBuilder + .UponReceiving("a create approval request with invalid data") + .WithRequest(HttpMethod.Post, "/api/v1/approval-requests") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + workflowId = emptyGuid, + targetUserId = emptyGuid, + requestedSystemId = emptyGuid, + requestedRoleId = emptyGuid, + }) + .WillRespond() + .WithStatus(HttpStatusCode.BadRequest) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(400), + title = Match.Type("Validation Error"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + using var content = JsonContent.Create(new + { + workflowId = emptyGuid, + targetUserId = emptyGuid, + requestedSystemId = emptyGuid, + requestedRoleId = emptyGuid, + }); + + var response = await client.PostAsync("/api/v1/approval-requests", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + }); + } + + public void Dispose() { } +} diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/AuditConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/AuditConsumerTests.cs new file mode 100644 index 00000000..2dac7b5b --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/AuditConsumerTests.cs @@ -0,0 +1,160 @@ +using PactNet.Matchers; +using System.Net.Http.Json; + +namespace Ums.ContractTest.Consumers; + +/// +/// G-082: Consumer contract tests for the Audit API (AuditRecord). +/// +/// La auditoría es sobre todo lectura y exige autenticación (cabecera X-User-Id) y +/// acotación por inquilino (parámetro tenantId). Rutas reales: /api/v1/audit-records. +/// +public sealed class AuditConsumerTests : IDisposable +{ + private readonly IPactBuilderV4 _pactBuilder; + + private static readonly string PactsDir = + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "pacts"); + + private const string SampleGuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + // Inquilino por defecto sembrado por el manejador de provider-states (RootTenantId del registro). + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + + public AuditConsumerTests(ITestOutputHelper output) + { + var config = new PactConfig + { + PactDir = PactsDir, + Outputters = [new XunitOutput(output)], + LogLevel = PactLogLevel.Warn, + }; + + _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/audit-records?tenantId=… + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetAuditRecords_ReturnsPagedList() + { + _pactBuilder + .UponReceiving("a paginated list request for audit records scoped by tenant") + .Given("at least one audit record exists") + .WithRequest(HttpMethod.Get, "/api/v1/audit-records") + .WithQuery("tenantId", Match.Equality(TenantId)) + .WithQuery("page", Match.Equality("1")) + .WithQuery("pageSize", Match.Equality("10")) + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + items = Match.MinType(new + { + auditRecordId = Match.Type(SampleGuid), + whoActed = Match.Type(SampleGuid), + eventType = Match.Type("ContractTestEvent"), + affectedEntityId = Match.Type(SampleGuid), + affectedEntityType = Match.Type("ContractTest"), + rootTenantId = Match.Type(SampleGuid), + }, 1), + totalItems = Match.Type(1), + page = Match.Type(1), + pageSize = Match.Type(10), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/audit-records?tenantId={TenantId}&page=1&pageSize=10"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "response should have 'items'"); + Assert.True(json.TryGetProperty("totalItems", out _), "response should have 'totalItems'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/audit-records/{id} — found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetAuditRecordById_WhenFound_Returns200() + { + const string id = "a2a2a2a2-0000-0000-0000-000000000001"; + + _pactBuilder + .UponReceiving("a request for a specific audit record that exists") + .Given($"an audit record with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/audit-records/{id}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + auditRecordId = Match.Type(id), + whoActed = Match.Type(SampleGuid), + eventType = Match.Type("ContractTestEvent"), + affectedEntityId = Match.Type(SampleGuid), + affectedEntityType = Match.Type("ContractTest"), + rootTenantId = Match.Type(SampleGuid), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/audit-records/{id}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("auditRecordId", out _), "response should have 'auditRecordId'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/audit-records/{id} — not found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetAuditRecordById_WhenNotFound_Returns404() + { + const string missingId = "00000000-0000-0000-0000-0000000000a2"; + + _pactBuilder + .UponReceiving("a request for an audit record that does not exist") + .Given($"no audit record with id {missingId} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/audit-records/{missingId}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.NotFound) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(404), + title = Match.Type("Not Found"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/audit-records/{missingId}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + }); + } + + public void Dispose() { } +} diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/AuthConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/AuthConsumerTests.cs index f1927652..cd0faa8e 100644 --- a/src/apps/ums.api/Ums.ContractTest/Consumers/AuthConsumerTests.cs +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/AuthConsumerTests.cs @@ -9,6 +9,13 @@ namespace Ums.ContractTest.Consumers; /// /// OPS-02: Consumer contract tests for the Auth API. +/// +/// El endpoint real de inicio de sesión es POST /api/v1/auth/login y recibe +/// { tenantCode, username, password } (ADR-0096 / AuthenticateUserCommand). Aquí sólo se +/// contrasta la FORMA HTTP de las rutas de rechazo, que no requieren sembrar un grafo de +/// autorización completo: la API responde 400 application/problem+json ante credenciales +/// inválidas o campos ausentes. El camino feliz (200 con el grafo + cookie de sesión) depende de +/// un grafo sembrado que excede el alcance de un contrato de forma y no se modela aquí. /// public sealed class AuthConsumerTests : IDisposable { @@ -29,31 +36,31 @@ public AuthConsumerTests(ITestOutputHelper output) _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); } + // ───────────────────────────────────────────────────────────── + // POST /api/v1/auth/login — campos obligatorios ausentes → 400 + // ───────────────────────────────────────────────────────────── + [Fact] [Trait("pact", "consumer")] - public async Task PostToken_WithValidCredentials_Returns200() + public async Task PostLogin_WithMissingFields_Returns400() { _pactBuilder - .UponReceiving("a valid token request") - .Given("a user account exists with valid credentials") - .WithRequest(HttpMethod.Post, "/api/v1/auth/token") - .WithHeader("Content-Type", Match.Regex("application/json.*", "application/json; charset=utf-8")) + .UponReceiving("a login request that is missing required fields") + .WithRequest(HttpMethod.Post, "/api/v1/auth/login") + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) .WithJsonBody(new { - email = Match.Type("user@example.com"), - password = Match.Type("ValidPassword123!"), + tenantCode = "", + username = "", + password = "", }) .WillRespond() - .WithStatus(HttpStatusCode.OK) - .WithHeader("Content-Type", Match.Regex("application/json.*", "application/json; charset=utf-8")) + .WithStatus(HttpStatusCode.BadRequest) + .WithHeader("Content-Type", Match.Type("application/problem+json")) .WithJsonBody(new { - token = Match.Type("eyJhbGciOiJIUzI1NiIsInR5cCI..."), - user = new - { - userId = Match.Type("3fa85f64-5717-4562-b3fc-2c963f66afa6"), - email = Match.Type("user@example.com"), - } + status = Match.Type(400), + title = Match.Type("Bad Request"), }); await _pactBuilder.VerifyAsync(async ctx => @@ -62,39 +69,42 @@ await _pactBuilder.VerifyAsync(async ctx => using var content = JsonContent.Create(new { - email = "user@example.com", - password = "ValidPassword123!", + tenantCode = "", + username = "", + password = "", }); - var response = await client.PostAsync("/api/v1/auth/token", content); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadFromJsonAsync(); - Assert.True(json.TryGetProperty("token", out _), "response should have 'token'"); + var response = await client.PostAsync("/api/v1/auth/login", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); }); } + // ───────────────────────────────────────────────────────────── + // POST /api/v1/auth/login — credenciales no autenticables → 400 + // ───────────────────────────────────────────────────────────── + [Fact] [Trait("pact", "consumer")] - public async Task PostToken_WithInvalidCredentials_Returns401() + public async Task PostLogin_WithUnauthenticableCredentials_Returns400() { _pactBuilder - .UponReceiving("an invalid token request") - .Given("a user account does not exist or credentials do not match") - .WithRequest(HttpMethod.Post, "/api/v1/auth/token") - .WithHeader("Content-Type", Match.Regex("application/json.*", "application/json; charset=utf-8")) + .UponReceiving("a login request with credentials that cannot be authenticated") + .Given("no tenant with code UNKNOWN_TENANT exists") + .WithRequest(HttpMethod.Post, "/api/v1/auth/login") + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) .WithJsonBody(new { - email = Match.Type("wrong@example.com"), - password = Match.Type("WrongPassword123!"), + tenantCode = "UNKNOWN_TENANT", + username = Match.Type("user@example.com"), + password = Match.Type("ValidPassword123!"), }) .WillRespond() - .WithStatus(HttpStatusCode.Unauthorized) + .WithStatus(HttpStatusCode.BadRequest) .WithHeader("Content-Type", Match.Type("application/problem+json")) .WithJsonBody(new { - status = Match.Type(401), - title = Match.Type("Unauthorized"), + status = Match.Type(400), + title = Match.Type("Bad Request"), }); await _pactBuilder.VerifyAsync(async ctx => @@ -103,12 +113,13 @@ await _pactBuilder.VerifyAsync(async ctx => using var content = JsonContent.Create(new { - email = "wrong@example.com", - password = "WrongPassword123!", + tenantCode = "UNKNOWN_TENANT", + username = "user@example.com", + password = "ValidPassword123!", }); - var response = await client.PostAsync("/api/v1/auth/token", content); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var response = await client.PostAsync("/api/v1/auth/login", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); }); } diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/ConfigurationConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/ConfigurationConsumerTests.cs new file mode 100644 index 00000000..dd1499a4 --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/ConfigurationConsumerTests.cs @@ -0,0 +1,349 @@ +using PactNet.Matchers; +using System.Net.Http.Json; + +namespace Ums.ContractTest.Consumers; + +/// +/// G-082: Consumer contract tests for the Configuration API (FeatureFlag + AppConfiguration). +/// +/// Cubre consulta (lista + por id), un error clave (404 / 400) y comandos de creación. +/// Rutas reales: /api/v1/feature-flags y /api/v1/app-configurations. +/// +public sealed class ConfigurationConsumerTests : IDisposable +{ + private readonly IPactBuilderV4 _pactBuilder; + + private static readonly string PactsDir = + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "pacts"); + + private const string SampleGuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + + public ConfigurationConsumerTests(ITestOutputHelper output) + { + var config = new PactConfig + { + PactDir = PactsDir, + Outputters = [new XunitOutput(output)], + LogLevel = PactLogLevel.Warn, + }; + + _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/feature-flags + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetFeatureFlags_ReturnsPagedList() + { + _pactBuilder + .UponReceiving("a paginated list request for feature flags") + .Given("at least one feature flag exists") + .WithRequest(HttpMethod.Get, "/api/v1/feature-flags") + .WithQuery("page", Match.Equality("1")) + .WithQuery("pageSize", Match.Equality("10")) + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + items = Match.MinType(new + { + featureFlagId = Match.Type(SampleGuid), + systemSuiteId = Match.Type(SampleGuid), + flagCode = Match.Type("CONTRACT_TEST_FLAG"), + flagType = Match.Type("Boolean"), + flagTargets = Match.Type("all"), + status = Match.Type("Inactive"), + }, 1), + totalItems = Match.Type(1), + page = Match.Type(1), + pageSize = Match.Type(10), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync("/api/v1/feature-flags?page=1&pageSize=10"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "response should have 'items'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/feature-flags/{id} — found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetFeatureFlagById_WhenFound_Returns200() + { + const string id = "a3a3a3a3-0000-0000-0000-000000000001"; + + _pactBuilder + .UponReceiving("a request for a specific feature flag that exists") + .Given($"a feature flag with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/feature-flags/{id}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + featureFlagId = Match.Type(id), + systemSuiteId = Match.Type(SampleGuid), + flagCode = Match.Type("CONTRACT_TEST_FLAG"), + flagType = Match.Type("Boolean"), + flagTargets = Match.Type("all"), + status = Match.Type("Inactive"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/feature-flags/{id}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("featureFlagId", out _), "response should have 'featureFlagId'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/feature-flags/{id} — not found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetFeatureFlagById_WhenNotFound_Returns404() + { + const string missingId = "00000000-0000-0000-0000-0000000000a3"; + + _pactBuilder + .UponReceiving("a request for a feature flag that does not exist") + .Given($"no feature flag with id {missingId} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/feature-flags/{missingId}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.NotFound) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(404), + title = Match.Type("Not Found"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/feature-flags/{missingId}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/feature-flags — valid → 201 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateFeatureFlag_WithValidData_Returns201() + { + const string suiteId = "cccc3333-0000-0000-0000-000000000003"; + + _pactBuilder + .UponReceiving("a create feature flag request with valid data") + .WithRequest(HttpMethod.Post, "/api/v1/feature-flags") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + systemSuiteId = Match.Type(suiteId), + flagCode = Match.Type("PACT_CREATE_FLAG"), + flagType = Match.Type("Boolean"), + flagTargets = Match.Type("all"), + }) + .WillRespond() + .WithStatus(HttpStatusCode.Created) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + featureFlagId = Match.Regex(SampleGuid, "[0-9a-fA-F-]{36}"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + using var content = JsonContent.Create(new + { + systemSuiteId = suiteId, + flagCode = "PACT_CREATE_FLAG", + flagType = "Boolean", + flagTargets = "all", + }); + + var response = await client.PostAsync("/api/v1/feature-flags", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/feature-flags — unsupported flag type → 400 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateFeatureFlag_WithUnsupportedType_Returns400() + { + const string suiteId = "cccc3333-0000-0000-0000-000000000003"; + + _pactBuilder + .UponReceiving("a create feature flag request with an unsupported flag type") + .WithRequest(HttpMethod.Post, "/api/v1/feature-flags") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + systemSuiteId = Match.Type(suiteId), + flagCode = Match.Type("PACT_BAD_FLAG"), + flagType = "NotAFlagType", + flagTargets = Match.Type("all"), + }) + .WillRespond() + .WithStatus(HttpStatusCode.BadRequest) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(400), + title = Match.Type("Validation Error"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + using var content = JsonContent.Create(new + { + systemSuiteId = suiteId, + flagCode = "PACT_BAD_FLAG", + flagType = "NotAFlagType", + flagTargets = "all", + }); + + var response = await client.PostAsync("/api/v1/feature-flags", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/app-configurations + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetAppConfigurations_ReturnsPagedList() + { + _pactBuilder + .UponReceiving("a paginated list request for app configurations") + .Given("at least one app configuration exists") + .WithRequest(HttpMethod.Get, "/api/v1/app-configurations") + .WithQuery("page", Match.Equality("1")) + .WithQuery("pageSize", Match.Equality("10")) + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + items = Match.MinType(new + { + appConfigurationId = Match.Type(SampleGuid), + code = Match.Type("CONTRACT_TEST_CFG"), + value = Match.Type("contract-test-value"), + description = Match.Type("Contract test configuration."), + scope = Match.Type("Global"), + version = Match.Type("1.0.0"), + status = Match.Type("Draft"), + }, 1), + totalItems = Match.Type(1), + page = Match.Type(1), + pageSize = Match.Type(10), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync("/api/v1/app-configurations?page=1&pageSize=10"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "response should have 'items'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/app-configurations — valid global config → 201 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateAppConfiguration_WithValidData_Returns201() + { + _pactBuilder + .UponReceiving("a create app configuration request with valid global data") + .WithRequest(HttpMethod.Post, "/api/v1/app-configurations") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + code = Match.Type("PACT_CREATE_CFG"), + value = Match.Type("pact-value"), + description = Match.Type("Pact created configuration."), + isInheritable = Match.Type(true), + isEncrypted = Match.Type(false), + }) + .WillRespond() + .WithStatus(HttpStatusCode.Created) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + appConfigurationId = Match.Regex(SampleGuid, "[0-9a-fA-F-]{36}"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + using var content = JsonContent.Create(new + { + code = "PACT_CREATE_CFG", + value = "pact-value", + description = "Pact created configuration.", + isInheritable = true, + isEncrypted = false, + }); + + var response = await client.PostAsync("/api/v1/app-configurations", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + }); + } + + public void Dispose() { } +} diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/IgaConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/IgaConsumerTests.cs new file mode 100644 index 00000000..0dfc11c6 --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/IgaConsumerTests.cs @@ -0,0 +1,251 @@ +using PactNet.Matchers; +using System.Net.Http.Json; + +namespace Ums.ContractTest.Consumers; + +/// +/// G-082: Consumer contract tests for the IGA API (RolePromotionRequest, ADR-UMS-093). +/// +/// El grupo exige autenticación; el actor (cabecera X-User-Id) debe ser un GUID válido y no +/// puede coincidir con el usuario objetivo (segregación de funciones, INV-RPR3). Rutas reales: +/// /api/v1/role-promotion-requests. Se contrasta creación (201), violación de SoD (400), +/// lista (200), obtención por id (200) y no-encontrado por id (404). G-100: el manejador devuelve el +/// error de dominio de «no encontrado» con un código estable e idioma-agnóstico, así que el mapeador +/// lo clasifica como 404 (antes lo colapsaba a 400 por buscar sólo el substring en inglés «not found»). +/// +public sealed class IgaConsumerTests : IDisposable +{ + private readonly IPactBuilderV4 _pactBuilder; + + private static readonly string PactsDir = + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "pacts"); + + private const string SampleGuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + private const string RequesterGuid = "0000000a-0000-0000-0000-00000000000a"; + private const string TargetGuid = "0000000b-0000-0000-0000-00000000000b"; + private const string CurrentRole = "0000000c-0000-0000-0000-00000000000c"; + private const string TargetRole = "0000000d-0000-0000-0000-00000000000d"; + + public IgaConsumerTests(ITestOutputHelper output) + { + var config = new PactConfig + { + PactDir = PactsDir, + Outputters = [new XunitOutput(output)], + LogLevel = PactLogLevel.Warn, + }; + + _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/role-promotion-requests — valid → 201 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateRolePromotionRequest_WithValidData_Returns201() + { + _pactBuilder + .UponReceiving("a create role promotion request with valid data") + .WithRequest(HttpMethod.Post, "/api/v1/role-promotion-requests") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + tenantId = Match.Type(SampleGuid), + targetUserId = Match.Type(TargetGuid), + currentRoleId = Match.Type(CurrentRole), + targetRoleId = Match.Type(TargetRole), + }) + .WillRespond() + .WithStatus(HttpStatusCode.Created) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + rolePromotionRequestId = Match.Regex(SampleGuid, "[0-9a-fA-F-]{36}"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + using var content = JsonContent.Create(new + { + tenantId = SampleGuid, + targetUserId = TargetGuid, + currentRoleId = CurrentRole, + targetRoleId = TargetRole, + }); + + var response = await client.PostAsync("/api/v1/role-promotion-requests", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/role-promotion-requests — SoD violation → 400 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateRolePromotionRequest_ViolatingSegregation_Returns400() + { + _pactBuilder + .UponReceiving("a create role promotion request that violates segregation of duties") + .WithRequest(HttpMethod.Post, "/api/v1/role-promotion-requests") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + tenantId = Match.Type(SampleGuid), + targetUserId = RequesterGuid, // el objetivo coincide con el solicitante → SoD + currentRoleId = Match.Type(CurrentRole), + targetRoleId = Match.Type(TargetRole), + }) + .WillRespond() + .WithStatus(HttpStatusCode.BadRequest) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(400), + title = Match.Type("Bad Request"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + using var content = JsonContent.Create(new + { + tenantId = SampleGuid, + targetUserId = RequesterGuid, + currentRoleId = CurrentRole, + targetRoleId = TargetRole, + }); + + var response = await client.PostAsync("/api/v1/role-promotion-requests", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/role-promotion-requests + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task ListRolePromotionRequests_Returns200() + { + _pactBuilder + .UponReceiving("a list request for role promotion requests") + .Given("at least one role promotion request exists") + .WithRequest(HttpMethod.Get, "/api/v1/role-promotion-requests") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(Match.MinType(new + { + id = Match.Type(SampleGuid), + tenantId = Match.Type(SampleGuid), + targetUserId = Match.Type(SampleGuid), + requesterId = Match.Type(SampleGuid), + currentRoleId = Match.Type(SampleGuid), + targetRoleId = Match.Type(SampleGuid), + status = Match.Type("Draft"), + }, 1)); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + var response = await client.GetAsync("/api/v1/role-promotion-requests"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(JsonValueKind.Array, json.ValueKind); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/role-promotion-requests/{id} — found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetRolePromotionRequestById_WhenFound_Returns200() + { + const string id = "a4a4a4a4-0000-0000-0000-000000000001"; + + _pactBuilder + .UponReceiving("a request for a specific role promotion request that exists") + .Given($"a role promotion request with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/role-promotion-requests/{id}") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + id = Match.Type(id), + tenantId = Match.Type(SampleGuid), + targetUserId = Match.Type(SampleGuid), + requesterId = Match.Type(SampleGuid), + currentRoleId = Match.Type(SampleGuid), + targetRoleId = Match.Type(SampleGuid), + status = Match.Type("Draft"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + var response = await client.GetAsync($"/api/v1/role-promotion-requests/{id}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("id", out _), "response should have 'id'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/role-promotion-requests/{id} — not found → 404 (G-100) + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetRolePromotionRequestById_WhenNotFound_Returns404() + { + const string id = "a4a4a4a4-0000-0000-0000-0000000000ff"; + + _pactBuilder + .UponReceiving("a request for a role promotion request that does not exist") + .Given($"no role promotion request with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/role-promotion-requests/{id}") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WillRespond() + .WithStatus(HttpStatusCode.NotFound) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(404), + title = Match.Type("Not Found"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + var response = await client.GetAsync($"/api/v1/role-promotion-requests/{id}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + }); + } + + public void Dispose() { } +} diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/TenantsConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/TenantsConsumerTests.cs index 0a37cdd5..0fcc4f35 100644 --- a/src/apps/ums.api/Ums.ContractTest/Consumers/TenantsConsumerTests.cs +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/TenantsConsumerTests.cs @@ -163,14 +163,13 @@ public async Task CreateTenant_WithValidBody_Returns201() .UponReceiving("a create tenant request with valid data") .WithRequest(HttpMethod.Post, "/api/v1/tenants") .WithHeader("X-User-Id", Match.Type("dev-user")) - .WithHeader("Content-Type", Match.Regex("application/json.*", "application/json; charset=utf-8")) - .WithHeader("Idempotency-Key", Match.Regex("[0-9a-fA-F-]{36}", "a1b2c3d4-e5f6-7890-abcd-ef1234567890")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithHeader("Idempotency-Key", Match.Regex("a1b2c3d4-e5f6-7890-abcd-ef1234567890", "[0-9a-fA-F-]{36}")) .WithJsonBody(new { - code = Match.Type("NEWCO"), - name = Match.Type("New Company"), - organizationTypeId = Match.Type(1), - idpStrategyId = Match.Type(1), + code = Match.Type("NEWCO"), + name = Match.Type("New Company"), + type = Match.Regex("INTERNAL", "INTERNAL|SUPPLIER|CLIENT"), }) .WillRespond() .WithStatus(HttpStatusCode.Created) @@ -178,8 +177,8 @@ public async Task CreateTenant_WithValidBody_Returns201() .WithJsonBody(new { tenantId = Match.Regex( - "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", - "3fa85f64-5717-4562-b3fc-2c963f66afa6"), + "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"), }); await _pactBuilder.VerifyAsync(async ctx => @@ -191,10 +190,9 @@ await _pactBuilder.VerifyAsync(async ctx => using var content = JsonContent.Create(new { - code = "NEWCO", - name = "New Company", - organizationTypeId = 1, - idpStrategyId = 1, + code = "NEWCO", + name = "New Company", + type = "INTERNAL", }); var response = await client.PostAsync("/api/v1/tenants", content); diff --git a/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs index 68d83cff..533fbc2e 100644 --- a/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs +++ b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs @@ -25,7 +25,7 @@ namespace Ums.ContractTest.Infrastructure; /// Starts the UMS API with InMemory stores so PactNet consumer tests can verify /// contracts against a live server. /// -public sealed class ContractTestWebApplicationFactory : WebApplicationFactory +public class ContractTestWebApplicationFactory : WebApplicationFactory { static ContractTestWebApplicationFactory() { @@ -34,11 +34,32 @@ static ContractTestWebApplicationFactory() Environment.SetEnvironmentVariable("Persistence__SeedDevData", "true"); Environment.SetEnvironmentVariable("Persistence__EnableOutbox", "false"); Environment.SetEnvironmentVariable("Persistence__InitializePlatformStoreOnStartup","false"); + // Un host levantado en «Production» exige un secreto de firma REAL: desde G-203 el arranque + // rechaza los marcadores de posición del repositorio, y sin esto la factoría productiva + // heredaba el de `appsettings.json` y se negaba a arrancar. + // + // Va como VARIABLE DE ENTORNO y no en el diccionario de `ConfigureAppConfiguration`: ese + // diccionario lo pisa `appsettings.json`, que la construcción del host añade después. + Environment.SetEnvironmentVariable("Jwt__Secret", SecretoDeFirmaDePrueba); } + /// + /// Entorno del host bajo prueba. Por defecto «Development» —el entorno que el + /// proveedor de Pact necesita para que se registre `/_pact/provider-states` (G-101)—. + /// Las pruebas que verifican el blindaje productivo lo sobrescriben con «Production». + /// + protected virtual string EnvironmentName => "Development"; + + /// + /// Secreto de firma para la corrida. Se genera al azar en cada arranque: fijarlo aquí lo + /// convertiría en una cadena versionada, que es el defecto que G-203 corrige. + /// + private static readonly string SecretoDeFirmaDePrueba = + Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(48)); + protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.UseEnvironment("Development"); + builder.UseEnvironment(EnvironmentName); builder.ConfigureAppConfiguration((_, cfg) => { diff --git a/src/apps/ums.api/Ums.ContractTest/Infrastructure/ProductionContractTestWebApplicationFactory.cs b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ProductionContractTestWebApplicationFactory.cs new file mode 100644 index 00000000..1ded1c1d --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ProductionContractTestWebApplicationFactory.cs @@ -0,0 +1,15 @@ +namespace Ums.ContractTest.Infrastructure; + +/// +/// G-101: variante de la factoría que arranca el host en entorno «Production», +/// reutilizando la misma sustitución de repositorios InMemory de la base. +/// +/// Sirve para verificar el blindaje del endpoint de estados de proveedor de Pact: +/// en producción, `POST /_pact/provider-states` NO debe estar registrado (404), +/// mientras que en desarrollo (factoría base) sí lo está para que el proveedor +/// de Pact pueda sembrar estados. +/// +public sealed class ProductionContractTestWebApplicationFactory : ContractTestWebApplicationFactory +{ + protected override string EnvironmentName => "Production"; +} diff --git a/src/apps/ums.api/Ums.ContractTest/Provider/ProviderStateEndpointGuardTests.cs b/src/apps/ums.api/Ums.ContractTest/Provider/ProviderStateEndpointGuardTests.cs new file mode 100644 index 00000000..106271d0 --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Provider/ProviderStateEndpointGuardTests.cs @@ -0,0 +1,63 @@ +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Ums.ContractTest.Provider; + +/// +/// G-101: verifica la guarda de entorno de `POST /_pact/provider-states`. +/// +/// El endpoint siembra datos de dominio arbitrarios de forma anónima, por lo que +/// sólo debe registrarse en entornos de desarrollo/contract-test. Estas pruebas +/// confirman el contraste: +/// - En «Development» (factoría base) el endpoint responde 200 OK. +/// - En «Production» el endpoint NO está registrado → 404 Not Found. +/// +/// Comparte la colección «Provider» con para +/// serializar el arranque de hosts: con hosting mínimo (WebApplication) la +/// resolución de entorno de WebApplicationFactory se contamina entre hosts que se +/// construyen en paralelo, de modo que un host «Production» concurrente haría que +/// el proveedor viese el endpoint como no registrado (404). En secuencia, cada host +/// resuelve su entorno correctamente. +/// +[Collection("Provider")] +public sealed class ProviderStateEndpointGuardTests +{ + private static readonly object ProviderStateBody = new { state = "at least one tenant exists" }; + + [Fact] + [Trait("pact", "guard")] + public async Task ProviderStates_InProduction_IsNotRegistered() + { + using var factory = new ProductionContractTestWebApplicationFactory(); + + // Base https para no toparse con UseHttpsRedirection (activo fuera de desarrollo): + // así la petición llega al enrutado, que devuelve 404 al no existir el endpoint. + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost"), + }); + + using var response = await client.PostAsJsonAsync( + "/_pact/provider-states", ProviderStateBody, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + [Trait("pact", "guard")] + public async Task ProviderStates_InDevelopment_IsRegistered() + { + using var factory = new ContractTestWebApplicationFactory(); + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + + using var response = await client.PostAsJsonAsync( + "/_pact/provider-states", ProviderStateBody, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/src/apps/ums.api/Ums.ContractTest/pacts/ums-web-app-ums-api.json b/src/apps/ums.api/Ums.ContractTest/pacts/ums-web-app-ums-api.json index 3976fd51..181bc59c 100644 --- a/src/apps/ums.api/Ums.ContractTest/pacts/ums-web-app-ums-api.json +++ b/src/apps/ums.api/Ums.ContractTest/pacts/ums-web-app-ums-api.json @@ -4,25 +4,23 @@ }, "interactions": [ { - "description": "a create tenant request with valid data", + "description": "a create app configuration request with valid global data", "pending": false, "request": { "body": { "content": { - "code": "NEWCO", - "idpStrategyId": 1, - "name": "New Company", - "organizationTypeId": 1 + "code": "PACT_CREATE_CFG", + "description": "Pact created configuration.", + "isEncrypted": false, + "isInheritable": true, + "value": "pact-value" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" - ], - "Idempotency-Key": [ - "[0-9a-fA-F-]{36}" + "application/json; charset=utf-8" ], "X-User-Id": [ "dev-user" @@ -38,7 +36,7 @@ } ] }, - "$.idpStrategyId": { + "$.description": { "combine": "AND", "matchers": [ { @@ -46,7 +44,7 @@ } ] }, - "$.name": { + "$.isEncrypted": { "combine": "AND", "matchers": [ { @@ -54,31 +52,30 @@ } ] }, - "$.organizationTypeId": { + "$.isInheritable": { "combine": "AND", "matchers": [ { "match": "type" } ] - } - }, - "header": { - "Content-Type": { + }, + "$.value": { "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] - }, - "Idempotency-Key": { + } + }, + "header": { + "Content-Type": { "combine": "AND", "matchers": [ { "match": "regex", - "regex": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + "regex": "application/json.*" } ] }, @@ -93,29 +90,29 @@ } }, "method": "POST", - "path": "/api/v1/tenants" + "path": "/api/v1/app-configurations" }, "response": { "body": { "content": { - "tenantId": "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + "appConfigurationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/json; charset=utf-8" ] }, "matchingRules": { "body": { - "$.tenantId": { + "$.appConfigurationId": { "combine": "AND", "matchers": [ { "match": "regex", - "regex": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + "regex": "[0-9a-fA-F-]{36}" } ] } @@ -125,8 +122,7 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } @@ -137,21 +133,38 @@ "type": "Synchronous/HTTP" }, { - "description": "a deactivate request for a profile that does not exist", + "description": "a create approval request with invalid data", "pending": false, - "providerStates": [ - { - "name": "no profile with id 00000000-0000-0000-0000-000000000001 exists" - } - ], "request": { + "body": { + "content": { + "requestedRoleId": "00000000-0000-0000-0000-000000000000", + "requestedSystemId": "00000000-0000-0000-0000-000000000000", + "targetUserId": "00000000-0000-0000-0000-000000000000", + "workflowId": "00000000-0000-0000-0000-000000000000" + }, + "contentType": "application/json", + "encoded": false + }, "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], "X-User-Id": [ "dev-user" ] }, "matchingRules": { "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, "X-User-Id": { "combine": "AND", "matchers": [ @@ -163,13 +176,13 @@ } }, "method": "POST", - "path": "/api/v1/profiles/00000000-0000-0000-0000-000000000001/deactivate" + "path": "/api/v1/approval-requests" }, "response": { "body": { "content": { - "status": 404, - "title": "Not Found" + "status": 400, + "title": "Validation Error" }, "contentType": "application/json", "encoded": false @@ -209,27 +222,51 @@ } } }, - "status": 404 + "status": 400 }, "type": "Synchronous/HTTP" }, { - "description": "a deactivate request for an active profile", + "description": "a create feature flag request with an unsupported flag type", "pending": false, - "providerStates": [ - { - "name": "a profile with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 exists" - } - ], "request": { + "body": { + "content": { + "flagCode": "PACT_BAD_FLAG", + "flagTargets": "all", + "flagType": "NotAFlagType", + "systemSuiteId": "cccc3333-0000-0000-0000-000000000003" + }, + "contentType": "application/json", + "encoded": false + }, "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], "X-User-Id": [ "dev-user" ] }, "matchingRules": { - "header": { - "X-User-Id": { + "body": { + "$.flagCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.flagTargets": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.systemSuiteId": { "combine": "AND", "matchers": [ { @@ -237,32 +274,17 @@ } ] } - } - }, - "method": "POST", - "path": "/api/v1/profiles/3fa85f64-5717-4562-b3fc-2c963f66afa6/deactivate" - }, - "response": { - "status": 204 - }, - "type": "Synchronous/HTTP" - }, - { - "description": "a delete request for a user account that does not exist", - "pending": false, - "providerStates": [ - { - "name": "no user account with id 00000000-0000-0000-0000-000000000001 exists" - } - ], - "request": { - "headers": { - "X-User-Id": [ - "dev-user" - ] - }, - "matchingRules": { + }, "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, "X-User-Id": { "combine": "AND", "matchers": [ @@ -273,14 +295,14 @@ } } }, - "method": "DELETE", - "path": "/api/v1/user-accounts/00000000-0000-0000-0000-000000000001" + "method": "POST", + "path": "/api/v1/feature-flags" }, "response": { "body": { "content": { - "status": 404, - "title": "Not Found" + "status": 400, + "title": "Validation Error" }, "contentType": "application/json", "encoded": false @@ -320,61 +342,59 @@ } } }, - "status": 404 + "status": 400 }, "type": "Synchronous/HTTP" }, { - "description": "a delete request for an inactive user account", + "description": "a create feature flag request with valid data", "pending": false, - "providerStates": [ - { - "name": "a user account with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 is inactive" - } - ], "request": { + "body": { + "content": { + "flagCode": "PACT_CREATE_FLAG", + "flagTargets": "all", + "flagType": "Boolean", + "systemSuiteId": "cccc3333-0000-0000-0000-000000000003" + }, + "contentType": "application/json", + "encoded": false + }, "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], "X-User-Id": [ "dev-user" ] }, "matchingRules": { - "header": { - "X-User-Id": { + "body": { + "$.flagCode": { "combine": "AND", "matchers": [ { "match": "type" } ] - } - } - }, - "method": "DELETE", - "path": "/api/v1/user-accounts/3fa85f64-5717-4562-b3fc-2c963f66afa6" - }, - "response": { - "status": 204 - }, - "type": "Synchronous/HTTP" - }, - { - "description": "a paginated list request for permission templates", - "pending": false, - "providerStates": [ - { - "name": "at least one permission template exists" - } - ], - "request": { - "headers": { - "X-User-Id": [ - "dev-user" - ] - }, - "matchingRules": { - "header": { - "X-User-Id": { + }, + "$.flagTargets": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.flagType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.systemSuiteId": { "combine": "AND", "matchers": [ { @@ -383,107 +403,2140 @@ ] } }, - "query": { - "page": { + "header": { + "Content-Type": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "regex", + "regex": "application/json.*" } ] }, - "pageSize": { + "X-User-Id": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" } ] } } }, - "method": "GET", - "path": "/api/v1/permission-templates", - "query": { - "page": [ - "1" - ], - "pageSize": [ - "10" - ] - } + "method": "POST", + "path": "/api/v1/feature-flags" }, "response": { "body": { "content": { - "items": [ - { - "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", - "roleName": "Administrator", - "status": "Published", - "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "systemSuiteName": "User Management System", - "templateId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "tenantId": "11111111-1111-1111-1111-111111111111", - "version": "1.0.0" - } - ], - "page": 1, - "pageSize": 10, - "totalItems": 1 + "featureFlagId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/json; charset=utf-8" ] }, "matchingRules": { "body": { - "$.items": { + "$.featureFlagId": { "combine": "AND", "matchers": [ { - "match": "type", - "min": 1 - } - ] - }, - "$.items[*].roleId": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.items[*].roleName": { - "combine": "AND", - "matchers": [ - { - "match": "type" + "match": "regex", + "regex": "[0-9a-fA-F-]{36}" } ] - }, - "$.items[*].status": { + } + }, + "header": { + "Content-Type": { "combine": "AND", "matchers": [ { "match": "type" } ] - }, - "$.items[*].systemSuiteId": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } + } + } + }, + "status": 201 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a create role promotion request that violates segregation of duties", + "pending": false, + "request": { + "body": { + "content": { + "currentRoleId": "0000000c-0000-0000-0000-00000000000c", + "targetRoleId": "0000000d-0000-0000-0000-00000000000d", + "targetUserId": "0000000a-0000-0000-0000-00000000000a", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], + "X-User-Id": [ + "0000000a-0000-0000-0000-00000000000a" + ] + }, + "matchingRules": { + "body": { + "$.currentRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.targetRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/role-promotion-requests" + }, + "response": { + "body": { + "content": { + "status": 400, + "title": "Bad Request" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 400 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a create role promotion request with valid data", + "pending": false, + "request": { + "body": { + "content": { + "currentRoleId": "0000000c-0000-0000-0000-00000000000c", + "targetRoleId": "0000000d-0000-0000-0000-00000000000d", + "targetUserId": "0000000b-0000-0000-0000-00000000000b", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], + "X-User-Id": [ + "0000000a-0000-0000-0000-00000000000a" + ] + }, + "matchingRules": { + "body": { + "$.currentRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.targetRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.targetUserId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/role-promotion-requests" + }, + "response": { + "body": { + "content": { + "rolePromotionRequestId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.rolePromotionRequestId": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "[0-9a-fA-F-]{36}" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 201 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a create tenant request with valid data", + "pending": false, + "request": { + "body": { + "content": { + "code": "NEWCO", + "name": "New Company", + "type": "INTERNAL" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], + "Idempotency-Key": [ + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ], + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "body": { + "$.code": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "INTERNAL|SUPPLIER|CLIENT" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, + "Idempotency-Key": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "[0-9a-fA-F-]{36}" + } + ] + }, + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/tenants" + }, + "response": { + "body": { + "content": { + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json; charset=utf-8" + } + ] + } + } + }, + "status": 201 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a deactivate request for a profile that does not exist", + "pending": false, + "providerStates": [ + { + "name": "no profile with id 00000000-0000-0000-0000-000000000001 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/profiles/00000000-0000-0000-0000-000000000001/deactivate" + }, + "response": { + "body": { + "content": { + "status": 404, + "title": "Not Found" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 404 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a deactivate request for an active profile", + "pending": false, + "providerStates": [ + { + "name": "a profile with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/profiles/3fa85f64-5717-4562-b3fc-2c963f66afa6/deactivate" + }, + "response": { + "status": 204 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a delete request for a user account that does not exist", + "pending": false, + "providerStates": [ + { + "name": "no user account with id 00000000-0000-0000-0000-000000000001 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "DELETE", + "path": "/api/v1/user-accounts/00000000-0000-0000-0000-000000000001" + }, + "response": { + "body": { + "content": { + "status": 404, + "title": "Not Found" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 404 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a delete request for an inactive user account", + "pending": false, + "providerStates": [ + { + "name": "a user account with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 is inactive" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "DELETE", + "path": "/api/v1/user-accounts/3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "response": { + "status": 204 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a list request for role promotion requests", + "pending": false, + "providerStates": [ + { + "name": "at least one role promotion request exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "0000000a-0000-0000-0000-00000000000a" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/role-promotion-requests" + }, + "response": { + "body": { + "content": [ + { + "currentRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "requesterId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "Draft", + "targetRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "targetUserId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$[*].currentRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].requesterId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].targetRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].targetUserId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a login request that is missing required fields", + "pending": false, + "request": { + "body": { + "content": { + "password": "", + "tenantCode": "", + "username": "" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/auth/login" + }, + "response": { + "body": { + "content": { + "status": 400, + "title": "Bad Request" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 400 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a login request with credentials that cannot be authenticated", + "pending": false, + "providerStates": [ + { + "name": "no tenant with code UNKNOWN_TENANT exists" + } + ], + "request": { + "body": { + "content": { + "password": "ValidPassword123!", + "tenantCode": "UNKNOWN_TENANT", + "username": "user@example.com" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.password": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.username": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/auth/login" + }, + "response": { + "body": { + "content": { + "status": 400, + "title": "Bad Request" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 400 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for app configurations", + "pending": false, + "providerStates": [ + { + "name": "at least one app configuration exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/app-configurations", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "appConfigurationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "code": "CONTRACT_TEST_CFG", + "description": "Contract test configuration.", + "scope": "Global", + "status": "Draft", + "value": "contract-test-value", + "version": "1.0.0" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].appConfigurationId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].code": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].scope": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].value": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].version": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for approval requests", + "pending": false, + "providerStates": [ + { + "name": "at least one approval request exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/approval-requests", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "approvalRequestId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "requestedRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "requestedSystemId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "Pending", + "targetUserId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "workflowId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].approvalRequestId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].requestedRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].requestedSystemId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].targetUserId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].workflowId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for audit records scoped by tenant", + "pending": false, + "providerStates": [ + { + "name": "at least one audit record exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/audit-records", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ], + "tenantId": [ + "11111111-1111-1111-1111-111111111111" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "affectedEntityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "affectedEntityType": "ContractTest", + "auditRecordId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "eventType": "ContractTestEvent", + "rootTenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "whoActed": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].affectedEntityId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].affectedEntityType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].auditRecordId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].eventType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].rootTenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].whoActed": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for feature flags", + "pending": false, + "providerStates": [ + { + "name": "at least one feature flag exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/feature-flags", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "featureFlagId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "flagCode": "CONTRACT_TEST_FLAG", + "flagTargets": "all", + "flagType": "Boolean", + "status": "Inactive", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].featureFlagId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].flagCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].flagTargets": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].flagType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for permission templates", + "pending": false, + "providerStates": [ + { + "name": "at least one permission template exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/permission-templates", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", + "roleName": "Administrator", + "status": "Published", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "systemSuiteName": "User Management System", + "templateId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "11111111-1111-1111-1111-111111111111", + "version": "1.0.0" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].roleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].roleName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].templateId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].version": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json; charset=utf-8" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for profiles", + "pending": false, + "providerStates": [ + { + "name": "at least one profile exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/profiles", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "isActive": true, + "permissionCount": 0, + "permissions": [], + "profileId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "roleCode": "ADMIN", + "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", + "roleName": "Administrator", + "scope": "OrgWide", + "systemSuiteCode": "UMS-CORE", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "systemSuiteName": "User Management System", + "tenantCode": "ACME", + "tenantId": "11111111-1111-1111-1111-111111111111", + "tenantName": "Acme Corp", + "userEmail": "user@example.com", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].isActive": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].permissionCount": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].profileId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].roleCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].roleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].roleName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].scope": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].tenantCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].tenantName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].userEmail": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].userId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } ] }, - "$.items[*].systemSuiteName": { + "$.pageSize": { "combine": "AND", "matchers": [ { @@ -491,15 +2544,121 @@ } ] }, - "$.items[*].templateId": { + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json; charset=utf-8" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for system suites", + "pending": false, + "providerStates": [ + { + "name": "at least one system suite exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { "combine": "AND", "matchers": [ { "match": "type" } ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] }, - "$.items[*].tenantId": { + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/system-suites", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "code": "UMS-CORE", + "name": "User Management System", + "status": "Active", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].code": { "combine": "AND", "matchers": [ { @@ -507,7 +2666,23 @@ } ] }, - "$.items[*].version": { + "$.items[*].name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteId": { "combine": "AND", "matchers": [ { @@ -557,11 +2732,11 @@ "type": "Synchronous/HTTP" }, { - "description": "a paginated list request for profiles", + "description": "a paginated list request for tenants", "pending": false, "providerStates": [ { - "name": "at least one profile exists" + "name": "at least one tenant exists" } ], "request": { @@ -601,7 +2776,7 @@ } }, "method": "GET", - "path": "/api/v1/profiles", + "path": "/api/v1/tenants", "query": { "page": [ "1" @@ -616,22 +2791,9 @@ "content": { "items": [ { - "isActive": true, - "permissionCount": 0, - "permissions": [], - "profileId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "roleCode": "ADMIN", - "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", - "roleName": "Administrator", - "scope": "OrgWide", - "systemSuiteCode": "UMS-CORE", - "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "systemSuiteName": "User Management System", - "tenantCode": "ACME", - "tenantId": "11111111-1111-1111-1111-111111111111", - "tenantName": "Acme Corp", - "userEmail": "user@example.com", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + "code": "ACME", + "name": "Acme Corp", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } ], "page": 1, @@ -657,15 +2819,7 @@ } ] }, - "$.items[*].isActive": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.items[*].permissionCount": { + "$.items[*].code": { "combine": "AND", "matchers": [ { @@ -673,7 +2827,7 @@ } ] }, - "$.items[*].profileId": { + "$.items[*].name": { "combine": "AND", "matchers": [ { @@ -681,7 +2835,7 @@ } ] }, - "$.items[*].roleCode": { + "$.items[*].tenantId": { "combine": "AND", "matchers": [ { @@ -689,7 +2843,7 @@ } ] }, - "$.items[*].roleId": { + "$.page": { "combine": "AND", "matchers": [ { @@ -697,7 +2851,7 @@ } ] }, - "$.items[*].roleName": { + "$.pageSize": { "combine": "AND", "matchers": [ { @@ -705,55 +2859,120 @@ } ] }, - "$.items[*].scope": { + "$.totalItems": { "combine": "AND", "matchers": [ { "match": "type" } ] - }, - "$.items[*].systemSuiteCode": { + } + }, + "header": { + "Content-Type": { "combine": "AND", "matchers": [ { - "match": "type" + "match": "regex", + "regex": "application/json; charset=utf-8" } ] - }, - "$.items[*].systemSuiteId": { + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for user accounts", + "pending": false, + "providerStates": [ + { + "name": "at least one user account exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { "combine": "AND", "matchers": [ { "match": "type" } ] - }, - "$.items[*].systemSuiteName": { + } + }, + "query": { + "page": { "combine": "AND", "matchers": [ { - "match": "type" + "match": "equality" } ] }, - "$.items[*].tenantCode": { + "pageSize": { "combine": "AND", "matchers": [ { - "match": "type" + "match": "equality" } ] - }, - "$.items[*].tenantId": { + } + } + }, + "method": "GET", + "path": "/api/v1/user-accounts", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "email": "user@example.com", + "status": "Active", + "userAccountId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.items": { "combine": "AND", "matchers": [ { - "match": "type" + "match": "type", + "min": 1 } ] }, - "$.items[*].tenantName": { + "$.items[*].email": { "combine": "AND", "matchers": [ { @@ -761,7 +2980,7 @@ } ] }, - "$.items[*].userEmail": { + "$.items[*].status": { "combine": "AND", "matchers": [ { @@ -769,7 +2988,7 @@ } ] }, - "$.items[*].userId": { + "$.items[*].userAccountId": { "combine": "AND", "matchers": [ { @@ -819,11 +3038,11 @@ "type": "Synchronous/HTTP" }, { - "description": "a paginated list request for system suites", + "description": "a request for a feature flag that does not exist", "pending": false, "providerStates": [ { - "name": "at least one system suite exists" + "name": "no feature flag with id 00000000-0000-0000-0000-0000000000a3 exists" } ], "request": { @@ -842,80 +3061,186 @@ } ] } + } + }, + "method": "GET", + "path": "/api/v1/feature-flags/00000000-0000-0000-0000-0000000000a3" + }, + "response": { + "body": { + "content": { + "status": 404, + "title": "Not Found" }, - "query": { - "page": { + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" } ] }, - "pageSize": { + "$.title": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" } ] } } }, - "method": "GET", - "path": "/api/v1/system-suites", - "query": { - "page": [ - "1" - ], - "pageSize": [ - "10" - ] + "status": 404 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a request for a role promotion request that does not exist", + "pending": false, + "providerStates": [ + { + "name": "no role promotion request with id a4a4a4a4-0000-0000-0000-0000000000ff exists" } + ], + "request": { + "headers": { + "X-User-Id": [ + "0000000a-0000-0000-0000-00000000000a" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/role-promotion-requests/a4a4a4a4-0000-0000-0000-0000000000ff" }, "response": { "body": { "content": { - "items": [ - { - "code": "UMS-CORE", - "name": "User Management System", - "status": "Active", - "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" - } - ], - "page": 1, - "pageSize": 10, - "totalItems": 1 + "status": 404, + "title": "Not Found" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/problem+json" ] }, "matchingRules": { "body": { - "$.items": { + "$.status": { "combine": "AND", "matchers": [ { - "match": "type", - "min": 1 + "match": "type" } ] }, - "$.items[*].code": { + "$.title": { "combine": "AND", "matchers": [ { "match": "type" } ] - }, - "$.items[*].name": { + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 404 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a request for a specific approval request that exists", + "pending": false, + "providerStates": [ + { + "name": "an approval request with id a1a1a1a1-0000-0000-0000-000000000001 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/approval-requests/a1a1a1a1-0000-0000-0000-000000000001" + }, + "response": { + "body": { + "content": { + "approvalRequestId": "a1a1a1a1-0000-0000-0000-000000000001", + "requestedRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "requestedSystemId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "Pending", + "targetUserId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "workflowId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.approvalRequestId": { "combine": "AND", "matchers": [ { @@ -923,7 +3248,7 @@ } ] }, - "$.items[*].status": { + "$.requestedRoleId": { "combine": "AND", "matchers": [ { @@ -931,7 +3256,7 @@ } ] }, - "$.items[*].systemSuiteId": { + "$.requestedSystemId": { "combine": "AND", "matchers": [ { @@ -939,7 +3264,7 @@ } ] }, - "$.page": { + "$.status": { "combine": "AND", "matchers": [ { @@ -947,7 +3272,7 @@ } ] }, - "$.pageSize": { + "$.targetUserId": { "combine": "AND", "matchers": [ { @@ -955,7 +3280,7 @@ } ] }, - "$.totalItems": { + "$.workflowId": { "combine": "AND", "matchers": [ { @@ -969,8 +3294,7 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } @@ -981,11 +3305,11 @@ "type": "Synchronous/HTTP" }, { - "description": "a paginated list request for tenants", + "description": "a request for a specific audit record that exists", "pending": false, "providerStates": [ { - "name": "at least one tenant exists" + "name": "an audit record with id a2a2a2a2-0000-0000-0000-000000000001 exists" } ], "request": { @@ -1004,71 +3328,145 @@ } ] } + } + }, + "method": "GET", + "path": "/api/v1/audit-records/a2a2a2a2-0000-0000-0000-000000000001" + }, + "response": { + "body": { + "content": { + "affectedEntityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "affectedEntityType": "ContractTest", + "auditRecordId": "a2a2a2a2-0000-0000-0000-000000000001", + "eventType": "ContractTestEvent", + "rootTenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "whoActed": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, - "query": { - "page": { + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.affectedEntityId": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" } ] }, - "pageSize": { + "$.affectedEntityType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.auditRecordId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.eventType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.rootTenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.whoActed": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a request for a specific feature flag that exists", + "pending": false, + "providerStates": [ + { + "name": "a feature flag with id a3a3a3a3-0000-0000-0000-000000000001 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" } ] } } }, "method": "GET", - "path": "/api/v1/tenants", - "query": { - "page": [ - "1" - ], - "pageSize": [ - "10" - ] - } + "path": "/api/v1/feature-flags/a3a3a3a3-0000-0000-0000-000000000001" }, "response": { "body": { "content": { - "items": [ - { - "code": "ACME", - "name": "Acme Corp", - "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" - } - ], - "page": 1, - "pageSize": 10, - "totalItems": 1 + "featureFlagId": "a3a3a3a3-0000-0000-0000-000000000001", + "flagCode": "CONTRACT_TEST_FLAG", + "flagTargets": "all", + "flagType": "Boolean", + "status": "Inactive", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/json; charset=utf-8" ] }, "matchingRules": { "body": { - "$.items": { - "combine": "AND", - "matchers": [ - { - "match": "type", - "min": 1 - } - ] - }, - "$.items[*].code": { + "$.featureFlagId": { "combine": "AND", "matchers": [ { @@ -1076,7 +3474,7 @@ } ] }, - "$.items[*].name": { + "$.flagCode": { "combine": "AND", "matchers": [ { @@ -1084,7 +3482,7 @@ } ] }, - "$.items[*].tenantId": { + "$.flagTargets": { "combine": "AND", "matchers": [ { @@ -1092,7 +3490,7 @@ } ] }, - "$.page": { + "$.flagType": { "combine": "AND", "matchers": [ { @@ -1100,7 +3498,7 @@ } ] }, - "$.pageSize": { + "$.status": { "combine": "AND", "matchers": [ { @@ -1108,7 +3506,7 @@ } ] }, - "$.totalItems": { + "$.systemSuiteId": { "combine": "AND", "matchers": [ { @@ -1122,8 +3520,7 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } @@ -1134,11 +3531,11 @@ "type": "Synchronous/HTTP" }, { - "description": "a paginated list request for user accounts", + "description": "a request for a specific permission template that exists", "pending": false, "providerStates": [ { - "name": "at least one user account exists" + "name": "a permission template with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 exists" } ], "request": { @@ -1157,50 +3554,23 @@ } ] } - }, - "query": { - "page": { - "combine": "AND", - "matchers": [ - { - "match": "equality" - } - ] - }, - "pageSize": { - "combine": "AND", - "matchers": [ - { - "match": "equality" - } - ] - } } }, "method": "GET", - "path": "/api/v1/user-accounts", - "query": { - "page": [ - "1" - ], - "pageSize": [ - "10" - ] - } + "path": "/api/v1/permission-templates/3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "response": { "body": { "content": { - "items": [ - { - "email": "user@example.com", - "status": "Active", - "userAccountId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" - } - ], - "page": 1, - "pageSize": 10, - "totalItems": 1 + "items": [], + "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", + "roleName": "Administrator", + "status": "Published", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "systemSuiteName": "User Management System", + "templateId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "11111111-1111-1111-1111-111111111111", + "version": "1.0.0" }, "contentType": "application/json", "encoded": false @@ -1212,16 +3582,15 @@ }, "matchingRules": { "body": { - "$.items": { + "$.roleId": { "combine": "AND", "matchers": [ { - "match": "type", - "min": 1 + "match": "type" } ] }, - "$.items[*].email": { + "$.roleName": { "combine": "AND", "matchers": [ { @@ -1229,7 +3598,7 @@ } ] }, - "$.items[*].status": { + "$.status": { "combine": "AND", "matchers": [ { @@ -1237,7 +3606,7 @@ } ] }, - "$.items[*].userAccountId": { + "$.systemSuiteId": { "combine": "AND", "matchers": [ { @@ -1245,7 +3614,7 @@ } ] }, - "$.page": { + "$.systemSuiteName": { "combine": "AND", "matchers": [ { @@ -1253,7 +3622,7 @@ } ] }, - "$.pageSize": { + "$.templateId": { "combine": "AND", "matchers": [ { @@ -1261,7 +3630,15 @@ } ] }, - "$.totalItems": { + "$.tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.version": { "combine": "AND", "matchers": [ { @@ -1287,17 +3664,17 @@ "type": "Synchronous/HTTP" }, { - "description": "a request for a specific permission template that exists", + "description": "a request for a specific role promotion request that exists", "pending": false, "providerStates": [ { - "name": "a permission template with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 exists" + "name": "a role promotion request with id a4a4a4a4-0000-0000-0000-000000000001 exists" } ], "request": { "headers": { "X-User-Id": [ - "dev-user" + "0000000a-0000-0000-0000-00000000000a" ] }, "matchingRules": { @@ -1313,32 +3690,30 @@ } }, "method": "GET", - "path": "/api/v1/permission-templates/3fa85f64-5717-4562-b3fc-2c963f66afa6" + "path": "/api/v1/role-promotion-requests/a4a4a4a4-0000-0000-0000-000000000001" }, "response": { "body": { "content": { - "items": [], - "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", - "roleName": "Administrator", - "status": "Published", - "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "systemSuiteName": "User Management System", - "templateId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "tenantId": "11111111-1111-1111-1111-111111111111", - "version": "1.0.0" + "currentRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "id": "a4a4a4a4-0000-0000-0000-000000000001", + "requesterId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "Draft", + "targetRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "targetUserId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/json; charset=utf-8" ] }, "matchingRules": { "body": { - "$.roleId": { + "$.currentRoleId": { "combine": "AND", "matchers": [ { @@ -1346,7 +3721,7 @@ } ] }, - "$.roleName": { + "$.id": { "combine": "AND", "matchers": [ { @@ -1354,7 +3729,7 @@ } ] }, - "$.status": { + "$.requesterId": { "combine": "AND", "matchers": [ { @@ -1362,7 +3737,7 @@ } ] }, - "$.systemSuiteId": { + "$.status": { "combine": "AND", "matchers": [ { @@ -1370,7 +3745,7 @@ } ] }, - "$.systemSuiteName": { + "$.targetRoleId": { "combine": "AND", "matchers": [ { @@ -1378,7 +3753,7 @@ } ] }, - "$.templateId": { + "$.targetUserId": { "combine": "AND", "matchers": [ { @@ -1393,14 +3768,6 @@ "match": "type" } ] - }, - "$.version": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] } }, "header": { @@ -1408,8 +3775,7 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } @@ -1689,89 +4055,51 @@ "type": "Synchronous/HTTP" }, { - "description": "a valid token request", + "description": "a request for an approval request that does not exist", "pending": false, "providerStates": [ { - "name": "a user account exists with valid credentials" + "name": "no approval request with id 00000000-0000-0000-0000-0000000000a1 exists" } ], "request": { - "body": { - "content": { - "email": "user@example.com", - "password": "ValidPassword123!" - }, - "contentType": "application/json", - "encoded": false - }, "headers": { - "Content-Type": [ - "application/json.*" + "X-User-Id": [ + "dev-user" ] }, "matchingRules": { - "body": { - "$.email": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.password": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - } - }, "header": { - "Content-Type": { + "X-User-Id": { "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } } }, - "method": "POST", - "path": "/api/v1/auth/token" + "method": "GET", + "path": "/api/v1/approval-requests/00000000-0000-0000-0000-0000000000a1" }, "response": { "body": { "content": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI...", - "user": { - "email": "user@example.com", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" - } + "status": 404, + "title": "Not Found" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/problem+json" ] }, "matchingRules": { "body": { - "$.token": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.user.email": { + "$.status": { "combine": "AND", "matchers": [ { @@ -1779,7 +4107,7 @@ } ] }, - "$.user.userId": { + "$.title": { "combine": "AND", "matchers": [ { @@ -1793,78 +4121,50 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } } }, - "status": 200 + "status": 404 }, "type": "Synchronous/HTTP" }, { - "description": "an invalid token request", + "description": "a request for an audit record that does not exist", "pending": false, "providerStates": [ { - "name": "a user account does not exist or credentials do not match" + "name": "no audit record with id 00000000-0000-0000-0000-0000000000a2 exists" } ], "request": { - "body": { - "content": { - "email": "wrong@example.com", - "password": "WrongPassword123!" - }, - "contentType": "application/json", - "encoded": false - }, "headers": { - "Content-Type": [ - "application/json.*" + "X-User-Id": [ + "dev-user" ] }, "matchingRules": { - "body": { - "$.email": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.password": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - } - }, "header": { - "Content-Type": { + "X-User-Id": { "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } } }, - "method": "POST", - "path": "/api/v1/auth/token" + "method": "GET", + "path": "/api/v1/audit-records/00000000-0000-0000-0000-0000000000a2" }, "response": { "body": { "content": { - "status": 401, - "title": "Unauthorized" + "status": 404, + "title": "Not Found" }, "contentType": "application/json", "encoded": false @@ -1904,7 +4204,7 @@ } } }, - "status": 401 + "status": 404 }, "type": "Synchronous/HTTP" } diff --git a/src/apps/ums.api/Ums.Domain.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyTests.cs b/src/apps/ums.api/Ums.Domain.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyTests.cs index 69306da5..5aa400a7 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyTests.cs @@ -97,8 +97,21 @@ public void UpdateAction_WithValidData_ReturnsSuccess() Assert.Equal(newAction, policy.EnforcementAction); } + // ------------------------------------------------------------------------- + // INVARIANTE DE IRREVERSIBILIDAD (G-051) — NO DEBILITAR. + // + // Deactivate es una transicion terminal: el agregado no expone reactivacion, + // por lo que una politica desactivada queda congelada. UpdateAction sobre una + // politica desactivada es una transicion invalida sobre un estado terminal y + // debe rechazarse con Result.Failure (BrokenRule PolicyInactiveCannotUpdate). + // + // El test previo `UpdateAction_WhenInactive_StillSucceeds` afirmaba lo contrario + // (exito), fijando el defecto. Estos tests son el candado de la invariante: no + // deben relajarse para volver a permitir la mutacion de una politica inactiva. + // ------------------------------------------------------------------------- + [Fact] - public void UpdateAction_WhenInactive_StillSucceeds() + public void UpdateAction_WhenInactive_ReturnsFailure() { var policy = AccessEnforcementPolicy.Create(ValidTenantId, ValidProfileId, null, ValidAction, ValidActor).Value; policy.Deactivate(ValidActor); @@ -106,8 +119,20 @@ public void UpdateAction_WhenInactive_StillSucceeds() var result = policy.UpdateAction(newAction, ValidActor); - Assert.True(result.IsSuccess); - Assert.Equal(newAction, policy.EnforcementAction); + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.PolicyInactiveCannotUpdate, result.Error); + } + + [Fact] + public void UpdateAction_WhenInactive_DoesNotMutateEnforcementAction() + { + var policy = AccessEnforcementPolicy.Create(ValidTenantId, ValidProfileId, null, ValidAction, ValidActor).Value; + policy.Deactivate(ValidActor); + + policy.UpdateAction(AccessEnforcementAction.BlockUser, ValidActor); + + // La accion original permanece intacta: el estado terminal es inmutable. + Assert.Equal(ValidAction, policy.EnforcementAction); } #endregion diff --git a/src/apps/ums.api/Ums.Domain.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowTests.cs b/src/apps/ums.api/Ums.Domain.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowTests.cs index 0955a9d0..fab35d8f 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowTests.cs @@ -110,7 +110,7 @@ public void RemoveRequiredDocument_WhenDocumentExists_ReturnsSuccess() var documentTypeId2 = DocumentTypeId.Load(Guid.NewGuid().ToString()); workflow.AddRequiredDocument(documentTypeId1, true, ValidActor); workflow.AddRequiredDocument(documentTypeId2, false, ValidActor); - var documentId = workflow.RequiredDocuments.First().Id; + var documentId = workflow.RequiredDocuments.First().GetId(); var result = workflow.RemoveRequiredDocument(documentId, ValidActor); @@ -125,7 +125,7 @@ public void RemoveRequiredDocument_WhenLastDocumentAndRequiresApproval_ReturnsFa ValidTenantId, ValidCode, ValidName, ValidDescription, ValidUserCategory, true, ValidSystemSuiteId, ValidActor, 1).Value; var documentTypeId = DocumentTypeId.Load(Guid.NewGuid().ToString()); workflow.AddRequiredDocument(documentTypeId, true, ValidActor); - var documentId = workflow.RequiredDocuments.First().Id; + var documentId = workflow.RequiredDocuments.First().GetId(); var result = workflow.RemoveRequiredDocument(documentId, ValidActor); diff --git a/src/apps/ums.api/Ums.Domain.Test/Approvals/RequiredDocumentChecklistTests.cs b/src/apps/ums.api/Ums.Domain.Test/Approvals/RequiredDocumentChecklistTests.cs new file mode 100644 index 00000000..2165725d --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Approvals/RequiredDocumentChecklistTests.cs @@ -0,0 +1,122 @@ +namespace Ums.Domain.Test.Approvals; + +using Ums.Domain.Approvals; +using ApprovalWorkflowAggregate = Ums.Domain.Approvals.ApprovalWorkflow.ApprovalWorkflow; +using UserDocumentAggregate = Ums.Domain.Approvals.UserDocument.UserDocument; +using Xunit; + +public class RequiredDocumentChecklistTests +{ + private static readonly ActorId Actor = ActorId.Create("user-001"); + + private static ApprovalWorkflowAggregate MakeWorkflow() => + ApprovalWorkflowAggregate.Create( + TenantId.Load(Guid.NewGuid().ToString()), + Code.Create("WF-001"), + Name.Create("Onboarding"), + Description.Create("Onboarding workflow"), + UserCategory.Internal, + requiresApproval: true, + SystemSuiteId.Load(Guid.NewGuid().ToString()), + Actor, + requiredDocumentCount: 1).Value; + + private static ApprovalWorkflowAggregate MakeWorkflowRequiring(DocumentTypeId documentTypeId, bool isMandatory = true) + { + var workflow = MakeWorkflow(); + workflow.AddRequiredDocument(documentTypeId, isMandatory, Actor); + return workflow; + } + + private static UserDocumentAggregate MakeDocument(DocumentTypeId documentTypeId, bool valid) + { + var document = UserDocumentAggregate.Upload( + UserId.Load(Guid.NewGuid().ToString()), + documentTypeId, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + DocumentCriticity.High, + TextValueObject.Create("/storage/doc.pdf"), + "checksum", + Actor).Value; + + if (valid) + { + document.Validate(Actor); + } + + return document; + } + + [Fact] + public void Evaluate_WhenNoRequiredDocuments_ReturnsSuccess() + { + var result = RequiredDocumentChecklist.Evaluate(MakeWorkflow(), Array.Empty()); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void Evaluate_WhenMandatoryDocumentValid_ReturnsSuccess() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(docType), + new[] { MakeDocument(docType, valid: true) }); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void Evaluate_WhenMandatoryDocumentMissing_FailsClosed() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(docType), + Array.Empty()); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + } + + [Fact] + public void Evaluate_WhenMandatoryDocumentPresentButNotValid_FailsClosed() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(docType), + new[] { MakeDocument(docType, valid: false) }); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + } + + [Fact] + public void Evaluate_WhenNonMandatoryDocumentMissing_ReturnsSuccess() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(docType, isMandatory: false), + Array.Empty()); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void Evaluate_WhenValidDocumentOfDifferentType_FailsClosed() + { + var requiredType = DocumentTypeId.Load(Guid.NewGuid()); + var otherType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(requiredType), + new[] { MakeDocument(otherType, valid: true) }); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Approvals/UserDocument/UserDocumentTests.cs b/src/apps/ums.api/Ums.Domain.Test/Approvals/UserDocument/UserDocumentTests.cs index e1d11bfc..5ed2e155 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Approvals/UserDocument/UserDocumentTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Approvals/UserDocument/UserDocumentTests.cs @@ -7,8 +7,8 @@ public class UserDocumentTests { private static readonly UserId ValidUserId = UserId.Load(Guid.NewGuid().ToString()); private static readonly DocumentTypeId ValidDocumentTypeId = DocumentTypeId.Load(Guid.NewGuid().ToString()); - private static readonly DateTime ValidIssueDate = new(2024, 1, 1); - private static readonly DateTime ValidExpirationDate = new(2025, 1, 1); + private static readonly DateTime ValidIssueDate = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime ValidExpirationDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly DocumentCriticity ValidCriticity = DocumentCriticity.High; private static readonly TextValueObject ValidFileStoragePath = TextValueObject.Create("/storage/doc.pdf"); private static readonly string ValidFileChecksum = "abc123def456"; @@ -36,8 +36,8 @@ public void Upload_WithValidData_ReturnsSuccess() [Fact] public void Upload_WhenExpirationBeforeIssueDate_ReturnsFailure() { - var issueDate = new DateTime(2025, 1, 1); - var expirationDate = new DateTime(2024, 1, 1); + var issueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var expirationDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); var result = UserDocument.Upload( ValidUserId, ValidDocumentTypeId, issueDate, expirationDate, @@ -50,7 +50,7 @@ public void Upload_WhenExpirationBeforeIssueDate_ReturnsFailure() [Fact] public void Upload_WhenExpirationEqualsIssueDate_ReturnsFailure() { - var date = new DateTime(2024, 1, 1); + var date = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); var result = UserDocument.Upload( ValidUserId, ValidDocumentTypeId, date, date, @@ -221,8 +221,8 @@ public void ReUpload_WhenExpired_ReturnsSuccess() ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Validate(ValidActor); document.Expire(ValidActor); - var newIssueDate = new DateTime(2025, 1, 1); - var newExpirationDate = new DateTime(2026, 1, 1); + var newIssueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; @@ -240,8 +240,8 @@ public void ReUpload_WhenRejected_ReturnsSuccess() ValidUserId, ValidDocumentTypeId, ValidIssueDate, ValidExpirationDate, ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Reject("Invalid", ValidActor); - var newIssueDate = new DateTime(2025, 1, 1); - var newExpirationDate = new DateTime(2026, 1, 1); + var newIssueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; @@ -258,8 +258,8 @@ public void ReUpload_WhenValid_ReturnsFailure() ValidUserId, ValidDocumentTypeId, ValidIssueDate, ValidExpirationDate, ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Validate(ValidActor); - var newIssueDate = new DateTime(2025, 1, 1); - var newExpirationDate = new DateTime(2026, 1, 1); + var newIssueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; @@ -277,8 +277,8 @@ public void ReUpload_WithInvalidDates_ReturnsFailure() ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Validate(ValidActor); document.Expire(ValidActor); - var newIssueDate = new DateTime(2026, 1, 1); - var newExpirationDate = new DateTime(2025, 1, 1); + var newIssueDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; @@ -296,8 +296,8 @@ public void ReUpload_RaisesDocumentUploadedEvent() ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Validate(ValidActor); document.Expire(ValidActor); - var newIssueDate = new DateTime(2025, 1, 1); - var newExpirationDate = new DateTime(2026, 1, 1); + var newIssueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; diff --git a/src/apps/ums.api/Ums.Domain.Test/Audit/AuditRecord/AuditRecordTests.cs b/src/apps/ums.api/Ums.Domain.Test/Audit/AuditRecord/AuditRecordTests.cs index 2a07d97c..f26b2fb8 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Audit/AuditRecord/AuditRecordTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Audit/AuditRecord/AuditRecordTests.cs @@ -167,7 +167,9 @@ public void Record_WithoutMetadata_SetsMetadataToNull() [Fact] public void Record_HasNoMutationMethods_AppendOnly() { - var record = AuditRecord.Record( + // Smoke: el agregado se construye; la aserción verifica la forma del tipo + // (sin métodos de mutación), por lo que la instancia se descarta. + _ = AuditRecord.Record( ValidWhoActed, ValidSubjectType, ValidWhatChanged, diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/Profile/ProfileTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/Profile/ProfileTests.cs index 284550b1..5f5f9f1d 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Authorization/Profile/ProfileTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/Profile/ProfileTests.cs @@ -145,6 +145,35 @@ public void AssignTemplate_WithValidTemplate_ReturnsSuccess() Assert.NotEmpty(profile.Permissions); } + [Fact] + public void AssignTemplate_NoMaterializaLosItemsRetirados() + { + // ADR-0164, la mitad que de verdad importa: retirar dejó de borrar la fila, así que + // `template.Items` ahora incluye lo retirado. Si la materialización siguiera leyéndola, la + // concesión retirada se copiaría al perfil como permiso ACTIVO y el grafo concedería por + // ella. Habríamos cambiado un borrado por una brecha. + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, SystemSuiteId.Load(Guid.NewGuid().ToString()), ValidActor).Value; + + var vigente = IdValueObject.Create(); + var retirado = IdValueObject.Create(); + template.AddItem(ExclusiveArcTarget.Module, vigente, ActionId.Load(Guid.NewGuid().ToString()), true, false, ValidActor); + template.AddItem(ExclusiveArcTarget.Module, retirado, ActionId.Load(Guid.NewGuid().ToString()), true, false, ValidActor); + + var itemRetirado = template.Items.Single(i => i.TargetId.GetValue() == retirado.GetValue()); + Assert.True(template.DeactivateItem(itemRetirado.GetId(), ValidActor).IsSuccess); + Assert.True(template.Publish(ValidActor).IsSuccess); + + var result = profile.AssignTemplate(template, ValidActor); + + Assert.True(result.IsSuccess); + // La plantilla conserva las dos filas; el perfil recibe UNA sola concesión. + Assert.Equal(2, template.Items.Count); + Assert.Single(profile.Permissions); + Assert.Equal(vigente.GetValue(), profile.Permissions.Single().TargetId.GetValue()); + Assert.DoesNotContain(profile.Permissions, p => p.TargetId.GetValue() == retirado.GetValue()); + } + [Fact] public void AssignTemplate_WhenProfileInactive_ReturnsFailure() { @@ -236,8 +265,91 @@ public void Deactivate_CascadesToAllActivePermissions() #endregion + #region ChangeRole (ADR-UMS-096: efecto de la promoción de rol IGA) + + [Fact] + public void ChangeRole_WhenActiveAndDifferentRole_ReturnsSuccessAndReassigns() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + var newRoleId = RoleId.Load(Guid.NewGuid().ToString()); + + var result = profile.ChangeRole(newRoleId, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(newRoleId.GetValue(), profile.RoleId.GetValue()); + } + + [Fact] + public void ChangeRole_RaisesProfileRoleChangedEvent() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + var newRoleId = RoleId.Load(Guid.NewGuid().ToString()); + + profile.ChangeRole(newRoleId, ValidActor); + + var events = profile.DomainEvents.GetUncommittedChanges().ToList(); + var changed = Assert.IsType(Assert.Single(events, e => e is ProfileRoleChangedEvent)); + Assert.Equal(ValidRoleId.GetValue(), changed.PreviousRoleId); + Assert.Equal(newRoleId.GetValue(), changed.NewRoleId); + } + + [Fact] + public void ChangeRole_WhenSameRole_ReturnsFailure() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + + var result = profile.ChangeRole(ValidRoleId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.ProfileRoleUnchanged, result.Error); + Assert.Equal(ValidRoleId.GetValue(), profile.RoleId.GetValue()); + } + + [Fact] + public void ChangeRole_WhenProfileInactive_ReturnsFailure() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + profile.Deactivate(ValidActor); + var newRoleId = RoleId.Load(Guid.NewGuid().ToString()); + + var result = profile.ChangeRole(newRoleId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.ProfileAlreadyInactive, result.Error); + } + + #endregion + #region Permission Overrides + // AT06/F1 (regresión): el override de un permiso REAL debe resolverse por su identidad canónica + // (Props.Id, la que expone el read-model vía GetId()). El Id base de Entity<> se regenera aleatorio + // en construcción — distinto de Props.Id ya desde Create — y la rehidratación no llama SetId, así + // que FindPermission buscando por el Id base devolvía PermissionNotFound (404 en la API: override/ + // activate/deactivate inalcanzables). Los tests previos solo cubrían casos negativos (id falso), + // por eso el bug pasó. Este test ejercita el camino positivo con el id canónico del permiso. + [Fact] + public void OverridePermissionDeny_WithCanonicalPermissionId_Succeeds() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, SystemSuiteId.Load(Guid.NewGuid().ToString()), ValidActor).Value; + template.AddItem(ExclusiveArcTarget.Module, IdValueObject.Create(), ActionId.Load(Guid.NewGuid().ToString()), true, false, ValidActor); + template.Publish(ValidActor); + profile.AssignTemplate(template, ValidActor); + + var permission = profile.Permissions.First(); + var canonicalId = permission.GetId(); // == Props.Id (lo que expone el DTO) + + // Documenta la causa raíz: el Id base diverge de la identidad canónica. + Assert.NotEqual(permission.Id.GetValue(), canonicalId.GetValue()); + + var result = profile.OverridePermissionDeny(canonicalId, ValidActor); + + Assert.True(result.IsSuccess); + Assert.True(permission.IsDenied); + Assert.True(permission.IsOverride); + } + [Fact] public void OverridePermissionAllow_WhenProfileInactive_ReturnsFailure() { diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/SeedData/AuthorizationSeedDataTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/SeedData/AuthorizationSeedDataTests.cs index 7bd442f5..9abb2784 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Authorization/SeedData/AuthorizationSeedDataTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/SeedData/AuthorizationSeedDataTests.cs @@ -1,10 +1,13 @@ namespace Ums.Domain.Test.Authorization.SeedData; using Ums.Domain.Authorization.SystemSuite; +using Ums.Domain.Authorization.SystemSuite.MenuNode; using Ums.Domain.Authorization.Template; using Ums.Domain.Kernel.ValueObjects; using Ums.Domain.Enums; using Xunit; +using ModuleEntity = Ums.Domain.Authorization.SystemSuite.Module.Module; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; public class AuthorizationSeedDataTests { @@ -28,36 +31,53 @@ public void BuildSystemSuite_WithModules_ReturnsNonEmptyModules() } [Fact] - public void BuildModule_WithMenus_ReturnsNonEmptyMenus() + public void BuildModule_WithRootNodes_ReturnsNonEmptyNodes() { var suite = CreateSuiteWithModule("MOD1", out var module); - module.AddMenu(Code.Create("MENU1"), Name.Create("Menu 1"), Description.Create("Desc"), 1, TestActor); - module.AddMenu(Code.Create("MENU2"), Name.Create("Menu 2"), Description.Create("Desc"), 2, TestActor); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create("MENU1"), Name.Create("Menu 1"), Description.Create("Desc"), 1, TestActor); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create("MENU2"), Name.Create("Menu 2"), Description.Create("Desc"), 2, TestActor); - Assert.Equal(2, module.Menus.Count); + Assert.Equal(2, module.Nodes.Count); } [Fact] - public void BuildMenu_WithSubMenus_ReturnsNonEmptySubMenus() + public void BuildMenuNode_WithSubMenuChildren_ReturnsNonEmptyChildren() { - var suite = CreateSuiteWithModuleAndMenu("MOD1", "MENU1", out var menu); + var suite = CreateSuiteWithModuleAndMenu("MOD1", "MENU1", out var module, out var menuNode); - menu.AddSubMenu(Code.Create("SUB1"), Name.Create("Sub 1"), Description.Create("Desc"), 1, TestActor); - menu.AddSubMenu(Code.Create("SUB2"), Name.Create("Sub 2"), Description.Create("Desc"), 2, TestActor); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create("SUB1"), Name.Create("Sub 1"), Description.Create("Desc"), 1, TestActor); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create("SUB2"), Name.Create("Sub 2"), Description.Create("Desc"), 2, TestActor); - Assert.Equal(2, menu.SubMenus.Count); + Assert.Equal(2, menuNode.Children.Count); } [Fact] - public void BuildSubMenu_WithOptions_ReturnsNonEmptyOptions() + public void BuildSubMenuNode_WithOptionChildren_ReturnsNonEmptyChildren() { - var suite = CreateSuiteWithModuleMenuAndSubMenu("MOD1", "MENU1", "SUB1", out var subMenu); + var suite = CreateSuiteWithModuleMenuAndSubMenu("MOD1", "MENU1", "SUB1", out var module, out var subNode); - subMenu.AddOption(Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), ActionCode.Create("READ"), 1, TestActor); - subMenu.AddOption(Code.Create("OPT2"), Name.Create("Opt 2"), Description.Create("Desc"), ActionCode.Create("CREATE"), 2, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), 1, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("OPT2"), Name.Create("Opt 2"), Description.Create("Desc"), 2, TestActor); - Assert.Equal(2, subMenu.Options.Count); + Assert.Equal(2, subNode.Children.Count); + } + + [Fact] + public void LinkNodeAction_OnOptionLeaf_RecordsActionCodesNm() + { + var suite = CreateSuiteWithModuleMenuAndSubMenu("MOD1", "MENU1", "SUB1", out var module, out var subNode); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), 1, TestActor); + var option = subNode.Children.First(); + + // G-046: an action must be registered in the suite catalog before it can be linked to a node. + suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Read"), TestActor); + suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Create"), TestActor); + + suite.LinkModuleNodeAction(module.Props.Id, option.GetId(), ActionCode.Create("READ"), TestActor); + suite.LinkModuleNodeAction(module.Props.Id, option.GetId(), ActionCode.Create("CREATE"), TestActor); + + Assert.Equal(2, option.ActionCodes.Count); } [Fact] @@ -93,22 +113,22 @@ public void FullHierarchy_SuiteHasAllLevels_ReturnsCompleteStructure() var module = suite.Modules.First(); suite.ActivateModule(module.Id, TestActor); - module.AddMenu(Code.Create("USERS"), Name.Create("Users"), Description.Create("User management"), 1, TestActor); - var menu = module.Menus.First(); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create("USERS"), Name.Create("Users"), Description.Create("User management"), 1, TestActor); + var menuNode = module.Nodes.First(); - menu.AddSubMenu(Code.Create("USER_LIST"), Name.Create("User List"), Description.Create("List users"), 1, TestActor); - var subMenu = menu.SubMenus.First(); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create("USER_LIST"), Name.Create("User List"), Description.Create("List users"), 1, TestActor); + var subNode = menuNode.Children.First(); - subMenu.AddOption(Code.Create("USER_VIEW"), Name.Create("View Users"), Description.Create("View"), ActionCode.Create("READ"), 1, TestActor); - subMenu.AddOption(Code.Create("USER_CREATE"), Name.Create("Create User"), Description.Create("Create"), ActionCode.Create("CREATE"), 2, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("USER_VIEW"), Name.Create("View Users"), Description.Create("View"), 1, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("USER_CREATE"), Name.Create("Create User"), Description.Create("Create"), 2, TestActor); suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Read"), TestActor); suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Create"), TestActor); Assert.Single(suite.Modules); - Assert.Single(suite.Modules.First().Menus); - Assert.Single(suite.Modules.First().Menus.First().SubMenus); - Assert.Equal(2, suite.Modules.First().Menus.First().SubMenus.First().Options.Count); + Assert.Single(module.Nodes); + Assert.Single(menuNode.Children); + Assert.Equal(2, subNode.Children.Count); Assert.Equal(2, suite.Actions.Count); } @@ -148,14 +168,14 @@ public void PermissionTemplate_WithMultipleTargetTypes_CoversAllArcLevels() TestActor).Value; var module = suite.Modules.First(); - var menu = module.Menus.First(); - var subMenu = menu.SubMenus.First(); - var option = subMenu.Options.First(); + var menuNode = module.Nodes.First(); + var subNode = menuNode.Children.First(); + var option = subNode.Children.First(); template.AddItem(ExclusiveArcTarget.SystemSuite, suite.GetId(), readActionId, true, false, TestActor); template.AddItem(ExclusiveArcTarget.Module, module.Id, createActionId, true, false, TestActor); - template.AddItem(ExclusiveArcTarget.Submodule, subMenu.Id, readActionId, true, false, TestActor); - template.AddItem(ExclusiveArcTarget.Option, option.Id, createActionId, false, true, TestActor); + template.AddItem(ExclusiveArcTarget.Submodule, subNode.GetId(), readActionId, true, false, TestActor); + template.AddItem(ExclusiveArcTarget.Option, option.GetId(), createActionId, false, true, TestActor); Assert.Equal(4, template.Items.Count); Assert.Contains(template.Items, i => i.TargetType == ExclusiveArcTarget.SystemSuite); @@ -226,7 +246,7 @@ public void PermissionTemplate_WithInactiveItem_HasDeactivatedEntry() template.AddItem(ExclusiveArcTarget.Module, module.Id, readActionId, true, false, TestActor); var item = template.Items.First(); - template.DeactivateItem(item.Id, TestActor); + template.DeactivateItem(item.GetId(), TestActor); Assert.False(template.Items.First().IsActive); } @@ -244,7 +264,6 @@ public void SystemTemplateCoherence_TemplateActionIdsExistInSystemSuite() TestActor).Value; var module = suite.Modules.First(); - var subMenu = module.Menus.First().SubMenus.First(); foreach (var action in suite.Actions.Take(5)) { @@ -262,8 +281,9 @@ public void SystemTemplateCoherence_TemplateTargetIdsExistInSystemSuite() { var suite = BuildCompleteSuite(); var moduleIds = suite.Modules.Select(m => m.Id.GetValue()).ToHashSet(); - var subMenuIds = suite.Modules.SelectMany(m => m.Menus.SelectMany(mn => mn.SubMenus)).Select(sm => sm.Id.GetValue()).ToHashSet(); - var optionIds = suite.Modules.SelectMany(m => m.Menus.SelectMany(mn => mn.SubMenus.SelectMany(sm => sm.Options))).Select(o => o.Id.GetValue()).ToHashSet(); + var allNodes = suite.Modules.SelectMany(m => FlattenNodes(m.Nodes)).ToList(); + var subMenuIds = allNodes.Where(n => n.Kind == NodeKind.SubMenu).Select(n => n.GetId().GetValue()).ToHashSet(); + var optionIds = allNodes.Where(n => n.Kind == NodeKind.Option).Select(n => n.GetId().GetValue()).ToHashSet(); var template = PermissionTemplate.Create( TestTenantId, @@ -273,12 +293,12 @@ public void SystemTemplateCoherence_TemplateTargetIdsExistInSystemSuite() var readActionId = suite.Actions.First(a => a.Code.GetValue() == "READ").GetId(); var module = suite.Modules.First(); - var subMenu = module.Menus.First().SubMenus.First(); - var option = subMenu.Options.First(); + var subNode = module.Nodes.First().Children.First(); + var option = subNode.Children.First(); template.AddItem(ExclusiveArcTarget.Module, module.Id, readActionId, true, false, TestActor); - template.AddItem(ExclusiveArcTarget.Submodule, subMenu.Id, readActionId, true, false, TestActor); - template.AddItem(ExclusiveArcTarget.Option, option.Id, readActionId, true, false, TestActor); + template.AddItem(ExclusiveArcTarget.Submodule, subNode.GetId(), readActionId, true, false, TestActor); + template.AddItem(ExclusiveArcTarget.Option, option.GetId(), readActionId, true, false, TestActor); foreach (var item in template.Items) { @@ -408,7 +428,19 @@ public void SeedData_WmsCustomActions_AllRegistered() } } - private static SystemSuite CreateSuiteWithModule(string moduleCode, out Ums.Domain.Authorization.SystemSuite.Module.Module module) + private static IEnumerable FlattenNodes(IEnumerable nodes) + { + foreach (var node in nodes) + { + yield return node; + foreach (var child in FlattenNodes(node.Children)) + { + yield return child; + } + } + } + + private static SystemSuite CreateSuiteWithModule(string moduleCode, out ModuleEntity module) { var suite = SystemSuite.Create( TestTenantId, @@ -423,19 +455,19 @@ private static SystemSuite CreateSuiteWithModule(string moduleCode, out Ums.Doma return suite; } - private static SystemSuite CreateSuiteWithModuleAndMenu(string moduleCode, string menuCode, out Ums.Domain.Authorization.SystemSuite.Menu.Menu menu) + private static SystemSuite CreateSuiteWithModuleAndMenu(string moduleCode, string menuCode, out ModuleEntity module, out MenuNodeEntity menuNode) { - var suite = CreateSuiteWithModule(moduleCode, out var module); - module.AddMenu(Code.Create(menuCode), Name.Create(menuCode), Description.Create("Desc"), 1, TestActor); - menu = module.Menus.First(); + var suite = CreateSuiteWithModule(moduleCode, out module); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create(menuCode), Name.Create(menuCode), Description.Create("Desc"), 1, TestActor); + menuNode = module.Nodes.First(); return suite; } - private static SystemSuite CreateSuiteWithModuleMenuAndSubMenu(string moduleCode, string menuCode, string subMenuCode, out Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu subMenu) + private static SystemSuite CreateSuiteWithModuleMenuAndSubMenu(string moduleCode, string menuCode, string subMenuCode, out ModuleEntity module, out MenuNodeEntity subNode) { - var suite = CreateSuiteWithModuleAndMenu(moduleCode, menuCode, out var menu); - menu.AddSubMenu(Code.Create(subMenuCode), Name.Create(subMenuCode), Description.Create("Desc"), 1, TestActor); - subMenu = menu.SubMenus.First(); + var suite = CreateSuiteWithModuleAndMenu(moduleCode, menuCode, out module, out var menuNode); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create(subMenuCode), Name.Create(subMenuCode), Description.Create("Desc"), 1, TestActor); + subNode = menuNode.Children.First(); return suite; } @@ -452,13 +484,13 @@ private static SystemSuite BuildCompleteSuite() var module = suite.Modules.First(); suite.ActivateModule(module.Id, TestActor); - module.AddMenu(Code.Create("MENU1"), Name.Create("Menu 1"), Description.Create("Desc"), 1, TestActor); - var menu = module.Menus.First(); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create("MENU1"), Name.Create("Menu 1"), Description.Create("Desc"), 1, TestActor); + var menuNode = module.Nodes.First(); - menu.AddSubMenu(Code.Create("SUB1"), Name.Create("Sub 1"), Description.Create("Desc"), 1, TestActor); - var subMenu = menu.SubMenus.First(); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create("SUB1"), Name.Create("Sub 1"), Description.Create("Desc"), 1, TestActor); + var subNode = menuNode.Children.First(); - subMenu.AddOption(Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), ActionCode.Create("READ"), 1, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), 1, TestActor); suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Read"), TestActor); suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Create"), TestActor); diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/MenuNode/MenuNodeMetadataEqualityTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/MenuNode/MenuNodeMetadataEqualityTests.cs new file mode 100644 index 00000000..6fb45ac4 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/MenuNode/MenuNodeMetadataEqualityTests.cs @@ -0,0 +1,101 @@ +namespace Ums.Domain.Test.Authorization.SystemSuite.MenuNode; + +using Ums.Domain.Authorization.SystemSuite.MenuNode; +using Xunit; + +/// +/// G-055: es un value object inmutable. Estas pruebas +/// fijan su igualdad por valor sobre los 7 campos normalizados (contenido igual ⇒ +/// iguales, hash consistente) y su uso correcto en HashSet/Dictionary. +/// +public class MenuNodeMetadataEqualityTests +{ + [Fact] + public void SameContent_AreEqualAndShareHash() + { + var a = MenuNodeMetadata.Create( + responsable: "Ana", + criticidad: "Alta", + productoImpactado: "UMS", + componenteTecnico: "Auth", + dependencias: "ninguna", + evidencias: "PR-80", + trazabilidadSdlc: "ADR-0090"); + + var b = MenuNodeMetadata.Create( + responsable: "Ana", + criticidad: "Alta", + productoImpactado: "UMS", + componenteTecnico: "Auth", + dependencias: "ninguna", + evidencias: "PR-80", + trazabilidadSdlc: "ADR-0090"); + + Assert.True(a.Equals(b)); + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.False(ReferenceEquals(a, b)); + } + + [Fact] + public void NormalizationMakesWhitespaceEquivalent() + { + var trimmed = MenuNodeMetadata.Create(responsable: "Ana"); + var padded = MenuNodeMetadata.Create(responsable: " Ana "); + + // Normalize() recorta, así que ambos representan el mismo valor. + Assert.Equal(trimmed, padded); + Assert.Equal(trimmed.GetHashCode(), padded.GetHashCode()); + } + + [Fact] + public void EmptyEqualsCreateWithoutArguments() + { + Assert.Equal(MenuNodeMetadata.Empty, MenuNodeMetadata.Create()); + Assert.Equal(MenuNodeMetadata.Empty.GetHashCode(), MenuNodeMetadata.Create().GetHashCode()); + } + + [Fact] + public void DifferentContent_AreNotEqual() + { + var a = MenuNodeMetadata.Create(responsable: "Ana"); + var b = MenuNodeMetadata.Create(responsable: "Beto"); + + Assert.NotEqual(a, b); + Assert.False(a.Equals(b)); + } + + [Fact] + public void DifferingOnlyInOneField_AreNotEqual() + { + var a = MenuNodeMetadata.Create(responsable: "Ana", criticidad: "Alta"); + var b = MenuNodeMetadata.Create(responsable: "Ana", criticidad: "Media"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void NullAndOtherType_AreNotEqual() + { + var a = MenuNodeMetadata.Create(responsable: "Ana"); + + Assert.False(a.Equals(null)); + Assert.False(a.Equals("Ana")); + } + + [Fact] + public void WorksAsHashSetElementAndDictionaryKey() + { + var a1 = MenuNodeMetadata.Create(responsable: "Ana", criticidad: "Alta"); + var a2 = MenuNodeMetadata.Create(responsable: "Ana", criticidad: "Alta"); + var other = MenuNodeMetadata.Create(responsable: "Beto"); + + var set = new HashSet { a1, a2, other }; + Assert.Equal(2, set.Count); + Assert.Contains(a2, set); + + var map = new Dictionary { [a1] = "x" }; + Assert.True(map.TryGetValue(a2, out var found)); + Assert.Equal("x", found); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/PresentacionDeNavegacionTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/PresentacionDeNavegacionTests.cs new file mode 100644 index 00000000..ae170ea8 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/PresentacionDeNavegacionTests.cs @@ -0,0 +1,111 @@ +namespace Ums.Domain.Test.Authorization.SystemSuite; + +using Ums.Domain.Authorization.SystemSuite; +using Ums.Domain.Authorization.SystemSuite.MenuNode; +using Xunit; + +/// +/// La presentación de la navegación —icono del módulo (G-182) e icono/ruta de los nodos (D-028)— +/// tiene que sobrevivir al alta. Estas pruebas existen porque no sobrevivía: el módulo aceptaba una +/// presentación para sus nodos raíz y la descartaba en silencio al crearlos, de modo que TODOS los +/// menús de primer nivel —que son justo los que pinta la barra— quedaban sin icono y sin ruta. +/// +public class PresentacionDeNavegacionTests +{ + private static readonly ActorId Actor = ActorId.Create("test-user"); + + private static (SystemSuite Suite, IdValueObject ModuleId) SuiteConModulo(string? icono = null) + { + var suite = SystemSuite.Create( + TenantId.Load(Guid.NewGuid().ToString()), + Code.Create("WMS"), + Name.Create("Almacén"), + Description.Create("Sistema de almacén"), + Actor).Value; + + suite.AddModule(Code.Create("INV"), Name.Create("Inventario"), Description.Create("Inventario"), 1, Actor, icono); + var moduleId = suite.Modules.First().Props.Id; + suite.ActivateModule(moduleId, Actor); + return (suite, moduleId); + } + + [Fact] + public void El_modulo_conserva_el_icono_con_el_que_se_dio_de_alta() + { + var (suite, _) = SuiteConModulo("package"); + + Assert.Equal("package", suite.Modules.First().Icon); + } + + [Fact] + public void Un_modulo_sin_icono_no_inventa_ninguno() + { + // Nulo y no una cadena por defecto: el cliente debe poder distinguir «sin configurar» + // de «configurado a algo», y elegir su respaldo. + var (suite, _) = SuiteConModulo(); + + Assert.Null(suite.Modules.First().Icon); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void El_icono_en_blanco_equivale_a_no_tener_icono(string icono) + { + var (suite, _) = SuiteConModulo(icono); + + Assert.Null(suite.Modules.First().Icon); + } + + [Fact] + public void El_icono_del_modulo_se_puede_cambiar_y_borrar() + { + var (suite, moduleId) = SuiteConModulo("package"); + + suite.SetModuleIcon(moduleId, " truck ", Actor); + Assert.Equal("truck", suite.Modules.First().Icon); + + suite.SetModuleIcon(moduleId, null, Actor); + Assert.Null(suite.Modules.First().Icon); + } + + [Fact] + public void El_nodo_raiz_conserva_su_icono_y_su_ruta() + { + // La regresión que estas pruebas cierran: el nodo raíz recibía la presentación y la perdía, + // así que el menú que ve el usuario se quedaba sin icono y sin destino. + var (suite, moduleId) = SuiteConModulo("package"); + + suite.AddModuleRootNode( + moduleId, + NodeKind.Menu, + Code.Create("STOCK"), + Name.Create("Stock"), + Description.Create("Stock"), + 1, + Actor, + presentation: MenuNodePresentation.Create("package", "/stock")); + + var nodo = suite.Modules.First().Nodes.First(); + Assert.Equal("package", nodo.Props.Presentation.Icon); + Assert.Equal("/stock", nodo.Props.Presentation.Route); + } + + [Fact] + public void El_nodo_hijo_conserva_su_icono_y_su_ruta() + { + var (suite, moduleId) = SuiteConModulo(); + + suite.AddModuleRootNode(moduleId, NodeKind.Menu, Code.Create("STOCK"), Name.Create("Stock"), + Description.Create("Stock"), 1, Actor); + var menuId = suite.Modules.First().Nodes.First().GetId(); + + suite.AddModuleChildNode(moduleId, menuId, NodeKind.Option, Code.Create("STOCK_VIEW"), + Name.Create("Ver Stock"), Description.Create("Ver Stock"), 1, Actor, + presentation: MenuNodePresentation.Create(null, "/stock/ver")); + + var hijo = suite.Modules.First().Nodes.First().Children.First(); + Assert.Null(hijo.Props.Presentation.Icon); + Assert.Equal("/stock/ver", hijo.Props.Presentation.Route); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/SystemSuiteTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/SystemSuiteTests.cs index f09222ee..a3182e12 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/SystemSuiteTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/SystemSuiteTests.cs @@ -2,6 +2,7 @@ namespace Ums.Domain.Test.Authorization.SystemSuite; using Ums.Domain.Authorization.SystemSuite; using Ums.Domain.Authorization.SystemSuite.DomainResource; +using Ums.Domain.Authorization.SystemSuite.MenuNode; using Xunit; public class SystemSuiteTests @@ -96,6 +97,109 @@ public void SetStatus_RaisesSystemSuiteStatusChangedEvent() #endregion + #region Delete (eliminación lógica, G-246) + + [Fact] + public void Delete_WhenStillInService_ReturnsFailure() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + + var result = suite.Delete(SystemSuiteDependents.None, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteNotDeprecated, result.Error); + } + + [Fact] + public void Delete_WhenDeprecatedWithoutLiveReferences_MarksDeletedAndRaisesEvent() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + // Su propia composición no cuenta como referencia externa: se oculta con el agregado. + suite.AddModule(Code.Create("MOD-001"), ValidName, ValidDescription, 1, ValidActor); + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + + var result = suite.Delete(SystemSuiteDependents.None, ValidActor); + + Assert.True(result.IsSuccess); + // La prueba de que es borrado LÓGICO en el plano del dominio: el agregado sigue existiendo, + // con su composición intacta, y lo único que cambió es el estado. + Assert.Equal(SystemStatus.Deleted, suite.Status); + Assert.Single(suite.Modules); + Assert.Contains(suite.DomainEvents.GetUncommittedChanges(), e => e is SystemSuiteDeletedEvent); + } + + [Fact] + public void Delete_WithLiveReferences_ReturnsFailureAndLeavesStatusUntouched() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + + var result = suite.Delete(SystemSuiteDependents.None with { Roles = 1 }, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteHasDependents, result.Error); + Assert.Equal(SystemStatus.Deprecated, suite.Status); + Assert.DoesNotContain(suite.DomainEvents.GetUncommittedChanges(), e => e is SystemSuiteDeletedEvent); + } + + [Fact] + public void Delete_WithReferenceAlreadyLogicallyDeleted_Succeeds() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + + // El conteo llega ya depurado desde el repositorio: lo eliminado lógicamente no se cuenta, + // así que el dominio ve cero referencias vivas y deja eliminar. Es la otra mitad de la regla + // de cascada — la que evita que un sistema quede bloqueado para siempre por lápidas. + var result = suite.Delete(SystemSuiteDependents.None, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(SystemStatus.Deleted, suite.Status); + } + + [Fact] + public void Delete_WhenAlreadyDeleted_ReturnsFailure() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + suite.Delete(SystemSuiteDependents.None, ValidActor); + + var result = suite.Delete(SystemSuiteDependents.None, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteAlreadyDeleted, result.Error); + } + + [Fact] + public void SetStatus_ToDeleted_ReturnsFailure() + { + // Si esto se permitiera, `PUT /status {"status":"Deleted"}` eliminaría el sistema sin pasar + // por la guarda de cascada. La puerta de la eliminación es una sola. + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + + var result = suite.SetStatus(SystemStatus.Deleted, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteDeletedNotSettable, result.Error); + Assert.Equal(SystemStatus.Active, suite.Status); + } + + [Fact] + public void SetStatus_OnDeletedSuite_ReturnsFailure() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + suite.Delete(SystemSuiteDependents.None, ValidActor); + + var result = suite.SetStatus(SystemStatus.Active, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteDeletedIsTerminal, result.Error); + Assert.Equal(SystemStatus.Deleted, suite.Status); + } + + #endregion + #region AddModule [Fact] @@ -198,8 +302,19 @@ public void RemoveModule_WhenModuleHasActiveMenus_ReturnsFailure() var moduleDescription = Description.Create("A test module"); suite.AddModule(moduleCode, moduleName, moduleDescription, 1, ValidActor); var moduleId = suite.Modules.First().GetId(); + // El agregado cuenta sus propios nodos-menú activos (G-154): módulo activo + un nodo Menú + // (nace Active) → la remoción debe fallar por ModuleHasActiveMenus. + suite.ActivateModule(moduleId, ValidActor); + suite.AddModuleRootNode( + moduleId, + NodeKind.Menu, + Code.Create("MENU-001"), + Name.Create("Menú Activo"), + Description.Create("Un menú activo del módulo"), + 1, + ValidActor); - var result = suite.RemoveModule(moduleId, ValidActor, activeMenuCount: 1); + var result = suite.RemoveModule(moduleId, ValidActor); Assert.True(result.IsFailure); Assert.Contains(DomainErrors.Authorization.ModuleHasActiveMenus, result.Error); diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/Template/PermissionTemplateTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/Template/PermissionTemplateTests.cs index 9b1a93eb..b953a664 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Authorization/Template/PermissionTemplateTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/Template/PermissionTemplateTests.cs @@ -40,6 +40,54 @@ public void Create_RaisesPermissionTemplateCreatedEvent() #endregion + #region CreateNextVersion + + [Fact] + public void CreateNextVersion_WhenNoExistingVersions_UsesInitial() + { + var result = PermissionTemplate.CreateNextVersion( + ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor, Array.Empty()); + + Assert.True(result.IsSuccess); + Assert.Equal("0.1.0", result.Value.Version.GetValue()); + } + + [Fact] + public void CreateNextVersion_WhenNullExistingVersions_UsesInitial() + { + var result = PermissionTemplate.CreateNextVersion( + ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor, null!); + + Assert.True(result.IsSuccess); + Assert.Equal("0.1.0", result.Value.Version.GetValue()); + } + + [Fact] + public void CreateNextVersion_WhenInitialExists_IncrementsMinor() + { + var result = PermissionTemplate.CreateNextVersion( + ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor, + new[] { TemplateVersion.Initial() }); + + Assert.True(result.IsSuccess); + Assert.Equal("0.2.0", result.Value.Version.GetValue()); + } + + [Fact] + public void CreateNextVersion_UsesMaxExistingVersion_NotStringOrder() + { + // 2.0.0 es la máxima real; el orden alfabético colocaría "10.0.0" < "2.0.0" pero + // aquí probamos la comparación numérica por segmento. + var result = PermissionTemplate.CreateNextVersion( + ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor, + new[] { TemplateVersion.Initial(), TemplateVersion.Create(2, 0, 0), TemplateVersion.Create(1, 5, 0) }); + + Assert.True(result.IsSuccess); + Assert.Equal("2.1.0", result.Value.Version.GetValue()); + } + + #endregion + #region Publish [Fact] @@ -205,6 +253,75 @@ public void Delete_RaisesPermissionTemplateDeletedEvent() Assert.Contains(events, e => e is PermissionTemplateDeletedEvent); } + // ── Borrado LÓGICO: el estado terminal es la política, no un efecto colateral ── + + [Fact] + public void Delete_TransicionaAlEstadoTerminalDeleted_YNoDestruyeElAgregado() + { + // Fija la política: eliminar es cambiar de estado, no desaparecer. Los ítems —el rastro de qué + // concesiones otorgó la plantilla— siguen ahí, que es lo que el negocio consulta hacia atrás. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + + var result = template.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(TemplateStatus.Deleted, template.Status); + Assert.True(template.IsDeleted); + Assert.Single(template.Items); + } + + [Fact] + public void Delete_CuandoYaEstaEliminada_ReturnsFailure() + { + // El estado terminal no es repetible: un segundo borrado es conflicto, no éxito silencioso. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.Delete(ValidActor); + + var result = template.Delete(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.TemplateAlreadyDeleted, result.Error); + } + + [Fact] + public void Delete_ConReferenciaViva_Bloquea_YConLaReferenciaYaEliminada_Permite() + { + // Regla de cascada en su forma más pura: el MISMO agregado se rechaza mientras la referencia + // está viva (activeProfileCount > 0) y se acepta en cuanto esa referencia deja de estarlo. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + + var bloqueado = template.Delete(ValidActor, activeProfileCount: 2); + + Assert.True(bloqueado.IsFailure); + Assert.Contains(DomainErrors.Authorization.TemplateHasActiveProfiles, bloqueado.Error); + Assert.NotEqual(TemplateStatus.Deleted, template.Status); + + // Cada petición HTTP rehidrata el agregado y el repositorio limpia las reglas rotas + // (`aggregate.BrokenRules.Clear()`); se replica aquí para que el segundo intento parta del + // mismo estado limpio que tendría en producción. + template.BrokenRules.Clear(); + + var permitido = template.Delete(ValidActor, activeProfileCount: 0); + + Assert.True(permitido.IsSuccess); + Assert.Equal(TemplateStatus.Deleted, template.Status); + } + + [Fact] + public void Delete_NoPermiteMutarLaPlantillaDespues() + { + // Una plantilla eliminada no puede recibir ítems ni volver a publicarse: el ciclo de vida acabó. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.Delete(ValidActor); + + var addResult = template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + Assert.True(addResult.IsFailure); + + var publishResult = template.Publish(ValidActor); + Assert.True(publishResult.IsFailure); + } + #endregion #region AddItem @@ -246,6 +363,46 @@ public void AddItem_WithDuplicateTarget_ReturnsFailure() Assert.Contains(DomainErrors.Authorization.TemplateItemTargetAlreadyExists, result.Error); } + /// + /// G-192 — el agregado NO restringe el arco a los cuatro destinos de navegación: admite + /// cualquier valor de , incluidos los objetos de dominio. + /// Esta prueba fija esa verdad para que la capa de aplicación no vuelva a estrecharla por su + /// cuenta: el validador rechazaba Aggregate y Entity mientras el grafo sí sabía proyectarlos. + /// + [Theory] + [InlineData(nameof(ExclusiveArcTarget.SystemSuite))] + [InlineData(nameof(ExclusiveArcTarget.Module))] + [InlineData(nameof(ExclusiveArcTarget.Submodule))] + [InlineData(nameof(ExclusiveArcTarget.Option))] + [InlineData(nameof(ExclusiveArcTarget.Aggregate))] + [InlineData(nameof(ExclusiveArcTarget.Entity))] + public void AddItem_AdmiteTodosLosDestinosDelArcoExclusivo(string targetTypeName) + { + var targetType = DomainEnumeration.GetAll().Single(t => t.Name == targetTypeName); + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + + var result = template.AddItem(targetType, ValidTargetId, ValidActionId, true, false, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(targetType, template.Items.Single().TargetType); + } + + /// + /// G-192 — el destino forma parte de la clave del ítem: el mismo recurso y la misma acción + /// pueden concederse como Aggregate y como Entity sin que el agregado lo tome por duplicado. + /// + [Fact] + public void AddItem_MismoDestinoConTipoDistinto_NoEsDuplicado() + { + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + Assert.True(template.AddItem(ExclusiveArcTarget.Aggregate, ValidTargetId, ValidActionId, true, false, ValidActor).IsSuccess); + + var result = template.AddItem(ExclusiveArcTarget.Entity, ValidTargetId, ValidActionId, true, false, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(2, template.Items.Count); + } + [Fact] public void AddItem_RaisesPermissionTemplateMutatedEvent() { @@ -259,47 +416,140 @@ public void AddItem_RaisesPermissionTemplateMutatedEvent() #endregion - #region RemoveItem + #region Retirada de ítems (ADR-0164) + + // El borrado FÍSICO de ítems se retiró del dominio: `RemoveItem` ya no existe y la colección no + // puede encoger. Estas pruebas fijan las cuatro afirmaciones que sostienen la política. + + [Fact] + public void ElDominioNoOfreceBorradoFisicoDeItems() + { + // La garantía es la AUSENCIA del método (ADR-0164 §2.1: «una capacidad que no debe usarse y + // sigue disponible acaba usándose»). Se comprueba por reflexión porque una llamada que no + // compila no puede escribirse como prueba: sin esto, nada impediría reintroducirlo mañana. + var metodos = typeof(PermissionTemplate).GetMethods().Select(m => m.Name).ToList(); + + Assert.DoesNotContain("RemoveItem", metodos); + Assert.DoesNotContain("DeleteItem", metodos); + } [Fact] - public void RemoveItem_WhenItemExists_ReturnsSuccess() + public void DeactivateItem_NoEncogeLaColeccion_LaConcesionSigueAhiRetirada() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); - var result = template.RemoveItem(itemId, ValidActor); + var result = template.DeactivateItem(itemId, ValidActor); Assert.True(result.IsSuccess); - Assert.Empty(template.Items); + Assert.Single(template.Items); + Assert.False(template.Items.First().IsActive); + // Y la separación que evita la brecha: retirado deja de estar entre lo que la plantilla concede. + Assert.Empty(template.ActiveItems); } [Fact] - public void RemoveItem_WhenNotFound_ReturnsFailure() + public void CicloRetirarYReactivar_DevuelveLaConcesionAlServicio_SobreLaMismaFila() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; - var fakeId = IdValueObject.Create(); + template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + var itemId = template.Items.First().GetId(); - var result = template.RemoveItem(fakeId, ValidActor); + Assert.True(template.DeactivateItem(itemId, ValidActor).IsSuccess); + Assert.True(template.ActivateItem(itemId, ValidActor).IsSuccess); + + Assert.Single(template.Items); + Assert.Single(template.ActiveItems); + Assert.Equal(itemId.GetValue(), template.Items.First().GetId().GetValue(), + comparer: EqualityComparer.Default); + } + + [Fact] + public void AddItem_SobreLaClaveDeUnItemRetirado_EsConflicto_LaClaveNoSeLibera() + { + // ADR-0164 §2.3: la clave natural no se reutiliza. El alta responde conflicto de DOMINIO + // legible —no un error de índice— y nombra la retirada, para que el cliente pueda proponer + // reactivar en vez de repetir el alta. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + template.DeactivateItem(template.Items.First().GetId(), ValidActor); + + var result = template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Common.NotFound, result.Error); + Assert.Contains(DomainErrors.Authorization.TemplateItemTargetRetired, result.Error); + Assert.Single(template.Items); } [Fact] - public void RemoveItem_WhenNotDraft_ReturnsFailure() + public void AddItem_ConLaMismaClaveEnInstanciasDistintas_DetectaElDuplicado() { + // Contraprueba de la comparación por VALOR. IdValueObject no sobrecarga ==, así que la guarda + // anterior (i.TargetId == targetId) comparaba REFERENCIAS: sobre una plantilla rehidratada + // desde la base —donde cada ítem trae sus propias instancias— el duplicado pasaba de largo y + // el alta reventaba contra el índice único como 23505. Misma clase de bug que G-043. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + var targetId = IdValueObject.Create(); + var actionGuid = Guid.NewGuid(); + + Assert.True(template.AddItem( + ValidTargetType, IdValueObject.Load(targetId.GetValue()), ActionId.Load(actionGuid), + true, false, ValidActor).IsSuccess); + + var result = template.AddItem( + ValidTargetType, IdValueObject.Load(targetId.GetValue()), ActionId.Load(actionGuid), + true, false, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.TemplateItemTargetAlreadyExists, result.Error); + } + + [Fact] + public void Publish_ConTodosLosItemsRetirados_Falla() + { + // Puerta que abre el borrado lógico: antes, retirar el último ítem lo borraba de la lista y + // `Items.Any()` bastaba para rechazar la publicación. Ahora la fila sobrevive, así que la + // guarda tiene que mirar lo VIGENTE; si no, se publicaría un contrato que no concede nada y + // sería asignable a perfiles. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + template.DeactivateItem(template.Items.First().GetId(), ValidActor); + + var result = template.Publish(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.TemplateItemsRequired, result.Error); + } + + [Fact] + public void DeactivateItem_CuandoNoEsBorrador_Falla() + { + // Publicada, la plantilla se congela: retirar una concesión de una plantilla vigente exige + // una versión nueva. Es lo que hace que el histórico viva en la cadena de versiones y no + // necesite un mecanismo propio. var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); - var result = template.RemoveItem(itemId, ValidActor); + var result = template.DeactivateItem(itemId, ValidActor); Assert.True(result.IsFailure); Assert.Contains(DomainErrors.Authorization.TemplateNotDraft, result.Error); } + [Fact] + public void DeactivateItem_CuandoElItemNoExiste_Falla() + { + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + + var result = template.DeactivateItem(IdValueObject.Create(), ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Common.NotFound, result.Error); + } + #endregion #region SetItemAllow @@ -309,7 +559,7 @@ public void SetItemAllow_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, false, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemAllow(itemId, ValidActor); @@ -333,7 +583,7 @@ public void SetItemAllow_WhenNotDraft_ReturnsFailure() var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemAllow(itemId, ValidActor); @@ -349,7 +599,7 @@ public void SetItemDeny_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, false, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemDeny(itemId, ValidActor); @@ -362,7 +612,7 @@ public void SetItemDeny_WhenNotDraft_ReturnsFailure() var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemDeny(itemId, ValidActor); @@ -378,7 +628,7 @@ public void SetItemNeutral_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemNeutral(itemId, ValidActor); @@ -394,7 +644,7 @@ public void ActivateItem_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.ActivateItem(itemId, ValidActor); @@ -407,7 +657,7 @@ public void ActivateItem_WhenNotDraft_ReturnsFailure() var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.ActivateItem(itemId, ValidActor); @@ -423,7 +673,7 @@ public void DeactivateItem_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.DeactivateItem(itemId, ValidActor); @@ -436,7 +686,7 @@ public void DeactivateItem_WhenNotDraft_ReturnsFailure() var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.DeactivateItem(itemId, ValidActor); diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/AppConfiguration/AppConfigurationTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/AppConfiguration/AppConfigurationTests.cs index b6f7888a..5c67ab7b 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Configuration/AppConfiguration/AppConfigurationTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/AppConfiguration/AppConfigurationTests.cs @@ -338,4 +338,82 @@ public void Update_RaisesAppConfigUpdatedEvent() } #endregion + + // ========================================================================= + #region Delete (borrado LÓGICO — estado terminal, la fila nunca se pierde) + // ========================================================================= + + [Fact] + public void Delete_DesdeBorrador_TransicionaADeleted() + { + // Borrar no es un paso del ciclo de vida sino una retirada: debe poder ejercerse sobre un + // borrador que jamás se publicó (Archive, en cambio, exige Published). + var config = MakeDraft(); + + var result = config.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, config.Status); + } + + [Fact] + public void Delete_DesdePublicado_TransicionaADeleted() + { + var config = MakePublished(); + + var result = config.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, config.Status); + } + + [Fact] + public void Delete_DesdeArchivado_TransicionaADeleted() + { + var config = MakePublished(); + config.Archive(ValidActor); + + var result = config.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, config.Status); + } + + [Fact] + public void Delete_DosVeces_ReturnsFailure() + { + var config = MakePublished(); + Assert.True(config.Delete(ValidActor).IsSuccess); + + var second = config.Delete(ValidActor); + + Assert.True(second.IsFailure); + Assert.Contains(DomainErrors.Configuration.AppConfigAlreadyDeleted, second.Error); + } + + [Fact] + public void Delete_CongelaLaConfiguracion() + { + // Estado terminal: ni se publica, ni se archiva, ni se actualiza una configuración borrada. + var config = MakeDraft(); + config.Delete(ValidActor); + + Assert.True(config.Publish(ValidActor).IsFailure); + Assert.True(config.Archive(ValidActor).IsFailure); + Assert.True(config.Update(ConfigurationValue.Create("v2"), Description.Create("d"), ValidActor).IsFailure); + Assert.Equal(ConfigStatus.Deleted, config.Status); + } + + [Fact] + public void Delete_RaisesAppConfigDeletedEvent() + { + var config = MakePublished(); + + config.Delete(ValidActor); + + var events = config.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is AppConfigDeletedEvent); + } + + #endregion } diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/FeatureFlag/FeatureFlagTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/FeatureFlag/FeatureFlagTests.cs index 7e7ec455..3a9e0f66 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Configuration/FeatureFlag/FeatureFlagTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/FeatureFlag/FeatureFlagTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Domain.Test.Configuration.FeatureFlag; using Ums.Domain.Configuration.FeatureFlag; @@ -436,3 +437,5 @@ public void Evaluate_WhenArchived_DoesNotAppendToLog() #endregion } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/IdpConfiguration/IdpConfigurationSelectorTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/IdpConfiguration/IdpConfigurationSelectorTests.cs new file mode 100644 index 00000000..ae6b19b0 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/IdpConfiguration/IdpConfigurationSelectorTests.cs @@ -0,0 +1,176 @@ +namespace Ums.Domain.Test.Configuration.IdpConfiguration; + +using Ums.Domain.Configuration.IdpConfiguration; +using Xunit; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; + +/// +/// FR-042 (ADR-UMS-097 §2.1/§4): la regla pura de selección de IdP por prioridad/suite/dominio, +/// con desempate por versión. Fuente única compartida por el login y el motor de consulta OIDC. +/// +public class IdpConfigurationSelectorTests +{ + private static readonly ActorId Actor = ActorId.Create("test"); + private static readonly Guid TenantGuid = Guid.NewGuid(); + + // ── Sin candidatos ────────────────────────────────────────────────────────── + + [Fact] + public void Select_WhenNoConfigurations_ReturnsNull() + { + var selection = IdpConfigurationSelector.Select( + new List(), systemSuiteId: null, emailDomain: null, providerType: null); + + Assert.Null(selection); + } + + [Fact] + public void Select_WhenOnlyDraftConfigurations_ReturnsNull() + { + // Draft (no activada) no es candidata. + var draft = BuildConfig(ProviderType.Keycloak, priority: 1, activate: false); + + var selection = IdpConfigurationSelector.Select( + new[] { draft }, systemSuiteId: null, emailDomain: null, providerType: null); + + Assert.Null(selection); + } + + // ── Prioridad ──────────────────────────────────────────────────────────────── + + [Fact] + public void Select_ByPriority_SelectsLowestPriorityNumber() + { + var low = BuildConfig(ProviderType.AzureAd, priority: 5); + var high = BuildConfig(ProviderType.Keycloak, priority: 10); + + var selection = IdpConfigurationSelector.Select( + new[] { high, low }, systemSuiteId: null, emailDomain: null, providerType: null); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.AzureAd, selection!.Value.Configuration.ProviderType); + Assert.False(selection.Value.DomainMatched); + } + + [Fact] + public void Select_PriorityTie_SelectsHighestVersion() + { + var versionOne = BuildConfig(ProviderType.AzureAd, priority: 1); // Version 1 + var versionTwo = BuildConfig(ProviderType.Keycloak, priority: 1, bumpVersion: 1); // Version 2 + + var selection = IdpConfigurationSelector.Select( + new[] { versionOne, versionTwo }, systemSuiteId: null, emailDomain: null, providerType: null); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.Keycloak, selection!.Value.Configuration.ProviderType); + } + + // ── Filtro por suite ───────────────────────────────────────────────────────── + + [Fact] + public void Select_SuiteFilter_OnlyConsidersMatchingSuite() + { + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + var inSuiteA = BuildConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA); + var inSuiteB = BuildConfig(ProviderType.Keycloak, priority: 99, suiteId: suiteB); + + var selection = IdpConfigurationSelector.Select( + new[] { inSuiteA, inSuiteB }, systemSuiteId: suiteB, emailDomain: null, providerType: null); + + Assert.NotNull(selection); + // La de suiteA tiene mejor prioridad pero se filtra fuera; gana la única de suiteB. + Assert.Equal(ProviderType.Keycloak, selection!.Value.Configuration.ProviderType); + } + + [Fact] + public void Select_SuiteFilter_WhenNoMatch_ReturnsNull() + { + var config = BuildConfig(ProviderType.AzureAd, priority: 1, suiteId: Guid.NewGuid()); + + var selection = IdpConfigurationSelector.Select( + new[] { config }, systemSuiteId: Guid.NewGuid(), emailDomain: null, providerType: null); + + Assert.Null(selection); + } + + // ── Routing por dominio ────────────────────────────────────────────────────── + + [Fact] + public void Select_DomainMatch_PrefersDomainMatchedOverPriority() + { + var domainConfig = BuildConfig(ProviderType.AzureAd, priority: 10, domainHints: new[] { "acme.com" }); + var betterPriority = BuildConfig(ProviderType.Keycloak, priority: 1); + + var selection = IdpConfigurationSelector.Select( + new[] { betterPriority, domainConfig }, systemSuiteId: null, emailDomain: "user@acme.com", providerType: null); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.AzureAd, selection!.Value.Configuration.ProviderType); + Assert.True(selection.Value.DomainMatched); + } + + [Fact] + public void Select_WhenDomainMatchesNoHint_FallsBackToPrioritySelection() + { + var domainConfig = BuildConfig(ProviderType.AzureAd, priority: 10, domainHints: new[] { "acme.com" }); + var betterPriority = BuildConfig(ProviderType.Keycloak, priority: 1); + + var selection = IdpConfigurationSelector.Select( + new[] { betterPriority, domainConfig }, systemSuiteId: null, emailDomain: "user@other.com", providerType: null); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.Keycloak, selection!.Value.Configuration.ProviderType); + Assert.False(selection.Value.DomainMatched); + } + + // ── Filtro por tipo de proveedor ───────────────────────────────────────────── + + [Fact] + public void Select_ProviderTypeFilter_OnlyConsidersMatchingType() + { + var azure = BuildConfig(ProviderType.AzureAd, priority: 1); + var keycloak = BuildConfig(ProviderType.Keycloak, priority: 99); + + var selection = IdpConfigurationSelector.Select( + new[] { azure, keycloak }, systemSuiteId: null, emailDomain: null, providerType: ProviderType.Keycloak.Name); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.Keycloak, selection!.Value.Configuration.ProviderType); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private static IdpConfigurationAggregate BuildConfig( + ProviderType providerType, + int priority, + Guid? suiteId = null, + string[]? domainHints = null, + int bumpVersion = 0, + bool activate = true) + { + var config = IdpConfigurationAggregate.Create( + TenantId.Load(TenantGuid), + suiteId.HasValue ? SystemSuiteId.Load(suiteId.Value) : SystemSuiteId.Create(), + providerType, + domainHints ?? Array.Empty(), + "{\"issuer\":\"https://idp.example\"}", + "vault/secret/idp", + priority, + null, + Actor).Value; + + // Update() incrementa Version y solo se permite en Draft/Inactive: se usa para el desempate. + for (var i = 0; i < bumpVersion; i++) + { + config.Update("{\"issuer\":\"https://idp.example/v\"}", "vault/secret/idp", domainHints ?? Array.Empty(), Actor); + } + + if (activate) + { + config.Activate(Actor); + } + + return config; + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterIdEqualityTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterIdEqualityTests.cs new file mode 100644 index 00000000..b3c8baf5 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterIdEqualityTests.cs @@ -0,0 +1,125 @@ +namespace Ums.Domain.Test.Configuration.Parameter; + +using Ums.Domain.Configuration.Parameter; +using Xunit; + +/// +/// G-055: los identificadores de los agregados de Parameter son value objects de +/// identidad. Estas pruebas fijan que su igualdad es por valor (mismo Guid ⇒ iguales, +/// hash consistente) y que sirven en HashSet/Dictionary, no por referencia. +/// +public class ParameterIdEqualityTests +{ + private static readonly Guid GuidA = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid GuidB = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + // ----- ParameterDefinitionId ----- + + [Fact] + public void ParameterDefinitionId_SameValue_AreEqualAndShareHash() + { + var a = ParameterDefinitionId.Load(GuidA); + var b = ParameterDefinitionId.Create(GuidA); + + Assert.True(a.Equals(b)); + Assert.True(a.Equals((object)b)); + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.False(ReferenceEquals(a, b)); + } + + [Fact] + public void ParameterDefinitionId_DifferentValue_AreNotEqual() + { + var a = ParameterDefinitionId.Load(GuidA); + var b = ParameterDefinitionId.Load(GuidB); + + Assert.NotEqual(a, b); + Assert.False(a.Equals(b)); + } + + [Fact] + public void ParameterDefinitionId_WorksAsHashSetElementAndDictionaryKey() + { + var a1 = ParameterDefinitionId.Load(GuidA); + var a2 = ParameterDefinitionId.Load(GuidA); + var b = ParameterDefinitionId.Load(GuidB); + + var set = new HashSet { a1, a2, b }; + Assert.Equal(2, set.Count); + Assert.Contains(a2, set); + + var map = new Dictionary { [a1] = "uno" }; + Assert.True(map.TryGetValue(a2, out var found)); + Assert.Equal("uno", found); + } + + [Fact] + public void ParameterDefinitionId_NullAndOtherType_AreNotEqual() + { + var a = ParameterDefinitionId.Load(GuidA); + + Assert.False(a.Equals(null)); + Assert.False(a.Equals("11111111-1111-1111-1111-111111111111")); + } + + // ----- ParameterGlobalValueId ----- + + [Fact] + public void ParameterGlobalValueId_SameValue_AreEqualAndShareHash() + { + var a = ParameterGlobalValueId.Load(GuidA); + var b = ParameterGlobalValueId.Create(GuidA); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ParameterGlobalValueId_DifferentValue_AreNotEqual() + { + Assert.NotEqual(ParameterGlobalValueId.Load(GuidA), ParameterGlobalValueId.Load(GuidB)); + } + + [Fact] + public void ParameterGlobalValueId_WorksAsDictionaryKey() + { + var map = new Dictionary + { + [ParameterGlobalValueId.Load(GuidA)] = 7, + }; + + Assert.True(map.ContainsKey(ParameterGlobalValueId.Load(GuidA))); + Assert.False(map.ContainsKey(ParameterGlobalValueId.Load(GuidB))); + } + + // ----- ParameterTenantValueId ----- + + [Fact] + public void ParameterTenantValueId_SameValue_AreEqualAndShareHash() + { + var a = ParameterTenantValueId.Load(GuidA); + var b = ParameterTenantValueId.Create(GuidA); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ParameterTenantValueId_DifferentValue_AreNotEqual() + { + Assert.NotEqual(ParameterTenantValueId.Load(GuidA), ParameterTenantValueId.Load(GuidB)); + } + + [Fact] + public void ParameterTenantValueId_HashSetDeduplicatesByValue() + { + var set = new HashSet + { + ParameterTenantValueId.Load(GuidA), + ParameterTenantValueId.Load(GuidA), + }; + + Assert.Single(set); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterSoftDeleteTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterSoftDeleteTests.cs new file mode 100644 index 00000000..db142b86 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterSoftDeleteTests.cs @@ -0,0 +1,153 @@ +namespace Ums.Domain.Test.Configuration.Parameter; + +using Ums.Domain.Configuration.Parameter; +using Ums.Domain.Configuration.Parameter.ValueObjects; +using Xunit; + +/// +/// Invariantes de dominio del borrado LÓGICO del catálogo de parámetros. +/// +/// La política del propietario es que el borrado físico no existe: eliminar es una transición de +/// estado, y no se puede eliminar algo que todavía tiene referencias reales. Estas pruebas fijan +/// las dos mitades de esa regla en el agregado, donde vive la invariante. +/// +public class ParameterSoftDeleteTests +{ + private static readonly ActorId ValidActor = ActorId.Create("user-001"); + private static readonly TenantId ValidTenantId = TenantId.Load(Guid.NewGuid()); + private static readonly IdValueObject ValidDefinitionId = IdValueObject.Create(); + + private static ParameterDefinition MakeDefinition() => + ParameterDefinition.Create( + Code.Create("PARAM-001"), + ParameterName.Create("Parameter 1"), + Description.Create("Test parameter"), + ParameterDataType.String, + DefaultValue.Create("default"), + ParameterScope.GlobalAndTenant, + isActive: true, + isMandatory: false, + displayOrder: 1, + ValidActor).Value; + + // ── ParameterDefinition ────────────────────────────────────────────────── + + [Fact] + public void Definition_Delete_SinDependientesVivos_MarcaEliminadaYDesactiva() + { + var definition = MakeDefinition(); + + var result = definition.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.True(definition.IsDeleted); + Assert.False(definition.IsActive); + } + + [Fact] + public void Definition_Delete_ConValorGlobalVivo_ReturnsFailure() + { + var definition = MakeDefinition(); + + var result = definition.Delete(ValidActor, liveGlobalValueCount: 1); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterHasActiveValues, result.Error); + Assert.False(definition.IsDeleted); + } + + [Fact] + public void Definition_Delete_ConValorDeInquilinoVivo_ReturnsFailure() + { + var definition = MakeDefinition(); + + var result = definition.Delete(ValidActor, liveTenantValueCount: 3); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterHasActiveValues, result.Error); + Assert.False(definition.IsDeleted); + } + + [Fact] + public void Definition_Delete_DosVeces_ReturnsFailure() + { + var definition = MakeDefinition(); + Assert.True(definition.Delete(ValidActor).IsSuccess); + + var second = definition.Delete(ValidActor); + + Assert.True(second.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterAlreadyDeleted, second.Error); + } + + // ── ParameterGlobalValue / ParameterTenantValue ────────────────────────── + + [Fact] + public void GlobalValue_Delete_DesdeBorrador_PasaAEstadoTerminal() + { + var value = ParameterGlobalValue.Create( + ValidDefinitionId, EffectiveValue.Create("hello"), ParameterDataType.String, ValidActor).Value; + + var result = value.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, value.Status); + } + + [Fact] + public void GlobalValue_Delete_DesdeArchivado_TambienEsPosible() + { + // Archivar no libera la referencia; eliminar sí. Por eso Delete admite cualquier estado vivo. + var value = ParameterGlobalValue.Create( + ValidDefinitionId, EffectiveValue.Create("hello"), ParameterDataType.String, ValidActor).Value; + value.Publish(ValidActor); + value.Archive(ValidActor); + + var result = value.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, value.Status); + } + + [Fact] + public void GlobalValue_Delete_DosVeces_ReturnsFailure() + { + var value = ParameterGlobalValue.Create( + ValidDefinitionId, EffectiveValue.Create("hello"), ParameterDataType.String, ValidActor).Value; + Assert.True(value.Delete(ValidActor).IsSuccess); + + Assert.True(value.Delete(ValidActor).IsFailure); + } + + [Fact] + public void TenantValue_Delete_PasaAEstadoTerminal() + { + var value = ParameterTenantValue.Create( + ValidTenantId, + ValidDefinitionId, + OverrideValue.Create("true"), + ParameterDataType.Boolean, + ParameterScope.GlobalAndTenant, + ValidActor).Value; + + var result = value.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, value.Status); + } + + [Fact] + public void TenantValue_Delete_DosVeces_ReturnsFailure() + { + var value = ParameterTenantValue.Create( + ValidTenantId, + ValidDefinitionId, + OverrideValue.Create("true"), + ParameterDataType.Boolean, + ParameterScope.GlobalAndTenant, + ValidActor).Value; + Assert.True(value.Delete(ValidActor).IsSuccess); + + Assert.True(value.Delete(ValidActor).IsFailure); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/IGA/RiskScoreTests.cs b/src/apps/ums.api/Ums.Domain.Test/IGA/RiskScoreTests.cs new file mode 100644 index 00000000..58cc8679 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/IGA/RiskScoreTests.cs @@ -0,0 +1,37 @@ +namespace Ums.Domain.Test.IGA; + +using Xunit; + +/// Pruebas del objeto de valor (rango [0, 100], FR-061). +public class RiskScoreTests +{ + [Theory] + [InlineData(0)] + [InlineData(50)] + [InlineData(100)] + public void Create_WithinRange_ReturnsSuccess(int value) + { + var result = RiskScore.Create(value); + + Assert.True(result.IsSuccess); + Assert.Equal(value, result.Value.GetValue()); + } + + [Theory] + [InlineData(-1)] + [InlineData(101)] + [InlineData(1000)] + public void Create_OutOfRange_ReturnsFailure(int value) + { + var result = RiskScore.Create(value); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.RiskScoreOutOfRange, result.Error); + } + + [Fact] + public void RiskScore_WithSameValue_AreEqual() + { + Assert.Equal(RiskScore.Create(42).Value, RiskScore.Create(42).Value); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/IGA/RoleMaturityStatusTests.cs b/src/apps/ums.api/Ums.Domain.Test/IGA/RoleMaturityStatusTests.cs new file mode 100644 index 00000000..796b163e --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/IGA/RoleMaturityStatusTests.cs @@ -0,0 +1,239 @@ +namespace Ums.Domain.Test.IGA; + +using Ums.Domain.IGA.RoleMaturityStatus; +using Xunit; + +/// +/// Pruebas de dominio del agregado (ADR-UMS-093, IGA). +/// +/// Cobertura por invariante: +/// INV-RMS1 — PerformanceScore ∈ [0, 5] (límites y fuera de rango). +/// INV-RMS2 — actualización de nivel debe apuntar a un nivel distinto. +/// INV-RMS3 — elegibilidad: sin incidencias, desempeño ≥ 3.0, tiempo mínimo en nivel; +/// Principal no promocionable. +/// Se verifica además la emisión de eventos de dominio por transición. +/// +public class RoleMaturityStatusTests +{ + private static readonly TenantId Tenant = TenantId.Create(); + private static readonly UserId User = UserId.Create(); + private static readonly RoleId Role = RoleId.Create(); + private static readonly ActorId Actor = ActorId.Create("actor-001"); + private static readonly DateTime Now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static RoleMaturityStatus Make( + RoleMaturityLevel level = RoleMaturityLevel.Junior, + DateTime? assignedAt = null) + => RoleMaturityStatus.Create(Tenant, User, Role, level, assignedAt ?? Now, Actor).Value; + + // ── Create ─────────────────────────────────────────────────────────────── + + [Fact] + public void Create_WithValidData_ReturnsSuccess() + { + var result = RoleMaturityStatus.Create(Tenant, User, Role, RoleMaturityLevel.Junior, Now, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RoleMaturityLevel.Junior, result.Value.CurrentMaturityLevel); + Assert.True(result.Value.HasNoComplianceIssues); + Assert.Equal(0m, result.Value.PerformanceScore); + Assert.Null(result.Value.NextEligibleMaturityLevel); + } + + [Fact] + public void Create_RaisesRoleMaturityStatusCreatedEvent() + { + var status = Make(); + + var events = status.DomainEvents.GetUncommittedChanges(); + Assert.Contains(events, e => e is RoleMaturityStatusCreatedEvent); + } + + // ── INV-RMS1 — PerformanceScore ∈ [0, 5] ────────────────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(2.5)] + [InlineData(5)] + public void UpdatePerformanceScore_WithinRange_ReturnsSuccess(double score) + { + var status = Make(); + + var result = status.UpdatePerformanceScore((decimal)score, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal((decimal)score, status.PerformanceScore); + } + + [Theory] + [InlineData(-0.1)] + [InlineData(5.1)] + [InlineData(100)] + public void UpdatePerformanceScore_OutOfRange_ReturnsFailure(double score) + { + var status = Make(); + + var result = status.UpdatePerformanceScore((decimal)score, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidPerformanceScore, result.Error); + } + + // ── INV-RMS2 — nivel distinto ───────────────────────────────────────────── + + [Fact] + public void UpdateMaturityLevel_ToDifferentLevel_ReturnsSuccess() + { + var status = Make(RoleMaturityLevel.Junior); + + var result = status.UpdateMaturityLevel(RoleMaturityLevel.Intermediate, Now, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RoleMaturityLevel.Intermediate, status.CurrentMaturityLevel); + Assert.Contains(status.DomainEvents.GetUncommittedChanges(), e => e is RoleMaturityLevelChangedEvent); + } + + [Fact] + public void UpdateMaturityLevel_ToSameLevel_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Junior); + + var result = status.UpdateMaturityLevel(RoleMaturityLevel.Junior, Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.MaturityLevelUnchanged, result.Error); + } + + [Fact] + public void UpdateMaturityLevel_ResetsEligibilityMarkers() + { + // Elegible primero, luego un cambio de nivel debe limpiar los marcadores. + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-7)); + status.UpdatePerformanceScore(4.0m, Actor); + status.EvaluateEligibility(Now, Actor); + Assert.NotNull(status.NextEligibleMaturityLevel); + + status.UpdateMaturityLevel(RoleMaturityLevel.Intermediate, Now, Actor); + + Assert.Null(status.NextEligibleMaturityLevel); + Assert.Null(status.EligibleForPromotionAt); + } + + // ── INV-RMS3 — elegibilidad ─────────────────────────────────────────────── + + [Fact] + public void EvaluateEligibility_WhenAllConditionsMet_ReturnsSuccess() + { + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-7)); // > 6 meses + status.UpdatePerformanceScore(3.0m, Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RoleMaturityLevel.Intermediate, status.NextEligibleMaturityLevel); + Assert.Equal(Now, status.EligibleForPromotionAt); + Assert.Contains(status.DomainEvents.GetUncommittedChanges(), e => e is RoleMaturityEligibilityConfirmedEvent); + } + + [Fact] + public void EvaluateEligibility_WithActiveComplianceIssue_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-7)); + status.UpdatePerformanceScore(4.0m, Actor); + status.MarkComplianceIssue(TextValueObject.Create("sancion abierta"), Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.ComplianceIssuesBlockPromotion, result.Error); + } + + [Fact] + public void EvaluateEligibility_WithInsufficientScore_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-7)); + status.UpdatePerformanceScore(2.9m, Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InsufficientPerformanceScore, result.Error); + } + + [Fact] + public void EvaluateEligibility_WithInsufficientTimeInLevel_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-2)); // < 6 meses + status.UpdatePerformanceScore(4.0m, Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InsufficientTimeInLevel, result.Error); + } + + [Theory] + [InlineData(2, RoleMaturityLevel.Intermediate, 12)] // Intermediate → Senior: 12m + [InlineData(3, RoleMaturityLevel.Senior, 18)] // Senior → Lead: 18m + [InlineData(4, RoleMaturityLevel.Lead, 24)] // Lead → Principal: 24m + public void EvaluateEligibility_RespectsPerLevelTimeThreshold(int _, RoleMaturityLevel level, int requiredMonths) + { + var justUnder = Make(level, Now.AddMonths(-(requiredMonths - 1))); + justUnder.UpdatePerformanceScore(4.0m, Actor); + Assert.True(justUnder.EvaluateEligibility(Now, Actor).IsFailure); + + var justOver = Make(level, Now.AddMonths(-(requiredMonths + 1))); + justOver.UpdatePerformanceScore(4.0m, Actor); + Assert.True(justOver.EvaluateEligibility(Now, Actor).IsSuccess); + } + + [Fact] + public void EvaluateEligibility_WhenPrincipal_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Principal, Now.AddMonths(-60)); + status.UpdatePerformanceScore(5.0m, Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.MaturityLevelAlreadyMax, result.Error); + } + + // ── Cumplimiento: marcar / resolver ─────────────────────────────────────── + + [Fact] + public void MarkComplianceIssue_SetsBlockAndRaisesEvent() + { + var status = Make(); + + var result = status.MarkComplianceIssue(TextValueObject.Create("documento vencido"), Actor); + + Assert.True(result.IsSuccess); + Assert.False(status.HasNoComplianceIssues); + Assert.Contains(status.DomainEvents.GetUncommittedChanges(), e => e is RoleMaturityComplianceIssueMarkedEvent); + } + + [Fact] + public void ResolveComplianceIssue_ClearsBlockAndRaisesEvent() + { + var status = Make(); + status.MarkComplianceIssue(TextValueObject.Create("documento vencido"), Actor); + + var result = status.ResolveComplianceIssue(Actor); + + Assert.True(result.IsSuccess); + Assert.True(status.HasNoComplianceIssues); + Assert.Contains(status.DomainEvents.GetUncommittedChanges(), e => e is RoleMaturityComplianceIssueResolvedEvent); + } + + [Fact] + public void MarkComplianceIssue_WithEmptyFactor_ReturnsFailure() + { + var status = Make(); + + var result = status.MarkComplianceIssue(TextValueObject.Create(""), Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.BlockingFactorRequired, result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/IGA/RolePromotionRequestTests.cs b/src/apps/ums.api/Ums.Domain.Test/IGA/RolePromotionRequestTests.cs new file mode 100644 index 00000000..d29cbe68 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/IGA/RolePromotionRequestTests.cs @@ -0,0 +1,508 @@ +namespace Ums.Domain.Test.IGA; + +using Ums.Domain.IGA.RolePromotionRequest; +using Xunit; + +/// +/// Pruebas de dominio del agregado (ADR-UMS-093, IGA). +/// +/// Cobertura: una prueba por transición de la máquina de estados Y por guarda: +/// – estado origen inválido (INV-RPR1) y saltos de estado ilegales; +/// – RiskScore inmutable tras congelar (INV-RPR2); +/// – segregación de funciones violada (INV-RPR3); +/// – elegibilidad fail-closed (INV-RPR4); +/// – sólo Approved→Execute y sólo Executed→Verify (INV-RPR5); +/// – enrutamiento por umbral de riesgo (alto ⇒ revisión de seguridad). +/// Cada transición emite su evento de dominio, también verificado. +/// +public class RolePromotionRequestTests +{ + private static readonly TenantId Tenant = TenantId.Create(); + private static readonly UserId Target = UserId.Create(); + private static readonly UserId Requester = UserId.Create(); + private static readonly UserId Approver = UserId.Create(); + private static readonly UserId Reviewer = UserId.Create(); + private static readonly UserId Executor = UserId.Create(); + private static readonly UserId Verifier = UserId.Create(); + private static readonly RoleId CurrentRole = RoleId.Create(); + private static readonly RoleId TargetRole = RoleId.Create(); + private static readonly ActorId Actor = ActorId.Create("actor-001"); + + private const int LowRisk = 30; + private const int HighRisk = 85; + + // ── Helpers ──────────────────────────────────────────────────────────────── + + private static RolePromotionRequest MakeDraft() + => RolePromotionRequest.Create(Tenant, Target, Requester, CurrentRole, TargetRole, Actor).Value; + + private static RolePromotionRequest MakeSubmitted(int risk = LowRisk) + { + var r = MakeDraft(); + r.Submit(RiskScore.Create(risk).Value, Actor); + return r; + } + + private static RolePromotionRequest MakePendingManagerApproval(int risk = LowRisk) + { + var r = MakeSubmitted(risk); + r.ConfirmEligibility(true, Actor); + return r; + } + + private static RolePromotionRequest MakeApprovedLowRisk() + { + var r = MakePendingManagerApproval(LowRisk); + r.ManagerApprove(Approver, Actor); + return r; + } + + private static RolePromotionRequest MakeApprovedHighRisk() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); // → PendingSecurityReview + r.SecurityApprove(Reviewer, Actor); // → Approved (revisor registrado en Props) + return r; + } + + private static RolePromotionRequest MakeExecuted() + { + var r = MakeApprovedLowRisk(); + r.Execute(Executor, Actor); + return r; + } + + // ── Create + SoD (INV-RPR3) ────────────────────────────────────────────── + + [Fact] + public void Create_WithValidData_StartsInDraft() + { + var result = RolePromotionRequest.Create(Tenant, Target, Requester, CurrentRole, TargetRole, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Draft, result.Value.Status); + Assert.Null(result.Value.RiskScore); + Assert.Contains(result.Value.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionRequestedEvent); + } + + [Fact] + public void Create_WhenRequesterEqualsTarget_ReturnsFailure() + { + var result = RolePromotionRequest.Create(Tenant, Target, Target, CurrentRole, TargetRole, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SelfPromotionNotAllowed, result.Error); + } + + [Fact] + public void Create_WhenTargetRoleEqualsCurrentRole_ReturnsFailure() + { + var result = RolePromotionRequest.Create(Tenant, Target, Requester, CurrentRole, CurrentRole, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SameRolePromotion, result.Error); + } + + // ── Submit (Draft → PendingEligibilityCheck) + RiskScore congelado ──────── + + [Fact] + public void Submit_FromDraft_FreezesRiskScoreAndTransitions() + { + var r = MakeDraft(); + + var result = r.Submit(RiskScore.Create(55).Value, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.PendingEligibilityCheck, r.Status); + Assert.Equal(55, r.RiskScore!.GetValue()); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionSubmittedEvent); + } + + [Fact] + public void Submit_WhenNotDraft_ReturnsInvalidStateTransition() + { + var r = MakePendingManagerApproval(); + + var result = r.Submit(RiskScore.Create(10).Value, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + [Fact] + public void Submit_WhenRiskScoreAlreadyFrozen_ReturnsFailure() + { + // INV-RPR2: reintentar Submit sobre una solicitud ya congelada falla. + var r = MakeSubmitted(40); + + var result = r.Submit(RiskScore.Create(90).Value, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.RiskScoreAlreadyFrozen, result.Error); + Assert.Equal(40, r.RiskScore!.GetValue()); // valor original intacto + } + + [Fact] + public void RiskScore_RemainsImmutableThroughLifecycle() + { + var r = MakeSubmitted(65); + var frozen = r.RiskScore!.GetValue(); + + r.ConfirmEligibility(true, Actor); + r.ManagerApprove(Approver, Actor); + + Assert.Equal(frozen, r.RiskScore!.GetValue()); + } + + // ── ConfirmEligibility (INV-RPR4 fail-closed) ───────────────────────────── + + [Fact] + public void ConfirmEligibility_WhenEligible_TransitionsToPendingManagerApproval() + { + var r = MakeSubmitted(); + + var result = r.ConfirmEligibility(true, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.PendingManagerApproval, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionEligibilityConfirmedEvent); + } + + [Fact] + public void ConfirmEligibility_WhenNotEligible_TransitionsToRejected() + { + var r = MakeSubmitted(); + + var result = r.ConfirmEligibility(false, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Rejected, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionRejectedEvent); + } + + [Fact] + public void ConfirmEligibility_WhenNotInEligibilityCheck_ReturnsInvalidStateTransition() + { + var r = MakeDraft(); + + var result = r.ConfirmEligibility(true, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + // ── ManagerApprove (enrutamiento por riesgo + SoD) ──────────────────────── + + [Fact] + public void ManagerApprove_WithLowRisk_TransitionsToApproved() + { + var r = MakePendingManagerApproval(LowRisk); + + var result = r.ManagerApprove(Approver, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Approved, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionManagerApprovedEvent); + } + + [Fact] + public void ManagerApprove_WithHighRisk_TransitionsToPendingSecurityReview() + { + var r = MakePendingManagerApproval(HighRisk); + + var result = r.ManagerApprove(Approver, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.PendingSecurityReview, r.Status); + } + + [Fact] + public void ManagerApprove_WhenApproverIsTarget_ReturnsSoDViolation() + { + var r = MakePendingManagerApproval(); + + var result = r.ManagerApprove(Target, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void ManagerApprove_WhenApproverIsRequester_ReturnsSoDViolation() + { + var r = MakePendingManagerApproval(); + + var result = r.ManagerApprove(Requester, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void ManagerApprove_WhenNotPendingManagerApproval_ReturnsInvalidStateTransition() + { + var r = MakeSubmitted(); // aún en PendingEligibilityCheck + + var result = r.ManagerApprove(Approver, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + [Fact] + public void ManagerReject_FromPendingManagerApproval_TransitionsToRejected() + { + var r = MakePendingManagerApproval(); + + var result = r.ManagerReject(Approver, "no cumple el perfil", Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Rejected, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionRejectedEvent); + } + + [Fact] + public void ManagerReject_WithoutReason_ReturnsFailure() + { + var r = MakePendingManagerApproval(); + + var result = r.ManagerReject(Approver, " ", Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.DecisionReasonRequired, result.Error); + } + + // ── SecurityApprove / SecurityReject (SoD reviewer ≠ approver) ───────────── + + [Fact] + public void SecurityApprove_FromPendingSecurityReview_TransitionsToApproved() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); + + var result = r.SecurityApprove(Reviewer, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Approved, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionSecurityApprovedEvent); + } + + [Fact] + public void SecurityApprove_WhenReviewerIsApprover_ReturnsSoDViolation() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); + + var result = r.SecurityApprove(Approver, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void SecurityApprove_WhenReviewerIsTarget_ReturnsSoDViolation() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); + + var result = r.SecurityApprove(Target, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void SecurityApprove_WhenNotInSecurityReview_ReturnsInvalidStateTransition() + { + var r = MakeApprovedLowRisk(); // ya Approved, sin pasar por seguridad + + var result = r.SecurityApprove(Reviewer, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + [Fact] + public void SecurityReject_FromPendingSecurityReview_TransitionsToRejected() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); + + var result = r.SecurityReject(Reviewer, "riesgo inaceptable", Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Rejected, r.Status); + } + + // ── Execute (INV-RPR5: sólo Approved) ───────────────────────────────────── + + [Fact] + public void Execute_FromApproved_TransitionsToExecuted() + { + var r = MakeApprovedLowRisk(); + + var result = r.Execute(Executor, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Executed, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionExecutedEvent); + } + + [Fact] + public void Execute_WhenNotApproved_ReturnsInvalidStateTransition() + { + var r = MakePendingManagerApproval(); + + var result = r.Execute(Executor, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + // SoD del ejecutor (ADR-UMS-096, INV-RPR3 endurecida): ejecutor ≠ objetivo ≠ aprobador ≠ revisor. + + [Fact] + public void Execute_WhenExecutorIsApprover_ReturnsSoDViolation() + { + var r = MakeApprovedLowRisk(); // aprobado por Approver + + var result = r.Execute(Approver, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + Assert.Equal(RolePromotionStatus.Approved, r.Status); // no transiciona + } + + [Fact] + public void Execute_WhenExecutorIsSecurityReviewer_ReturnsSoDViolation() + { + var r = MakeApprovedHighRisk(); // revisado por Reviewer + + var result = r.Execute(Reviewer, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + Assert.Equal(RolePromotionStatus.Approved, r.Status); // no transiciona + } + + [Fact] + public void Execute_WhenExecutorIsTarget_ReturnsSoDViolation() + { + var r = MakeApprovedLowRisk(); + + var result = r.Execute(Target, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + // ── Verify (INV-RPR5: sólo Executed + SoD verifier ≠ executor/target) ───── + + [Fact] + public void Verify_FromExecuted_TransitionsToVerified() + { + var r = MakeExecuted(); + + var result = r.Verify(Verifier, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Verified, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionVerifiedEvent); + } + + [Fact] + public void Verify_WhenVerifierIsExecutor_ReturnsSoDViolation() + { + var r = MakeExecuted(); + + var result = r.Verify(Executor, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void Verify_WhenVerifierIsTarget_ReturnsSoDViolation() + { + var r = MakeExecuted(); + + var result = r.Verify(Target, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void Verify_WhenNotExecuted_ReturnsInvalidStateTransition() + { + var r = MakeApprovedLowRisk(); + + var result = r.Verify(Verifier, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + // ── Cancel (sólo Draft) ─────────────────────────────────────────────────── + + [Fact] + public void Cancel_FromDraft_TransitionsToCancelled() + { + var r = MakeDraft(); + + var result = r.Cancel("ya no se requiere", Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Cancelled, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionCancelledEvent); + } + + [Fact] + public void Cancel_WhenNotDraft_ReturnsInvalidStateTransition() + { + var r = MakeSubmitted(); + + var result = r.Cancel("tarde", Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + // ── Saltos de estado ilegales ───────────────────────────────────────────── + + [Fact] + public void Execute_DirectlyFromDraft_IsIllegal() + { + var r = MakeDraft(); + + Assert.True(r.Execute(Executor, Actor).IsFailure); + } + + [Fact] + public void Verify_DirectlyFromDraft_IsIllegal() + { + var r = MakeDraft(); + + Assert.True(r.Verify(Verifier, Actor).IsFailure); + } + + [Fact] + public void Verified_IsTerminal_CannotBeExecutedAgain() + { + var r = MakeExecuted(); + r.Verify(Verifier, Actor); + + Assert.True(r.Execute(Executor, Actor).IsFailure); + Assert.Equal(RolePromotionStatus.Verified, r.Status); + } + + [Fact] + public void Rejected_IsTerminal_CannotBeApproved() + { + var r = MakePendingManagerApproval(); + r.ManagerReject(Approver, "rechazada", Actor); + + Assert.True(r.ManagerApprove(Approver, Actor).IsFailure); + Assert.Equal(RolePromotionStatus.Rejected, r.Status); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/Auth/IdpAuthOutcomeClassifierTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/Auth/IdpAuthOutcomeClassifierTests.cs new file mode 100644 index 00000000..ddf26d9a --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/Auth/IdpAuthOutcomeClassifierTests.cs @@ -0,0 +1,135 @@ +namespace Ums.Domain.Test.Identity.Auth; + +using System.Collections.Generic; +using Ums.Domain.Identity.Auth; +using Xunit; + +/// +/// FR-042 (ADR-UMS-097 §2.3, slice 2b): pruebas del clasificador de outcome IdP, que es la decisión de +/// seguridad que gobierna el fallback encadenado. Verifica que SOLO señales inequívocas de +/// indisponibilidad de infraestructura se clasifiquen como +/// (autorizan avanzar), y que TODO lo demás sea +/// (fail-closed, anti credential-spraying). +/// +public class IdpAuthOutcomeClassifierTests +{ + private static Result Success() + => Result.Success(new ExternalIdentity( + "user@ransa.pe", "sub-1", "User", new Dictionary())); + + private static Result Failure(string error) + => Result.Failure(error); + + // ── Éxito ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Classify_Success_ReturnsSuccess() + { + Assert.Equal(IdpAuthOutcome.Success, IdpAuthOutcomeClassifier.Classify(Success())); + } + + // ── Indisponibilidad de infraestructura (ÚNICO caso que autoriza avanzar) ───── + + [Theory] + [InlineData("AUTH_012: No IDP adapter registered for strategy 'KEYCLOAK'.")] + [InlineData("AUTH_034: No se pudo obtener el JWKS del issuer. HTTP 503.")] + // G-108: el token endpoint OIDC 5xx/timeout/transporte emite AUTH_035 (rama de INFRA, distinta del + // AUTH_021 de credencial) → clasifica como InfraUnavailable → habilita el fallback encadenado. + [InlineData("AUTH_035: El token endpoint del IdP no está disponible (5xx/timeout/transporte). HTTP 503.")] + [InlineData("AUTH_035: El token endpoint del IdP no está disponible (5xx/timeout/transporte). No se pudo contactar el token endpoint (transporte/timeout).")] + public void Classify_InfraSignals_ReturnsInfraUnavailable(string error) + { + Assert.Equal(IdpAuthOutcome.InfraUnavailable, IdpAuthOutcomeClassifier.Classify(Failure(error))); + } + + // ── Fallos de credencial/token del IdP → TERMINAL (no avanza) ───────────────── + + [Theory] + [InlineData("AUTH_006: Invalid username or password.")] + [InlineData("AUTH_004: Authenticated IDP user has no UMS account.")] + [InlineData("AUTH_020: No se encontró configuración OIDC válida para el proveedor.")] + [InlineData("AUTH_026: La firma del 'id_token' es inválida.")] + [InlineData("AUTH_027: El 'iss' del 'id_token' no coincide con el issuer configurado.")] + [InlineData("AUTH_029: El 'id_token' está expirado ('exp').")] + [InlineData("AUTH_031: El 'nonce' del 'id_token' no coincide con el emitido en la autorización.")] + [InlineData("AUTH_033: El parámetro 'state' del callback no coincide con el emitido.")] + public void Classify_CredentialAndTokenRejections_ReturnsCredentialTerminal(string error) + { + Assert.Equal(IdpAuthOutcome.CredentialTerminal, IdpAuthOutcomeClassifier.Classify(Failure(error))); + } + + // ── GUARDA ANTI-REGRESIÓN DE SEGURIDAD (ADR-UMS-097 §2.3, G-108) ───────────────────────────── + // AUTH_021 es la rama de CREDENCIAL/4xx del intercambio de código (invalid_grant). DEBE seguir + // siendo TERMINAL: si se clasificara como infra, la cadena de fallback probaría la MISMA credencial + // contra cada IdP → credential spraying cross-IdP. Esta prueba NO debe "arreglarse" debilitándola + // (p. ej. metiendo AUTH_021 en InfraUnavailableCodes o clasificando por el texto «503»): la + // resiliencia se ganó separando ESTRUCTURALMENTE el 5xx en AUTH_035, no relajando el 4xx. + + [Fact] + public void Classify_TokenExchange4xxInvalidGrant_StaysTerminal_AntiSprayingGuard() + { + // Un 4xx invalid_grant (rechazo de credencial por el token endpoint) → AUTH_021 → TERMINAL. + Assert.Equal(IdpAuthOutcome.CredentialTerminal, + IdpAuthOutcomeClassifier.Classify(Failure("AUTH_021: Falló el intercambio del código por tokens contra el IdP. HTTP 400."))); + } + + [Fact] + public void Classify_TokenExchange4xx_IsNotInfra_EvenIfMessageMentions503_AntiSprayingGuard() + { + // Blindaje contra clasificación por texto: aunque el mensaje incluyera «503», el CÓDIGO AUTH_021 + // (credencial/4xx) manda ⇒ TERMINAL. La separación es por código/status estructural, nunca por texto. + Assert.Equal(IdpAuthOutcome.CredentialTerminal, + IdpAuthOutcomeClassifier.Classify(Failure("AUTH_021: Falló el intercambio del código por tokens contra el IdP. HTTP 503."))); + Assert.False(IdpAuthOutcomeClassifier.IsInfraUnavailable( + "AUTH_021: Falló el intercambio del código por tokens contra el IdP. HTTP 503.")); + } + + // ── AUTH_013 está reutilizado por flujos no-IdP → ambiguo → TERMINAL (fail-closed) ──── + + [Fact] + public void Classify_Auth013_IsTreatedAsTerminal_FailClosed() + { + Assert.Equal(IdpAuthOutcome.CredentialTerminal, + IdpAuthOutcomeClassifier.Classify(Failure("AUTH_013: Stub IDP only accepts credentials starting with 'MOCK-'."))); + } + + // ── Códigos desconocidos / error sin código → TERMINAL (fail-closed) ───────── + + [Theory] + [InlineData("AUTH_999: algo no clasificable")] + [InlineData("mensaje sin código de error")] + public void Classify_UnknownOrCodeless_ReturnsCredentialTerminal(string error) + { + Assert.Equal(IdpAuthOutcome.CredentialTerminal, IdpAuthOutcomeClassifier.Classify(Failure(error))); + } + + [Fact] + public void Classify_NullResult_ReturnsCredentialTerminal() + { + Assert.Equal(IdpAuthOutcome.CredentialTerminal, IdpAuthOutcomeClassifier.Classify(null!)); + } + + // El helper IsInfraUnavailable es defensivo ante error vacío/nulo (fail-closed). + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("mensaje sin código")] + [InlineData("AUTH_999: desconocido")] + public void IsInfraUnavailable_EmptyNullOrUnknown_ReturnsFalse(string? error) + { + Assert.False(IdpAuthOutcomeClassifier.IsInfraUnavailable(error)); + } + + // ── La lista blanca de infra es minimalista y explícita ────────────────────── + + [Fact] + public void InfraAllowlist_IsExactlyAdapterJwksAndTokenEndpoint() + { + // La lista blanca crece SOLO con señales inequívocas de infra. G-108 añade AUTH_035 (token endpoint + // OIDC 5xx/timeout/transporte). AUTH_021 (4xx credencial) NUNCA entra aquí. + Assert.Equal(new HashSet { "AUTH_012", "AUTH_034", "AUTH_035" }, + new HashSet(IdpAuthOutcomeClassifier.InfraUnavailableCodes)); + Assert.DoesNotContain("AUTH_021", IdpAuthOutcomeClassifier.InfraUnavailableCodes); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantParameter/TenantParameterTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantParameter/TenantParameterTests.cs new file mode 100644 index 00000000..29be7e80 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantParameter/TenantParameterTests.cs @@ -0,0 +1,292 @@ +namespace Ums.Domain.Test.Identity.Tenant.TenantParameter; + +using Ums.Domain.Identity.Tenant.TenantParameter; +using Xunit; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using TenantParameterEntity = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; + +public class TenantParameterTests +{ + private static readonly ActorId ValidActor = ActorId.Create("user-001"); + + private static TenantParameterEntity CreateStringParameter(string code = "STR-PARAM", string value = "old", string? allowedValues = null) + { + return TenantParameterEntity.Create( + TenantId.Create(), + code, + "Parametro de prueba", + value, + TenantParameterValueType.String, + TenantParameterCategory.Session, + isSensitive: false, + defaultValue: null, + allowedValues: allowedValues, + ValidActor).Value; + } + + #region Create + + [Fact] + public void Create_WithValidData_ReturnsSuccessActiveAndPreservesFields() + { + var result = TenantParameterEntity.Create( + TenantId.Create(), + "INT-PARAM", + "Parametro entero", + "10", + TenantParameterValueType.Integer, + TenantParameterCategory.Session, + isSensitive: false, + defaultValue: null, + allowedValues: null, + ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal("INT-PARAM", result.Value.Code.GetValue()); + Assert.Equal("10", result.Value.Value); + Assert.Equal(TenantParameterValueType.Integer, result.Value.ValueType); + Assert.Equal(TenantParameterCategory.Session, result.Value.Category); + Assert.True(result.Value.IsActive); + } + + [Fact] + public void Create_WithEmptyCode_ReturnsFailure() + { + var result = TenantParameterEntity.Create( + TenantId.Create(), + "", + "Parametro sin codigo", + "v", + TenantParameterValueType.String, + TenantParameterCategory.Session, + isSensitive: false, + defaultValue: null, + allowedValues: null, + ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Common.Required, result.Error); + } + + #endregion + + #region UpdateValue + + [Fact] + public void UpdateValue_WithValidValue_ReturnsSuccessAndRaisesUpdatedEvent() + { + var parameter = CreateStringParameter(value: "old"); + + var result = parameter.UpdateValue("new", ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal("new", parameter.Value); + var events = parameter.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is TenantParameterUpdatedEvent); + } + + [Fact] + public void UpdateValue_WithTypeMismatch_ReturnsFailureAndKeepsValue() + { + var parameter = TenantParameterEntity.Create( + TenantId.Create(), + "INT-PARAM", + "Parametro entero", + "10", + TenantParameterValueType.Integer, + TenantParameterCategory.Session, + isSensitive: false, + defaultValue: null, + allowedValues: null, + ValidActor).Value; + + var result = parameter.UpdateValue("no-es-entero", ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.InvalidValueType, result.Error); + Assert.Equal("10", parameter.Value); + } + + [Fact] + public void UpdateValue_WhenNotInAllowedList_ReturnsFailureAndKeepsValue() + { + var parameter = CreateStringParameter(value: "ES", allowedValues: "ES,EN,PT"); + + var result = parameter.UpdateValue("ZZ", ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.ValueNotInAllowedList, result.Error); + Assert.Equal("ES", parameter.Value); + } + + [Fact] + public void UpdateValue_WhenInAllowedList_ReturnsSuccess() + { + var parameter = CreateStringParameter(value: "ES", allowedValues: "ES,EN,PT"); + + var result = parameter.UpdateValue("EN", ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal("EN", parameter.Value); + } + + #endregion + + #region Deactivate / Reactivate + + [Fact] + public void Deactivate_SetsInactiveAndRaisesDeactivatedEvent() + { + var parameter = CreateStringParameter(); + + var result = parameter.Deactivate(ValidActor); + + Assert.True(result.IsSuccess); + Assert.False(parameter.IsActive); + var events = parameter.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is TenantParameterDeactivatedEvent); + } + + [Fact] + public void Reactivate_SetsActiveAndRaisesReactivatedEvent() + { + var parameter = CreateStringParameter(); + parameter.Deactivate(ValidActor); + + var result = parameter.Reactivate(ValidActor); + + Assert.True(result.IsSuccess); + Assert.True(parameter.IsActive); + var events = parameter.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is TenantParameterReactivatedEvent); + } + + #endregion + + #region Delete (borrado lógico) + + [Fact] + public void Delete_ConVinculoActivo_ReturnsFailure() + { + // Regla de cascada: un parámetro ACTIVO es una referencia viva de la configuración del + // inquilino —el provider lo resuelve ahora mismo por su código— y no se puede eliminar. + var parameter = CreateStringParameter(); + + var result = parameter.Delete(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.HasActiveBinding, result.Error); + Assert.False(parameter.IsDeleted); + } + + [Fact] + public void Delete_TrasDesactivar_ReturnsSuccessYMarcaEliminado() + { + // La desactivación ES la eliminación lógica del vínculo: una vez hecha, el borrado procede. + // La fila conserva todos sus datos (código, valor, auditoría): solo cambia de visibilidad. + var parameter = CreateStringParameter(value: "es-PE"); + parameter.Deactivate(ValidActor); + + var result = parameter.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.True(parameter.IsDeleted); + Assert.Equal("es-PE", parameter.Value); + var events = parameter.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is TenantParameterDeletedEvent); + } + + [Fact] + public void Delete_CuandoYaEstaEliminado_ReturnsFailure() + { + var parameter = CreateStringParameter(); + parameter.Deactivate(ValidActor); + parameter.Delete(ValidActor); + + var result = parameter.Delete(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.AlreadyDeleted, result.Error); + } + + [Fact] + public void Reactivate_TrasEliminar_ReturnsFailure() + { + // El borrado lógico es terminal: reactivar no puede ser la puerta trasera que lo resucite. + var parameter = CreateStringParameter(); + parameter.Deactivate(ValidActor); + parameter.Delete(ValidActor); + + var result = parameter.Reactivate(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.AlreadyDeleted, result.Error); + Assert.False(parameter.IsActive); + } + + #endregion + + #region Uniqueness among active parameters (Tenant aggregate) + + private static TenantAggregate CreateTenant() + { + return TenantAggregate.Create( + Code.Create("TEN-001"), + Name.Create("Inquilino de prueba"), + OrganizationType.INTERNAL, + ValidActor).Value; + } + + [Fact] + public void AddParameter_WithUniqueActiveCode_ReturnsSuccess() + { + var tenant = CreateTenant(); + + var result = tenant.AddParameter( + "PARAM-1", "desc", "v", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Single(tenant.Parameters); + } + + [Fact] + public void AddParameter_WithDuplicateActiveCode_ReturnsFailure() + { + var tenant = CreateTenant(); + tenant.AddParameter( + "PARAM-1", "desc", "v", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + + var result = tenant.AddParameter( + "PARAM-1", "otra desc", "w", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.CodeNotUnique, result.Error); + } + + [Fact] + public void AddParameter_WithCodeOfDeactivatedParameter_ReturnsSuccess() + { + var tenant = CreateTenant(); + tenant.AddParameter( + "PARAM-1", "desc", "v", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + tenant.DeactivateParameter("PARAM-1", ValidActor); + + var result = tenant.AddParameter( + "PARAM-1", "desc nueva", "w", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Single(tenant.Parameters, p => p.IsActive); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantTests.cs index 0874cdf0..c1a5c9cf 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantTests.cs @@ -1,7 +1,7 @@ +#pragma warning disable S1144 namespace Ums.Domain.Test.Identity.Tenant; using Ums.Domain.Identity.Tenant; -using Ums.Domain.Identity.Tenant.Branding; using Xunit; public class TenantTests @@ -11,17 +11,6 @@ public class TenantTests private static readonly ActorId ValidActor = ActorId.Create("user-001"); private static readonly OrganizationType ValidType = OrganizationType.INTERNAL; private static readonly Description ValidDescription = Description.Create("Test IdP"); - private static BrandingSettings ValidBrandingSettings => BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in to continue"), - LoginText.Create("Sign In"), - LoginText.Create("Powered by UMS")) - .WithCustomDomain(null) - .WithMagicLinkFallback(false) - .Build(); #region Create @@ -145,65 +134,219 @@ public void AddBranch_WithEmptyName_ReturnsFailureWithBrokenRules() #endregion - #region RemoveBranch + #region CloseBranch (ADR-0164: el borrado es lógico) [Fact] - public void RemoveBranch_WhenBranchNotFound_ReturnsFailure() + public void CloseBranch_WhenBranchNotFound_ReturnsFailure() { var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; var fakeId = IdValueObject.Create(); - var result = tenant.RemoveBranch(fakeId, ValidActor); + var result = tenant.CloseBranch(fakeId, ValidActor); Assert.True(result.IsFailure); Assert.Contains(DomainErrors.Common.NotFound, result.Error); } + /// + /// La prueba central de ADR-0164 §2.1 en el dominio: el cierre NO encoge la colección. Antes esto + /// hacía `_branches.Remove(...)` y el reconciliador de EF lo traducía en un DELETE. + /// [Fact] - public void RemoveBranch_WhenBranchIsActive_ReturnsFailure() + public void CloseBranch_NoQuitaLaSucursalDeLaColeccion() { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var branchCode = Code.Create("BR-001"); - var branchName = Name.Create("Branch One"); - tenant.AddBranch(branchCode, branchName, ValidActor); - var branchId = tenant.Branches.First().GetId(); + var tenant = NuevoInquilinoConSucursal(out var branchId); - var result = tenant.RemoveBranch(branchId, ValidActor); + var result = tenant.CloseBranch(branchId, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Single(tenant.Branches); + Assert.True(tenant.Branches.First().IsClosed); + Assert.False(tenant.Branches.First().IsActive); + Assert.NotNull(tenant.Branches.First().ClosedAtUtc); + Assert.Equal(ValidActor.GetValue(), tenant.Branches.First().ClosedBy); + } + + [Fact] + public void CloseBranch_SobreSucursalActiva_ProcedeSinExigirDesactivarAntes() + { + // Cerrar y desactivar son verbos independientes (ADR-0164 §2.4): ninguno es el paso previo + // del otro. Una sucursal que opera puede cerrarse sin la ceremonia de desactivarla primero. + var tenant = NuevoInquilinoConSucursal(out var branchId); + Assert.True(tenant.Branches.First().IsActive); + + var result = tenant.CloseBranch(branchId, ValidActor); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void CloseBranch_ConUsuariosActivos_EsRechazado() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + + var result = tenant.CloseBranch(branchId, ValidActor, activeUserCount: 3); Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Common.Invalid, result.Error); + Assert.Contains(DomainErrors.Tenant.BranchHasLiveReferences, result.Error); + Assert.False(tenant.Branches.First().IsClosed); } [Fact] - public void RemoveBranch_WhenBranchIsInactive_ReturnsSuccess() + public void CloseBranch_ConPerfilesActivos_EsRechazado() { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var branchCode = Code.Create("BR-001"); - var branchName = Name.Create("Branch One"); - tenant.AddBranch(branchCode, branchName, ValidActor); - var branchId = tenant.Branches.First().GetId(); - tenant.DeactivateBranch(branchId, ValidActor); + var tenant = NuevoInquilinoConSucursal(out var branchId); - var result = tenant.RemoveBranch(branchId, ValidActor); + var result = tenant.CloseBranch(branchId, ValidActor, activeProfileCount: 2); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchHasLiveReferences, result.Error); + } + + [Fact] + public void CloseBranch_ConReferenciasYaEliminadas_Procede() + { + // Contraprueba obligatoria de ADR-0164 §2.2: lo YA eliminado no bloquea. Los recuentos que + // recibe el dominio son de referencias ACTIVAS, así que un cero significa «no queda nada + // vivo», no «nunca hubo nada». + var tenant = NuevoInquilinoConSucursal(out var branchId); + + var result = tenant.CloseBranch(branchId, ValidActor, activeUserCount: 0, activeProfileCount: 0); Assert.True(result.IsSuccess); - Assert.Empty(tenant.Branches); } [Fact] - public void RemoveBranch_RaisesBranchRemovedEvent() + public void CloseBranch_DosVeces_EsRechazado() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + tenant.CloseBranch(branchId, ValidActor); + + var result = tenant.CloseBranch(branchId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchAlreadyClosed, result.Error); + } + + [Fact] + public void CloseBranch_RaisesBranchClosedEvent() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + + tenant.CloseBranch(branchId, ValidActor); + + var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); + var cierre = Assert.Single(events.OfType()); + Assert.Equal("BR-001", cierre.Code); + } + + /// ADR-0164 §2.3: el código de una sucursal cerrada no se libera. + [Fact] + public void AddBranch_ConElCodigoDeUnaSucursalCerrada_EsRechazado() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + tenant.CloseBranch(branchId, ValidActor); + + var result = tenant.AddBranch(Code.Create("BR-001"), Name.Create("Otra sucursal"), ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchCodeNotUnique, result.Error); + } + + [Fact] + public void ReactivateBranch_SobreSucursalCerrada_EsRechazado() + { + // La puerta de atrás cerrada (ADR-0164 §2.4): al estado terminal no se llega —ni se sale de + // él— manipulando el estado reversible. + var tenant = NuevoInquilinoConSucursal(out var branchId); + tenant.CloseBranch(branchId, ValidActor); + + var result = tenant.ReactivateBranch(branchId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchClosed, result.Error); + Assert.False(tenant.Branches.First().IsActive); + } + + [Fact] + public void DeactivateBranch_SobreSucursalCerrada_EsRechazado() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + tenant.CloseBranch(branchId, ValidActor); + + var result = tenant.DeactivateBranch(branchId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchClosed, result.Error); + } + + #endregion + + #region Bitácora de episodios (ADR-0164) + + [Fact] + public void AddBranch_AnotaElEpisodioDeApertura() + { + var tenant = NuevoInquilinoConSucursal(out _); + + var asientos = tenant.Branches.First().PendingLifecycleEntries; + + var apertura = Assert.Single(asientos); + Assert.Equal(BranchLifecycleEpisode.Opened, apertura.Episode); + Assert.Equal(ValidActor.GetValue(), apertura.ActorId); + Assert.Equal("Branch One", apertura.NameSnapshot); + } + + [Fact] + public void LaBitacora_RegistraCadaEpisodioConSuFechaYSuAutor() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + var otroActor = ActorId.Create("user-002"); + + tenant.DeactivateBranch(branchId, ValidActor, reason: "Cierre temporal por obras"); + tenant.ReactivateBranch(branchId, otroActor, reason: "Reapertura tras obras"); + tenant.CloseBranch(branchId, otroActor, reason: "Cese de operaciones en la plaza"); + + var asientos = tenant.Branches.First().PendingLifecycleEntries.ToList(); + + Assert.Equal(4, asientos.Count); + Assert.Equal(BranchLifecycleEpisode.Opened, asientos[0].Episode); + Assert.Equal(BranchLifecycleEpisode.Deactivated, asientos[1].Episode); + Assert.Equal(BranchLifecycleEpisode.Reactivated, asientos[2].Episode); + Assert.Equal(BranchLifecycleEpisode.Closed, asientos[3].Episode); + + Assert.Equal(ValidActor.GetValue(), asientos[1].ActorId); + Assert.Equal("Cierre temporal por obras", asientos[1].Reason); + Assert.Equal(otroActor.GetValue(), asientos[2].ActorId); + Assert.Equal(otroActor.GetValue(), asientos[3].ActorId); + Assert.Equal("Cese de operaciones en la plaza", asientos[3].Reason); + Assert.All(asientos, a => Assert.NotEqual(default, a.OccurredAtUtc)); + Assert.All(asientos, a => Assert.Equal(branchId.GetValue(), a.BranchId)); + } + + /// + /// La razón de ser de la bitácora: dos ÉPOCAS de la misma sucursal deben poder distinguirse. El + /// asiento guarda la foto (nombre, geocerca) de cada época, así que una auditoría posterior no ve + /// los datos de hoy proyectados sobre un despacho de entonces. + /// + [Fact] + public void LaBitacora_ConservaLaFotoDeCadaEpoca() { var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var branchCode = Code.Create("BR-001"); - var branchName = Name.Create("Branch One"); - tenant.AddBranch(branchCode, branchName, ValidActor); + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Almacén Callao"), ValidActor, Value.Create("LIMA-CALLAO")); var branchId = tenant.Branches.First().GetId(); + tenant.DeactivateBranch(branchId, ValidActor); + // Entre época y época la sucursal se muda y cambia de nombre. + tenant.UpdateBranch(branchId, Name.Create("Terminal Paita"), Value.Create("PIURA-PAITA"), ValidActor); + tenant.ReactivateBranch(branchId, ValidActor); - tenant.RemoveBranch(branchId, ValidActor); + var asientos = tenant.Branches.First().PendingLifecycleEntries.ToList(); - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BranchRemovedEvent); + Assert.Equal("Almacén Callao", asientos[1].NameSnapshot); + Assert.Equal("LIMA-CALLAO", asientos[1].GeofencingSnapshot); + Assert.Equal("Terminal Paita", asientos[2].NameSnapshot); + Assert.Equal("PIURA-PAITA", asientos[2].GeofencingSnapshot); } #endregion @@ -718,352 +861,6 @@ public void RemoveIdentityProvider_RaisesIdentityProviderRemovedEvent() #endregion - #region SetBranding - - [Fact] - public void SetBranding_WithValidData_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - var result = tenant.SetBranding(ValidBrandingSettings, ValidActor); - - Assert.True(result.IsSuccess); - Assert.NotNull(tenant.Branding); - Assert.Equal(LogoFormat.Png, tenant.Branding.LogoFormat); - Assert.Equal(BackgroundStyle.SolidColor, tenant.Branding.BackgroundStyle); - Assert.False(tenant.Branding.MagicLinkFallbackEnabled); - } - - [Fact] - public void SetBranding_WhenAlreadyExists_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var result = tenant.SetBranding(ValidBrandingSettings, ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Tenant.BrandingAlreadyExists, result.Error); - } - - [Fact] - public void SetBranding_WithInvalidHexColor_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("INVALID"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(null) - .WithMagicLinkFallback(false) - .Build(); - - var result = tenant.SetBranding(settings, ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Branding.InvalidHexColor, result.Error); - } - - [Fact] - public void SetBranding_WithCustomDomain_SetsDnsStatusToPending() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - - tenant.SetBranding(settings, ValidActor); - - Assert.Equal(DnsVerificationStatus.Pending, tenant.Branding!.DnsVerificationStatus); - Assert.Equal("edge.platform.io", tenant.Branding.DnsCnameTarget.GetValue()); - } - - [Fact] - public void SetBranding_RaisesBrandingCreatedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingCreatedEvent); - } - - #endregion - - #region UpdateBranding - - [Fact] - public void UpdateBranding_WhenBrandingExists_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var updatedSettings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/new-logo.svg"), LogoFormat.Svg) - .WithTheme(HexColor.Create("#00FF00"), BackgroundStyle.Gradient) - .WithTexts( - LoginText.Create("New Welcome"), - LoginText.Create("New secondary"), - LoginText.Create("New button"), - LoginText.Create("New footer")) - .WithCustomDomain(null) - .WithMagicLinkFallback(true) - .Build(); - - var result = tenant.UpdateBranding(updatedSettings, ValidActor); - - Assert.True(result.IsSuccess); - Assert.Equal(LogoFormat.Svg, tenant.Branding!.LogoFormat); - Assert.True(tenant.Branding.MagicLinkFallbackEnabled); - } - - [Fact] - public void UpdateBranding_WhenBrandingNotFound_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - var result = tenant.UpdateBranding(ValidBrandingSettings, ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Tenant.BrandingNotFound, result.Error); - } - - [Fact] - public void UpdateBranding_WhenCustomDomainChanged_ResetsDnsStatusToPending() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var initialSettings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("old.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(initialSettings, ValidActor); - tenant.VerifyBrandingDns(ValidActor); - - var updatedSettings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("new.example.com")) - .WithMagicLinkFallback(false) - .Build(); - - tenant.UpdateBranding(updatedSettings, ValidActor); - - Assert.Equal(DnsVerificationStatus.Pending, tenant.Branding!.DnsVerificationStatus); - } - - [Fact] - public void UpdateBranding_RaisesBrandingUpdatedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - tenant.UpdateBranding(ValidBrandingSettings, ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingUpdatedEvent); - } - - #endregion - - #region RemoveBranding - - [Fact] - public void RemoveBranding_WhenBrandingExists_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var result = tenant.RemoveBranding(ValidActor); - - Assert.True(result.IsSuccess); - Assert.Null(tenant.Branding); - } - - [Fact] - public void RemoveBranding_WhenBrandingNotFound_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - var result = tenant.RemoveBranding(ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Tenant.BrandingNotFound, result.Error); - } - - [Fact] - public void RemoveBranding_RaisesBrandingRemovedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - tenant.RemoveBranding(ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingRemovedEvent); - } - - #endregion - - #region VerifyBrandingDns - - [Fact] - public void VerifyBrandingDns_WhenCustomDomainExists_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(settings, ValidActor); - - var result = tenant.VerifyBrandingDns(ValidActor); - - Assert.True(result.IsSuccess); - Assert.Equal(DnsVerificationStatus.Verified, tenant.Branding!.DnsVerificationStatus); - } - - [Fact] - public void VerifyBrandingDns_WhenNoCustomDomain_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var result = tenant.VerifyBrandingDns(ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Branding.DnsVerificationRequired, result.Error); - } - - [Fact] - public void VerifyBrandingDns_WhenBrandingNotFound_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - var result = tenant.VerifyBrandingDns(ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Tenant.BrandingNotFound, result.Error); - } - - [Fact] - public void VerifyBrandingDns_RaisesBrandingDnsVerifiedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(settings, ValidActor); - - tenant.VerifyBrandingDns(ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingDnsVerifiedEvent); - } - - #endregion - - #region FailBrandingDns - - [Fact] - public void FailBrandingDns_WhenCustomDomainExists_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(settings, ValidActor); - - var result = tenant.FailBrandingDns(ValidActor); - - Assert.True(result.IsSuccess); - Assert.Equal(DnsVerificationStatus.Failed, tenant.Branding!.DnsVerificationStatus); - } - - [Fact] - public void FailBrandingDns_WhenNoCustomDomain_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var result = tenant.FailBrandingDns(ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Branding.DnsVerificationRequired, result.Error); - } - - [Fact] - public void FailBrandingDns_RaisesBrandingDnsFailedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(settings, ValidActor); - - tenant.FailBrandingDns(ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingDnsFailedEvent); - } - - #endregion - #region Tenant Status Edge Cases [Fact] @@ -1111,4 +908,15 @@ public void ReactivateBranch_WhenBranchNotFound_ReturnsFailure() } #endregion + + /// Inquilino con una sucursal BR-001 «Branch One» recién dada de alta. + private static Tenant NuevoInquilinoConSucursal(out IdValueObject branchId) + { + var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ValidActor); + branchId = tenant.Branches.First().GetId(); + return tenant; + } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/TenantSignupRequest/TenantSignupRequestTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/TenantSignupRequest/TenantSignupRequestTests.cs new file mode 100644 index 00000000..abc250e6 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/TenantSignupRequest/TenantSignupRequestTests.cs @@ -0,0 +1,115 @@ +namespace Ums.Domain.Test.Identity.TenantSignupRequest; + +using BeyondNetCode.Shell.Ddd.ValueObjects.Audit; +using Ums.Domain.Identity.TenantSignupRequest; +using Xunit; + +public class TenantSignupRequestTests +{ + private static readonly Name ValidCompanyName = Name.Create("Comercializadora del Sur S.A."); + private static readonly CompanyReference ValidCompanyReference = CompanyReference.Create("RUC-20100412447"); + private static readonly Name ValidContactName = Name.Create("Ana Torres"); + private static readonly Email ValidContactEmail = Email.Create("ana.torres@empresa.com"); + private static readonly ActorId ValidActor = ActorId.Create("user-001"); + + #region Create + + [Fact] + public void Create_WithValidData_ReturnsSuccessInPendingStatus() + { + var result = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, ValidContactName, ValidContactEmail, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(TenantSignupRequestStatus.Pending, result.Value.Status); + Assert.Null(result.Value.ApprovedTenantId); + Assert.Equal(ValidCompanyName, result.Value.CompanyName); + Assert.Equal(ValidContactEmail, result.Value.ContactEmail); + } + + [Fact] + public void Create_WithEmptyCompanyName_ReturnsFailure() + { + var result = TenantSignupRequest.Create( + Name.Create(""), ValidCompanyReference, ValidContactName, ValidContactEmail, ValidActor); + + Assert.True(result.IsFailure); + } + + [Fact] + public void Create_WithEmptyContactName_ReturnsFailure() + { + var result = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, Name.Create(""), ValidContactEmail, ValidActor); + + Assert.True(result.IsFailure); + } + + [Fact] + public void Create_WithInvalidContactEmail_ReturnsFailure() + { + var result = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, ValidContactName, Email.Create("no-es-un-correo"), ValidActor); + + Assert.True(result.IsFailure); + } + + #endregion + + #region Approve + + [Fact] + public void Approve_WhenPending_TransitionsToApprovedAndFixesTenantId() + { + var request = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, ValidContactName, ValidContactEmail, ValidActor).Value; + var tenantId = TenantId.Create(); + + var result = request.Approve(tenantId, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(TenantSignupRequestStatus.Approved, request.Status); + Assert.NotNull(request.ApprovedTenantId); + Assert.Equal(tenantId.GetValue(), request.ApprovedTenantId!.GetValue()); + } + + [Fact] + public void Approve_WhenAlreadyApproved_ReturnsFailureAndKeepsFirstTenantId() + { + var request = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, ValidContactName, ValidContactEmail, ValidActor).Value; + var firstTenantId = TenantId.Create(); + request.Approve(firstTenantId, ValidActor); + + var secondTenantId = TenantId.Create(); + var result = request.Approve(secondTenantId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.SignupRequestNotPending, result.Error); + Assert.Equal(TenantSignupRequestStatus.Approved, request.Status); + Assert.Equal(firstTenantId.GetValue(), request.ApprovedTenantId!.GetValue()); + } + + [Fact] + public void Approve_WhenRejected_ReturnsFailure() + { + var props = new TenantSignupRequestProps( + IdValueObject.Create(), + ValidCompanyName, + ValidCompanyReference, + ValidContactName, + ValidContactEmail, + TenantSignupRequestStatus.Rejected, + null, + AuditValueObject.Create(ValidActor.GetValue())); + var request = new TenantSignupRequest(props); + + var result = request.Approve(TenantId.Create(), ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.SignupRequestNotPending, result.Error); + Assert.Null(request.ApprovedTenantId); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/MfaEnrollment/MfaEnrollmentTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/MfaEnrollment/MfaEnrollmentTests.cs new file mode 100644 index 00000000..5a86b52e --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/MfaEnrollment/MfaEnrollmentTests.cs @@ -0,0 +1,91 @@ +namespace Ums.Domain.Test.Identity.UserAccount.MfaEnrollment; + +using Ums.Domain.Identity.UserAccount.MfaEnrollment; +using Xunit; + +public class MfaEnrollmentTests +{ + private static readonly UserAccountId ValidUserAccountId = UserAccountId.Load(Guid.NewGuid().ToString()); + private static readonly ActorId ValidActor = ActorId.Create("user-001"); + + #region Create + + [Fact] + public void Create_WithValidData_ReturnsSuccess() + { + var result = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ValidUserAccountId, result.Value.UserAccountId); + Assert.Equal(MfaMethod.Totp, result.Value.Method); + } + + [Fact] + public void Create_StartsInEnrolledStatus_NotVerifiedNorNotEnrolled() + { + var result = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(MfaEnrollmentStatus.Enrolled, result.Value.Status); + Assert.NotEqual(MfaEnrollmentStatus.NotEnrolled, result.Value.Status); + Assert.NotEqual(MfaEnrollmentStatus.Verified, result.Value.Status); + } + + [Theory] + [InlineData(1)] // Totp + [InlineData(2)] // WebAuthn + [InlineData(3)] // SmsOtp + [InlineData(4)] // EmailOtp + public void Create_WithAnyMethod_StartsEnrolled(int methodId) + { + var method = DomainEnumeration.FromValue(methodId)!; + + var result = MfaEnrollment.Create(ValidUserAccountId, method, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(method, result.Value.Method); + Assert.Equal(MfaEnrollmentStatus.Enrolled, result.Value.Status); + } + + [Fact] + public void GetId_ReturnsStableNonEmptyIdentifier() + { + var enrollment = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor).Value; + + var first = enrollment.GetId(); + var second = enrollment.GetId(); + + Assert.NotEqual(Guid.Empty, first.GetValue()); + Assert.Equal(first.GetValue(), second.GetValue()); + } + + #endregion + + #region Verify + + [Fact] + public void Verify_WhenEnrolled_TransitionsToVerified() + { + var enrollment = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor).Value; + + var result = enrollment.Verify(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(MfaEnrollmentStatus.Verified, enrollment.Status); + } + + [Fact] + public void Verify_WhenAlreadyVerified_ReturnsFailureAndKeepsStatus() + { + var enrollment = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor).Value; + enrollment.Verify(ValidActor); + + var result = enrollment.Verify(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.UserAccount.MfaAlreadyVerified, result.Error); + Assert.Equal(MfaEnrollmentStatus.Verified, enrollment.Status); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/UserAccountTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/UserAccountTests.cs index 0aa78502..06f3bea4 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/UserAccountTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/UserAccountTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Domain.Test.Identity.UserAccount; using Ums.Domain.Identity.UserAccount; @@ -288,7 +289,7 @@ public void ActivatePassword_WithValidId_ReturnsSuccess() user.AddPassword(ValidPasswordHash, ValidActor); var secondHash = PasswordHash.Create("newhashedpassword456"); user.AddPassword(secondHash, ValidActor); - var firstCredentialId = user.PasswordCredentials.First().Id; + var firstCredentialId = user.PasswordCredentials.First().GetId(); var result = user.ActivatePassword(firstCredentialId, ValidActor); @@ -317,7 +318,7 @@ public void ActivatePassword_WhenBlocked_ReturnsFailure() user.Activate(ValidActor); var reason = Reason.Create("Security violation"); user.Block(reason, ValidActor); - var credentialId = user.PasswordCredentials.First().Id; + var credentialId = user.PasswordCredentials.First().GetId(); var result = user.ActivatePassword(credentialId, ValidActor); @@ -336,7 +337,7 @@ public void RemovePassword_WhenMultiplePasswords_ReturnsSuccess() user.AddPassword(ValidPasswordHash, ValidActor); var secondHash = PasswordHash.Create("newhashedpassword456"); user.AddPassword(secondHash, ValidActor); - var firstCredentialId = user.PasswordCredentials.First().Id; + var firstCredentialId = user.PasswordCredentials.First().GetId(); var result = user.RemovePassword(firstCredentialId, ValidActor); @@ -349,7 +350,7 @@ public void RemovePassword_WhenLastPassword_ReturnsFailure() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.AddPassword(ValidPasswordHash, ValidActor); - var credentialId = user.PasswordCredentials.First().Id; + var credentialId = user.PasswordCredentials.First().GetId(); var result = user.RemovePassword(credentialId, ValidActor); @@ -438,7 +439,7 @@ public void VerifyMfaChallenge_WithValidEnrollment_ReturnsSuccess() var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; var method = MfaMethod.Totp; user.EnrollMfa(method, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); var result = user.VerifyMfaChallenge(enrollmentId, ValidActor); @@ -464,7 +465,7 @@ public void VerifyMfaChallenge_RaisesMfaVerifiedEvent() var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; var method = MfaMethod.Totp; user.EnrollMfa(method, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.VerifyMfaChallenge(enrollmentId, ValidActor); @@ -478,7 +479,7 @@ public void VerifyMfaChallenge_WhenAlreadyVerified_ReturnsFailure() var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; var method = MfaMethod.Totp; user.EnrollMfa(method, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.VerifyMfaChallenge(enrollmentId, ValidActor); var result = user.VerifyMfaChallenge(enrollmentId, ValidActor); @@ -559,12 +560,16 @@ public void SetValidityPeriod_UpdatesPreviousExpiresAtInEvent() #region RecordAuthenticationAttempt + // ADR-UMS-095: parámetros de política de bloqueo por intentos fallidos usados por las pruebas. + private const int MaxAttempts = 3; + private const int LockoutMinutes = 15; + [Fact] public void RecordAuthenticationAttempt_RaisesAuthenticationAttemptedEvent() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; - user.RecordAuthenticationAttempt(true, "Valid credentials", "192.168.1.1", ValidActor); + user.RecordAuthenticationAttempt(true, DateTimeOffset.UtcNow, MaxAttempts, LockoutMinutes, "Valid credentials", "192.168.1.1", ValidActor); var events = user.DomainEvents.GetUncommittedChanges().ToList(); Assert.Contains(events, e => e is AuthenticationAttemptedEvent); @@ -575,13 +580,108 @@ public void RecordAuthenticationAttempt_WithFailedAttempt_RaisesEventWithSuccess { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; - user.RecordAuthenticationAttempt(false, "Invalid password", "192.168.1.1", ValidActor); + user.RecordAuthenticationAttempt(false, DateTimeOffset.UtcNow, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); var events = user.DomainEvents.GetUncommittedChanges().ToList(); var authEvent = events.OfType().First(); Assert.False(authEvent.Success); } + // ── ADR-UMS-095: bloqueo temporal de cuenta por intentos fallidos ────────────── + + [Fact] + public void RecordAuthenticationAttempt_AfterMaxFailedAttempts_LocksAccount() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + } + + Assert.True(user.IsLockedOut(now)); + Assert.Equal(MaxAttempts, user.FailedLoginAttempts); + Assert.Equal(now.AddMinutes(LockoutMinutes), user.LockedUntil); + } + + [Fact] + public void RecordAuthenticationAttempt_BelowThreshold_DoesNotLock() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + + Assert.False(user.IsLockedOut(now)); + Assert.Equal(1, user.FailedLoginAttempts); + Assert.Null(user.LockedUntil); + } + + [Fact] + public void RecordAuthenticationAttempt_Success_ResetsCounterAndLock() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + + user.RecordAuthenticationAttempt(true, now, MaxAttempts, LockoutMinutes, "Login successful", "192.168.1.1", ValidActor); + + Assert.Equal(0, user.FailedLoginAttempts); + Assert.Null(user.LockedUntil); + Assert.False(user.IsLockedOut(now)); + } + + [Fact] + public void RecordAuthenticationAttempt_WhileLocked_DoesNotIncrement() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + } + var lockedUntilAfterLock = user.LockedUntil; + + // Un intento fallido adicional dentro de la ventana no incrementa ni extiende el bloqueo. + user.RecordAuthenticationAttempt(false, now.AddMinutes(1), MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + + Assert.Equal(MaxAttempts, user.FailedLoginAttempts); + Assert.Equal(lockedUntilAfterLock, user.LockedUntil); + } + + [Fact] + public void IsLockedOut_AfterLockExpires_ReturnsFalse() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + } + + Assert.True(user.IsLockedOut(now)); + Assert.False(user.IsLockedOut(now.AddMinutes(LockoutMinutes).AddSeconds(1))); + } + + [Fact] + public void RecordAuthenticationAttempt_FailAfterLockExpired_StartsNewCount() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + } + var afterExpiry = now.AddMinutes(LockoutMinutes).AddSeconds(1); + + // Tras expirar el bloqueo, un nuevo fallo vuelve a contar (el contador sigue en curso hasta un login exitoso). + user.RecordAuthenticationAttempt(false, afterExpiry, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + + Assert.Equal(MaxAttempts + 1, user.FailedLoginAttempts); + } + #endregion #region RevokeEnrollment @@ -591,7 +691,7 @@ public void RevokeEnrollment_WithValidId_ReturnsSuccessAndRemovesEnrollment() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.EnrollMfa(MfaMethod.Totp, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); var result = user.RevokeEnrollment(enrollmentId, ValidActor); @@ -616,7 +716,7 @@ public void RevokeEnrollment_RaisesMfaEnrollmentRevokedEvent() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.EnrollMfa(MfaMethod.Totp, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.RevokeEnrollment(enrollmentId, ValidActor); @@ -638,7 +738,7 @@ public void HasVerifiedMfaEnrollment_WhenVerified_ReturnsTrue() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.EnrollMfa(MfaMethod.Totp, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.VerifyMfaChallenge(enrollmentId, ValidActor); Assert.True(user.HasVerifiedMfaEnrollment([MfaMethod.Totp])); @@ -657,7 +757,7 @@ public void HasVerifiedMfaEnrollment_WhenMethodNotAllowed_ReturnsFalse() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.EnrollMfa(MfaMethod.Totp, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.VerifyMfaChallenge(enrollmentId, ValidActor); Assert.False(user.HasVerifiedMfaEnrollment([MfaMethod.EmailOtp])); @@ -811,3 +911,5 @@ public void Deny_DeniedIsTerminalState_CannotBeActivated() #endregion } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/UserManagementDelegation/UserManagementDelegationTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/UserManagementDelegation/UserManagementDelegationTests.cs index 44137057..175a1fe9 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Identity/UserManagementDelegation/UserManagementDelegationTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/UserManagementDelegation/UserManagementDelegationTests.cs @@ -135,6 +135,55 @@ public void Activate_RaisesDelegationActivatedEvent() Assert.Contains(events, e => e is DelegationActivatedEvent); } + [Fact] + public void Activate_WhenRequiresApproval_IsBlockedFailClosed() + { + // G-056: una delegación con requiresApproval=true NO puede activarse directamente. + // La única vía legítima a Active es SubmitForApproval → Approve. + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, ValidDelegatingAdmin, ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, requiresApproval: true, ValidActor).Value; + + var result = delegation.Activate(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Delegation.ApprovalRequired, result.Error); + Assert.Equal(DelegationStatus.Draft, delegation.Status); + } + + [Fact] + public void Activate_WhenRequiresApproval_AndApproved_ReturnsSuccess() + { + // G-056: tras la aprobación, la delegación sí queda Active. La compuerta no impide el + // flujo legítimo, sólo el atajo que la eludía. + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, ValidDelegatingAdmin, ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, requiresApproval: true, ValidActor).Value; + + delegation.SubmitForApproval(Guid.NewGuid(), ValidActor); + var approveResult = delegation.Approve(ValidActor); + + Assert.True(approveResult.IsSuccess); + Assert.Equal(DelegationStatus.Active, delegation.Status); + } + + [Fact] + public void Activate_WhenRequiresApproval_AndSubmittedButNotApproved_IsBlocked() + { + // G-056: estando en PendingApproval, Activate directo sigue vedado mientras persista + // requiresApproval — sólo Approve puede promover a Active. + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, ValidDelegatingAdmin, ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, requiresApproval: true, ValidActor).Value; + + delegation.SubmitForApproval(Guid.NewGuid(), ValidActor); + var result = delegation.Activate(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Delegation.ApprovalRequired, result.Error); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + } + #endregion #region SubmitForApproval @@ -188,6 +237,49 @@ public void Approve_WhenNotPendingApproval_ReturnsFailure() Assert.True(result.IsFailure); } + // G-150 / INV-DEL8 (separación de funciones): el administrador delegante no puede autoaprobar + // su propia delegación. El aprobador (actorId) que coincide con DelegatingAdminId → falla, + // el estado NO transita a Active y el motivo es SelfApprovalNotAllowed. + [Fact] + public void Approve_WhenApproverIsDelegatingAdmin_ReturnsFailure() + { + var delegatingAdminId = Guid.NewGuid(); + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, + UserAccountId.Load(delegatingAdminId), + ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, true, + ActorId.Create(delegatingAdminId.ToString())).Value; + delegation.SubmitForApproval(Guid.NewGuid(), ActorId.Create(delegatingAdminId.ToString())); + + // El mismo administrador delegante intenta aprobar → SoD lo impide. + var result = delegation.Approve(ActorId.Create(delegatingAdminId.ToString())); + + Assert.True(result.IsFailure); + Assert.Contains("self_approval_not_allowed", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + } + + // Control positivo de la SoD: un aprobador DISTINTO del delegante sí puede aprobar. + [Fact] + public void Approve_WhenApproverIsDifferentFromDelegatingAdmin_ReturnsSuccess() + { + var delegatingAdminId = Guid.NewGuid(); + var approverId = Guid.NewGuid(); + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, + UserAccountId.Load(delegatingAdminId), + ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, true, + ActorId.Create(delegatingAdminId.ToString())).Value; + delegation.SubmitForApproval(Guid.NewGuid(), ActorId.Create(delegatingAdminId.ToString())); + + var result = delegation.Approve(ActorId.Create(approverId.ToString())); + + Assert.True(result.IsSuccess); + Assert.Equal(DelegationStatus.Active, delegation.Status); + } + #endregion #region Reject diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ActionCodeTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ActionCodeTests.cs index 65140ebb..cbc3d846 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ActionCodeTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ActionCodeTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -56,7 +56,7 @@ public void Create_WithCodeOver50Chars_HasBrokenRuleForTooLong() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.SystemSuite.ActionCodeTooLong, brokenRules.First().Message); + Assert.Contains(DomainErrors.SystemSuite.ActionCodeTooLong, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationKeyTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationKeyTests.cs index b99506fd..e3be7a1d 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationKeyTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationKeyTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -56,7 +56,7 @@ public void Create_WithKeyOver100Chars_HasBrokenRuleForTooLong() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.SystemSuite.ConfigurationKeyTooLong, brokenRules.First().Message); + Assert.Contains(DomainErrors.SystemSuite.ConfigurationKeyTooLong, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationValueTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationValueTests.cs index 5781c5e5..16e570f4 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationValueTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationValueTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -56,7 +56,7 @@ public void Create_WithValueOver2000Chars_HasBrokenRuleForTooLong() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.SystemSuite.ConfigurationValueTooLong, brokenRules.First().Message); + Assert.Contains(DomainErrors.SystemSuite.ConfigurationValueTooLong, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/CustomDomainTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/CustomDomainTests.cs index a73c23fe..8cf3d361 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/CustomDomainTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/CustomDomainTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -55,7 +55,7 @@ public void Create_WithInvalidDomain_HasBrokenRuleForInvalidFormat() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.Branding.InvalidCustomDomain, brokenRules.First().Message); + Assert.Contains(DomainErrors.Branding.InvalidCustomDomain, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/EmailTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/EmailTests.cs index 30a90b18..51764c1e 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/EmailTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/EmailTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -55,7 +55,7 @@ public void Create_WithInvalidEmailFormat_HasBrokenRuleForInvalidEmail() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.UserAccount.InvalidEmail, brokenRules.First().Message); + Assert.Contains(DomainErrors.UserAccount.InvalidEmail, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/GenericStringValidatorTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/GenericStringValidatorTests.cs index 799918bf..ed72e6fb 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/GenericStringValidatorTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/GenericStringValidatorTests.cs @@ -14,7 +14,7 @@ public void AddRules_WithRequiredAndEmptyValue_AddsBrokenRule() var brokenRules = vo.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/HexColorTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/HexColorTests.cs index 47407c8c..e5293afb 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/HexColorTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/HexColorTests.cs @@ -53,7 +53,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -72,7 +72,7 @@ public void Create_WithInvalidHex_HasBrokenRuleForInvalidFormat() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.Branding.InvalidHexColor, brokenRules.First().Message); + Assert.Contains(DomainErrors.Branding.InvalidHexColor, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/IdValueObjectTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/IdValueObjectTests.cs index 5a6dd216..811cefc7 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/IdValueObjectTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/IdValueObjectTests.cs @@ -702,35 +702,6 @@ public void BranchId_Load_FromString_ReturnsParsedGuid() #endregion - #region BrandingId - - [Fact] - public void BrandingId_Create_GeneratesNewGuid() - { - var id1 = BrandingId.Create(); - var id2 = BrandingId.Create(); - - Assert.NotEqual(id1, id2); - } - - [Fact] - public void BrandingId_Load_FromGuid_ReturnsSameGuid() - { - var id = BrandingId.Load(TestGuid); - - Assert.Equal(TestGuid, id.GetValue()); - } - - [Fact] - public void BrandingId_Load_FromString_ReturnsParsedGuid() - { - var id = BrandingId.Load("12345678-1234-1234-1234-123456789abc"); - - Assert.Equal(TestGuid, id.GetValue()); - } - - #endregion - #region DocumentTypeId [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LoginTextTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LoginTextTests.cs index cbef6f54..413162a7 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LoginTextTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LoginTextTests.cs @@ -56,7 +56,7 @@ public void Create_WithTextOver200Chars_HasBrokenRuleForTooLong() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.Branding.LoginTextTooLong, brokenRules.First().Message); + Assert.Contains(DomainErrors.Branding.LoginTextTooLong, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LogoTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LogoTests.cs index fc5e1be9..adf95a7e 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LogoTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LogoTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/TemplateVersionTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/TemplateVersionTests.cs index 720d857b..77f6324d 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/TemplateVersionTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/TemplateVersionTests.cs @@ -39,4 +39,30 @@ public void Initial_ReturnsVersion010() Assert.Equal("0.1.0", result.GetValue()); Assert.Empty(result.BrokenRules.GetBrokenRules()); } + + [Fact] + public void Segments_ParseSemVer() + { + var version = TemplateVersion.Create(3, 4, 5); + + Assert.Equal(3, version.Major); + Assert.Equal(4, version.Minor); + Assert.Equal(5, version.Patch); + } + + [Fact] + public void Next_IncrementsMinorAndResetsPatch() + { + Assert.Equal("0.2.0", TemplateVersion.Initial().Next().GetValue()); + Assert.Equal("1.3.0", TemplateVersion.Create(1, 2, 7).Next().GetValue()); + } + + [Fact] + public void CompareTo_OrdersNumericallyBySegment() + { + Assert.True(TemplateVersion.Create(2, 0, 0).CompareTo(TemplateVersion.Create(10, 0, 0)) < 0); + Assert.True(TemplateVersion.Create(1, 2, 0).CompareTo(TemplateVersion.Create(1, 1, 9)) > 0); + Assert.Equal(0, TemplateVersion.Create(1, 1, 1).CompareTo(TemplateVersion.Create(1, 1, 1))); + Assert.True(TemplateVersion.Initial().CompareTo(null) > 0); + } } diff --git a/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicy.cs b/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicy.cs index a3e2523c..0d3a4085 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicy.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicy.cs @@ -11,6 +11,7 @@ private AccessEnforcementPolicy(AccessEnforcementPolicyProps props) : base(props public RoleId? RoleId => Props.RoleId; public AccessEnforcementAction EnforcementAction => Props.EnforcementAction; public bool IsActive => Props.IsActive; + public int GracePeriodDays => Props.GracePeriodDays; public AccessEnforcementPolicyId GetId() => AccessEnforcementPolicyId.Load(Props.Id.GetValue()); @@ -19,14 +20,21 @@ public static Result Create( ProfileId? profileId, RoleId? roleId, AccessEnforcementAction enforcementAction, - ActorId createdBy) + ActorId createdBy, + int gracePeriodDays = 0) { if (profileId is null && roleId is null) { return Result.Failure(DomainErrors.Approvals.PolicyRequiresProfileOrRole); } - var props = new AccessEnforcementPolicyProps(IdValueObject.Create(), tenantId, profileId, roleId, enforcementAction, true, createdBy); + // G-120: el periodo de gracia no puede ser negativo (0 = enforcement inmediato). + if (gracePeriodDays < 0) + { + return Result.Failure(DomainErrors.Approvals.GracePeriodInvalid); + } + + var props = new AccessEnforcementPolicyProps(IdValueObject.Create(), tenantId, profileId, roleId, enforcementAction, true, gracePeriodDays, createdBy); var policy = new AccessEnforcementPolicy(props); if (!policy.IsValid()) @@ -57,6 +65,21 @@ public Result Deactivate(ActorId updatedBy) public Result UpdateAction(AccessEnforcementAction newAction, ActorId updatedBy) { + // Irreversibilidad (G-051): Deactivate es una transicion terminal — el agregado no + // expone reactivacion — por lo que una politica desactivada queda congelada. Mutar + // su accion de enforcement sobre un estado terminal es una transicion invalida: + // se rechaza fail-closed, coherente con el patron de estados terminales de los + // agregados de Configuration (FlagArchivedCannotChange, AppConfigAlreadyArchived). + if (!IsActive) + { + BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Approvals.PolicyInactiveCannotUpdate)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + Props.EnforcementAction = newAction; TrackingState.MarkAsDirty(); Props.Audit.Update(updatedBy.GetValue()); diff --git a/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyProps.cs b/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyProps.cs index 46d5ca58..3aeb706c 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyProps.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyProps.cs @@ -8,6 +8,9 @@ public class AccessEnforcementPolicyProps : IProps public RoleId? RoleId { get; set; } public AccessEnforcementAction EnforcementAction { get; set; } public bool IsActive { get; set; } + // G-120 (FR-053): periodo de gracia en días antes de que la acción de enforcement se aplique a un + // documento crítico vencido/faltante. 0 = enforcement inmediato (comportamiento previo). + public int GracePeriodDays { get; set; } public AuditValueObject Audit { get; private set; } public AccessEnforcementPolicyProps( @@ -17,6 +20,7 @@ public AccessEnforcementPolicyProps( RoleId? roleId, AccessEnforcementAction enforcementAction, bool isActive, + int gracePeriodDays, ActorId createdBy) { Id = id; @@ -25,6 +29,7 @@ public AccessEnforcementPolicyProps( RoleId = roleId; EnforcementAction = enforcementAction; IsActive = isActive; + GracePeriodDays = gracePeriodDays; Audit = AuditValueObject.Create(createdBy.GetValue()); } diff --git a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/ApprovalRequest.cs b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/ApprovalRequest.cs index 50dbb985..c1391f0f 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/ApprovalRequest.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/ApprovalRequest.cs @@ -65,11 +65,9 @@ public Result Approve(ActorId approvedBy, RoleId grantedRoleId, string? decision Props.Status = ApprovalStatus.Approved; Props.GrantedRoleId = grantedRoleId; Props.DecisionReason = decisionReason; - DomainEvents.RaiseEvent(new ApprovalRequestApprovedEvent( - Props.Id.GetValue(), - Props.WorkflowId.GetValue(), - approvedBy.GetValue(), - DateTime.UtcNow)); + // El agregado no emite eventos de dominio en la transicion: el despacho es + // responsabilidad del handler/flujo de aprobacion. Emitirlos aqui provocaba + // la publicacion previa al guardado y dejaba la solicitud en Pending (G-051). TrackingState.MarkAsDirty(); Props.Audit.Update(approvedBy.GetValue()); return Result.Success(); @@ -85,12 +83,8 @@ public Result Reject(ActorId rejectedBy, string? decisionReason = null) Props.Status = ApprovalStatus.Rejected; Props.DecisionReason = decisionReason; - DomainEvents.RaiseEvent(new ApprovalRequestRejectedEvent( - Props.Id.GetValue(), - Props.WorkflowId.GetValue(), - rejectedBy.GetValue(), - decisionReason ?? string.Empty, - DateTime.UtcNow)); + // Simetrico con Approve: la transicion no emite eventos de dominio; el + // despacho corresponde al handler/flujo de aprobacion (G-051). TrackingState.MarkAsDirty(); Props.Audit.Update(rejectedBy.GetValue()); return Result.Success(); diff --git a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/Events/ApprovalRequestDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/Events/ApprovalRequestDomainEventsManager.cs index 65cc796d..0932b53b 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/Events/ApprovalRequestDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/Events/ApprovalRequestDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Approvals.ApprovalRequest.Events; public class ApprovalRequestDomainEventsManager : DomainEventsManager @@ -9,3 +10,5 @@ private void Apply(ApprovalRequestRejectedEvent @event) { } private void Apply(ProfileAssignedToUserEvent @event) { } private void Apply(ApprovalRequestCancelledEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/ApprovalWorkflow.cs b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/ApprovalWorkflow.cs index 59de75a0..f2ecc0c7 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/ApprovalWorkflow.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/ApprovalWorkflow.cs @@ -55,7 +55,7 @@ public static Result Create( public Result AddRequiredDocument(DocumentTypeId documentTypeId, bool isMandatory, ActorId createdBy) { - if (_requiredDocuments.Any(d => d.DocumentTypeId == documentTypeId)) + if (_requiredDocuments.Any(d => d.DocumentTypeId.Equals(documentTypeId))) { BrokenRules.Add(new BrokenRule(nameof(RequiredDocuments), DomainErrors.Approvals.DocumentTypeAlreadyRequired)); } @@ -112,7 +112,10 @@ public Result RemoveRequiredDocument(IdValueObject documentId, ActorId updatedBy private Result FindRequiredDocument(IdValueObject documentId) { - var document = _requiredDocuments.FirstOrDefault(d => d.Id.GetValue() == documentId.GetValue()); + // AT06/F1 (misma clase de bug): identidad canónica = Props.Id; el Id base de Entity<> se + // regenera aleatorio en cada construcción y la rehidratación no llama SetId → buscar por d.Id + // fallaba tras recargar (remover documento requerido por id → 404). Cf. FindMfaEnrollment. + var document = _requiredDocuments.FirstOrDefault(d => d.Props.Id.GetValue() == documentId.GetValue()); return document is null ? Result.Failure(DomainErrors.Common.NotFound) : Result.Success(document); diff --git a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/Events/ApprovalWorkflowDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/Events/ApprovalWorkflowDomainEventsManager.cs index dddbdfac..37dfeaf0 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/Events/ApprovalWorkflowDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/Events/ApprovalWorkflowDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Approvals.ApprovalWorkflow.Events; public class ApprovalWorkflowDomainEventsManager : DomainEventsManager @@ -9,4 +10,5 @@ private void Apply(ApprovalWorkflowDocumentAddedEvent @event) { } private void Apply(ApprovalWorkflowDocumentRemovedEvent @event) { } private void Apply(ApprovalWorkflowActivatedEvent @event) { } private void Apply(ApprovalWorkflowDeactivatedEvent @event) { } -} \ No newline at end of file +} +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Approvals/DocumentType/Events/DocumentTypeDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Approvals/DocumentType/Events/DocumentTypeDomainEventsManager.cs index 85becd64..1c349d56 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/DocumentType/Events/DocumentTypeDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/DocumentType/Events/DocumentTypeDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Approvals.DocumentType.Events; public class DocumentTypeDomainEventsManager : DomainEventsManager @@ -7,3 +8,5 @@ public DocumentTypeDomainEventsManager(IAggregateRoot aggregateRoot) : base(aggr private void Apply(DocumentTypeRegisteredEvent @event) { } private void Apply(DocumentTypeUpdatedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Approvals/RequiredDocumentChecklist.cs b/src/apps/ums.api/Ums.Domain/Approvals/RequiredDocumentChecklist.cs new file mode 100644 index 00000000..6b6b6a4d --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Approvals/RequiredDocumentChecklist.cs @@ -0,0 +1,47 @@ +namespace Ums.Domain.Approvals; + +using Ums.Domain.Enums; +using ApprovalWorkflowAggregate = Ums.Domain.Approvals.ApprovalWorkflow.ApprovalWorkflow; +using UserDocumentAggregate = Ums.Domain.Approvals.UserDocument.UserDocument; + +/// +/// Politica de dominio (pura, sin E/S) que hace cumplir el checklist de documentos +/// requeridos de un contra los documentos del +/// usuario objetivo al momento de aprobar una solicitud (G-051 F4). +/// +/// Es una regla de dominio cross-agregado de SOLO LECTURA: cruza el checklist declarado +/// por el workflow con el estado de los del usuario. +/// No muta ningun agregado, por lo que no introduce una excepcion a D-016; el handler +/// que la invoca sigue mutando un unico agregado de negocio (ApprovalRequest) en su +/// transaccion. La lectura precede a la transicion y es fail-closed. +/// +/// Semantica fail-closed: si un tipo de documento marcado como obligatorio no cuenta con +/// un UserDocument en estado para el usuario objetivo, +/// la evaluacion falla y la aprobacion no puede proceder. +/// +public static class RequiredDocumentChecklist +{ + /// + /// Evalua la completitud del checklist obligatorio. Devuelve Result.Success solo + /// cuando cada documento obligatorio del workflow tiene un UserDocument valido del usuario. + /// + /// Workflow que declara el checklist de documentos requeridos. + /// Documentos del usuario objetivo de la solicitud. + public static Result Evaluate( + ApprovalWorkflowAggregate workflow, + IReadOnlyCollection targetUserDocuments) + { + var validDocumentTypeIds = targetUserDocuments + .Where(document => document.Status == DocumentStatus.Valid) + .Select(document => document.DocumentTypeId.GetValue()) + .ToHashSet(); + + var hasMissingMandatoryDocument = workflow.RequiredDocuments + .Where(required => required.IsMandatory) + .Any(required => !validDocumentTypeIds.Contains(required.DocumentTypeId.GetValue())); + + return hasMissingMandatoryDocument + ? Result.Failure(DomainErrors.Approvals.RequiredDocumentsIncomplete) + : Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/Events/UserDocumentDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/Events/UserDocumentDomainEventsManager.cs index 93e3565a..7336c5c5 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/Events/UserDocumentDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/Events/UserDocumentDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Approvals.UserDocument.Events; public class UserDocumentDomainEventsManager : DomainEventsManager @@ -11,3 +12,5 @@ private void Apply(DocumentExpiredEvent @event) { } private void Apply(DocumentNearExpirationEvent @event) { } private void Apply(EnforcementExecutedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/UserDocument.cs b/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/UserDocument.cs index 97bb4598..fe72e0fb 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/UserDocument.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/UserDocument.cs @@ -113,10 +113,16 @@ public Result Reject(string rejectionReason, ActorId rejectedBy) // Background worker expires: VALID → EXPIRED (INV-UD3) public Result Expire(ActorId actor) { + // INV-UD3: EXPIRED solo es alcanzable desde VALID. Se rechaza cualquier salto + // del ciclo de vida (PENDING_REVIEW → EXPIRED o REJECTED → EXPIRED). if (Status == DocumentStatus.Expired) { BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.Compliance.DocumentAlreadyExpired)); } + else if (Status != DocumentStatus.Valid) + { + BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.Compliance.DocumentCannotTransition)); + } if (!IsValid()) { diff --git a/src/apps/ums.api/Ums.Domain/Authorization/AssignmentRule/Events/TemplateAssignmentRuleDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Authorization/AssignmentRule/Events/TemplateAssignmentRuleDomainEventsManager.cs index c5b1134c..7519353d 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/AssignmentRule/Events/TemplateAssignmentRuleDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/AssignmentRule/Events/TemplateAssignmentRuleDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Authorization.AssignmentRule.Events; using Ums.Domain.Events; @@ -10,3 +11,5 @@ private void Apply(AssignmentRuleCreatedEvent @event) { } private void Apply(AssignmentRuleDeactivatedEvent @event) { } private void Apply(AssignmentRuleReactivatedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/AuthorizationGraph.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/AuthorizationGraph.cs index 99f11599..4d324898 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Graph/AuthorizationGraph.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/AuthorizationGraph.cs @@ -26,12 +26,41 @@ public sealed record AuthorizationGraph /// Who is authenticated and in which context (tenant, suite, role, branch). public GraphContext Context { get; init; } + /// + /// G-043 — true cuando el usuario está autenticado y aprobado pero AÚN NO tiene un perfil activo + /// (onboarding pendiente). En ese caso el grafo es un "lobby": trae User y + /// Tenant reales pero SystemSuite/Role/Profile en null, y Actions/MenuAccess/DomainPermissions/Scopes + /// vacíos. El cliente debe detectar esta bandera y mostrar el flujo de onboarding en lugar de la app, + /// en vez de tratar el login como fallido. Por defecto false (usuarios con perfil). + /// + public bool OnboardingPending { get; init; } + + /// + /// Discriminador cerrado del estado de acceso (ADR-0156 §5.1). Existe porque + /// no basta desde que la autenticación admite filtrar por + /// sistema: «el usuario no tiene ningún perfil» y «tiene perfiles, pero ninguno en el sistema + /// que se pidió» son estados distintos, y responder el segundo como el primero le mostraría un + /// flujo de alta a alguien que ya está de alta. + /// + /// se conserva y equivale a + /// : un consumidor de 2.0–2.3 sigue + /// funcionando sin tocarlo. Los dos no pueden divergir porque los deriva + /// de la misma entrada. + /// + public GraphAccessState AccessState { get; init; } = GraphAccessState.Granted; + /// How the user authenticated and session timing. public GraphAuthentication Authentication { get; init; } /// All actions registered in the SystemSuite — the full action catalogue. public IReadOnlyList Actions { get; init; } + /// + /// Perfiles activos del usuario en este inquilino, con el vigente marcado. Permite al cliente + /// ofrecer el cambio de perfil sin una llamada adicional. Vacía en el grafo lobby (G-043). + /// + public IReadOnlyList Profiles { get; init; } = []; + /// /// Module → Menu → SubMenu → Option tree with resolved permission per option. /// Only modules that have at least one reachable (Allow) option are included. @@ -52,6 +81,20 @@ public sealed record AuthorizationGraph /// Effective tenant configuration resolved with tenant-level override precedence. public GraphEffectiveConfig EffectiveConfig { get; init; } + /// + /// Ajustes del sistema marcados como visibles para el cliente, agrupados por espacio de + /// nombres: branding, tema, disposición, idioma, parámetros funcionales… + /// + /// Es el bloque que permite al cliente inicializar la aplicación —logotipo, colores, página + /// inicial, idioma— sin una llamada adicional, y es EXTENSIBLE: añadir un espacio de nombres + /// nuevo no cambia la forma del contrato, solo aparece otra clave. + /// + /// Solo viaja lo marcado explícitamente (G-178). `AppSetting` es una bolsa clave/valor sin + /// tipo donde junto al color de la marca puede haber una cadena de conexión. + /// + public IReadOnlyDictionary> Settings { get; init; } + = new Dictionary>(); + /// /// OAuth2-style scopes derived from all Allow permissions. /// Format: "resourceCode.actionCode" (lowercase), e.g. "users.read", "inventory.write". @@ -80,14 +123,25 @@ public static AuthorizationGraph Build( IReadOnlyList featureFlags, GraphEffectiveConfig effectiveConfig, IReadOnlyList scopes, - DateTime generatedAt) + DateTime generatedAt, + bool onboardingPending = false, + IReadOnlyList? profiles = null, + IReadOnlyDictionary>? settings = null, + GraphAccessState accessState = GraphAccessState.Granted) { var validUntil = generatedAt.AddMinutes(effectiveConfig.SessionTimeoutMinutes); + // `onboardingPending` manda sobre `accessState`: son el mismo hecho contado dos veces —una + // para los consumidores de 2.0–2.3 y otra para los de 2.4— y derivarlos aquí es lo único + // que garantiza que ningún llamante pueda emitir un grafo donde se contradigan. + var estado = onboardingPending ? GraphAccessState.OnboardingPending : accessState; + return new AuthorizationGraph { SchemaVersion = global::Ums.Sdk.Contracts.SchemaVersion.Current, Context = context, + OnboardingPending = onboardingPending, + AccessState = estado, Authentication = authentication, Actions = actions, MenuAccess = menuAccess, @@ -95,6 +149,8 @@ public static AuthorizationGraph Build( FeatureFlags = featureFlags, EffectiveConfig = effectiveConfig, Scopes = scopes, + Profiles = profiles ?? [], + Settings = settings ?? new Dictionary>(), GeneratedAt = generatedAt, ValidUntil = validUntil, }; diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphAccessState.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphAccessState.cs new file mode 100644 index 00000000..6255a661 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphAccessState.cs @@ -0,0 +1,30 @@ +namespace Ums.Domain.Authorization.Graph; + +/// +/// Estado de acceso del grafo (ADR-0156 §5.1). Enumeración CERRADA: el contrato publicado la +/// declara con `enum` en `auth-graph.schema.json`, así que añadir un valor es un cambio MAYOR de +/// esquema, no una ampliación silenciosa. +/// +public enum GraphAccessState +{ + /// + /// Hay perfil vigente: el grafo lleva navegación, permisos y ámbitos. `onboardingPending` + /// es `false`. + /// + Granted = 0, + + /// + /// El usuario TIENE perfiles activos en el inquilino, pero NINGUNO en el sistema que se pidió + /// —o el sistema pedido no existe, que es indistinguible por construcción: el filtro se aplica + /// sobre los sistemas de los perfiles del usuario y NUNCA consulta el catálogo por código + /// (ADR-0156 §6). `onboardingPending` es `false`: la cuenta está dada de alta y mostrarle un + /// flujo de onboarding sería mentirle. + /// + NoProfileInSystem = 1, + + /// + /// El usuario no tiene NINGÚN perfil activo en el inquilino (G-043, grafo lobby). + /// `onboardingPending` es `true`. + /// + OnboardingPending = 2, +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphContext.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphContext.cs index dd72cc86..5e747755 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphContext.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphContext.cs @@ -9,10 +9,23 @@ namespace Ums.Domain.Authorization.Graph; public sealed record GraphContext( GraphUser User, GraphTenant Tenant, - GraphSystemSuite SystemSuite, - GraphRole Role, - GraphProfile Profile, - GraphBranch? Branch); // null when Scope == OrgWide + GraphSystemSuite? SystemSuite, // null en el grafo lobby (onboarding pendiente, G-043) + GraphRole? Role, // null en el grafo lobby + GraphProfile? Profile, // null en el grafo lobby + GraphBranch? Branch, // null when Scope == OrgWide + GraphRequestedSystem? RequestedSystem = null); // eco del sistema pedido (ADR-0156 §2.5) + +/// +/// Eco literal del sistema que el cliente pidió al autenticarse, ya normalizado. Es `null` cuando +/// no pidió ninguno (portal multiproducto). +/// +/// NO es una lectura del catálogo: no revela nada que el llamante no supiera ya, porque es su +/// propia entrada devuelta. Existe para que el cliente pueda decir «no tiene acceso a SDLC» sin +/// llevar su configuración al navegador, y para que un grafo capturado como evidencia sea +/// autodescriptivo — ante un grafo de otra suite, hoy no hay forma de saber si se pidió algo +/// distinto. +/// +public sealed record GraphRequestedSystem(string Code); public sealed record GraphUser( [property: JsonIgnore] Guid Id, @@ -51,3 +64,26 @@ public sealed record GraphBranch( [property: JsonIgnore] Guid Id, string Code, [property: JsonPropertyName("value")] string Name); + +/// +/// Un perfil al que el usuario autenticado tiene acceso: la combinación de un rol dentro de un +/// sistema, opcionalmente acotada a una sucursal. +/// +/// Viaja la lista completa para que el cliente pueda ofrecer el cambio de perfil sin preguntar de +/// nuevo (G-177). Antes se elegía uno y los demás se descartaban sin dejar rastro: el usuario no +/// sabía siquiera que existían. +/// +/// El sistema NO sale del perfil —que no lo guarda— sino de su rol, que pertenece a exactamente +/// uno. Se proyecta aquí porque el cliente lo necesita para rotular el selector. +/// +public sealed record GraphProfileOption( + [property: JsonIgnore] Guid Id, + string SystemCode, + [property: JsonPropertyName("systemValue")] string SystemName, + string RoleCode, + [property: JsonPropertyName("roleValue")] string RoleName, + int HierarchyLevel, + string? BranchCode, + [property: JsonPropertyName("branchValue")] string? BranchName, + string Scope, + bool IsCurrent); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphMenuAccess.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphMenuAccess.cs index c55ebbe1..74796967 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphMenuAccess.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphMenuAccess.cs @@ -3,37 +3,60 @@ namespace Ums.Domain.Authorization.Graph; /// -/// The complete SystemSuite menu hierarchy with effective permissions per option. -/// Each option carries the resolved effect (Allow/Deny/NotGranted) and its source -/// (Template or Override), giving the client system everything it needs to render -/// and enforce access at the UI level without re-querying UMS. +/// Navegación concedida de un módulo, como árbol recursivo. +/// +/// SUSTITUYE a la cadena rígida Módulo→Menú→Submenú→Opción que se proyectaba antes. Aquella +/// recorría exactamente tres niveles literales, de modo que un nodo colgado a otra profundidad +/// —topología que el modelo admite (ADR-0090) y que la API aceptaba— se persistía, se veía en la +/// administración y NO llegaba nunca al usuario: desaparecía del grafo sin error ni traza (G-171). +/// +/// Dos cambios de fondo respecto de la forma anterior: +/// +/// 1. **Profundidad libre.** El cliente recorre hasta +/// agotarlo, sin presuponer tres niveles. +/// 2. **Una opción, una entrada.** Antes una opción con cinco acciones producía cinco filas +/// idénticas salvo el actionCode. Ahora es un nodo con su lista de acciones. /// public sealed record GraphMenuModule( [property: JsonIgnore] Guid Id, - string Code, + string Code, [property: JsonPropertyName("value")] string Name, - int SortOrder, - string Status, - IReadOnlyList Menus); + int SortOrder, + string Status, + // Identificador de icono del módulo (no un recurso). Nulo si no se configuró. + string? Icon, + IReadOnlyList Nodes); -public sealed record GraphMenu( - [property: JsonIgnore] Guid Id, - string Code, - [property: JsonPropertyName("value")] string Label, - int SortOrder, - IReadOnlyList SubMenus); - -public sealed record GraphSubMenu( +/// +/// Nodo del árbol de navegación. clasifica su papel (Menu, SubMenu, Option) sin +/// fijar su profundidad. +/// +/// Solo viajan los nodos ALCANZABLES: una hoja sin ninguna acción concedida se omite, y una rama +/// que se queda sin hojas se omite con ella. La ausencia significa «no concedido», que es la misma +/// semántica fail-closed que ya regía (G-039); enviar decenas de filas NotGranted era +/// repetir en cada login lo que el contrato ya dice una vez. +/// +public sealed record GraphNavigationNode( [property: JsonIgnore] Guid Id, - string Code, - [property: JsonPropertyName("value")] string Label, - int SortOrder, - IReadOnlyList Options); + string Code, + [property: JsonPropertyName("value")] string Name, + string Kind, + int SortOrder, + /// Identificador de icono, no un recurso: el catálogo gráfico lo decide el cliente. + string? Icon, + /// Ruta destino. Sin ella el cliente sabe qué pintar pero no a dónde llevar. + string? Route, + IReadOnlyList Actions, + IReadOnlyList Children); -public sealed record GraphMenuOption( - [property: JsonIgnore] Guid Id, - string Code, - [property: JsonPropertyName("value")] string Label, +/// +/// Una acción concedida —o denegada explícitamente— sobre un nodo hoja. +/// +/// Deny viaja porque no es lo mismo que la ausencia: una denegación explícita gana sobre +/// cualquier concesión heredada, y el cliente necesita poder mostrarla como bloqueada en vez de +/// como inexistente. +/// +public sealed record GraphNodeAction( string ActionCode, - AccessEffect Effect, + AccessEffect Effect, PermissionSource Source); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphNavigation.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphNavigation.cs new file mode 100644 index 00000000..df783282 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphNavigation.cs @@ -0,0 +1,36 @@ +namespace Ums.Domain.Authorization.Graph; + +/// +/// Recorrido del árbol de navegación, en un solo sitio. +/// +/// El árbol admite cualquier profundidad (ADR-0090). Antes cada consumidor lo recorría con tres +/// bucles anidados escritos a mano —el constructor, el emisor de claims, los cuatro serializadores, +/// los endpoints— y todos compartían el mismo defecto: perdían en silencio lo que no encajara en +/// tres niveles (G-171). Un recorrido único evita que la próxima corrección haya que aplicarla +/// siete veces. +/// +public static class GraphNavigation +{ + /// Aplana el árbol en profundidad, en el orden en que se pinta. + public static IEnumerable Flatten(IEnumerable nodes) + { + foreach (var node in nodes) + { + yield return node; + foreach (var child in Flatten(node.Children)) yield return child; + } + } + + /// Todos los nodos de navegación del grafo, de todos sus módulos. + public static IEnumerable AllNodes(AuthorizationGraph graph) + => graph.MenuAccess.SelectMany(m => Flatten(m.Nodes)); + + /// + /// Pares (código de nodo, código de acción) con efecto Allow. Es la forma en que el + /// resto del sistema expresa «lo que este perfil puede hacer». + /// + public static IEnumerable<(string Code, string ActionCode)> AllowedPairs(AuthorizationGraph graph) + => AllNodes(graph) + .SelectMany(n => n.Actions.Where(a => a.Effect == AccessEffect.Allow), + (n, a) => (n.Code, a.ActionCode)); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/IAuthorizationGraphBuilder.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/IAuthorizationGraphBuilder.cs index 9bd55aa1..0ea0a352 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Graph/IAuthorizationGraphBuilder.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/IAuthorizationGraphBuilder.cs @@ -17,21 +17,38 @@ public interface IAuthorizationGraphBuilder /// The authenticated user — already fetched by the command handler. /// The tenant in which the user authenticated. /// The resolved auth method (Local or IDP). + /// + /// Código del sistema que pide el grafo, OPCIONAL (ADR-0156 §3.2). Ausente —portal + /// multiproducto— el grafo se arma sobre todos los perfiles del usuario; presente, solo sobre + /// los perfiles cuyo sistema coincide. El filtro se aplica SOBRE LOS PERFILES DEL USUARIO y + /// tiene prohibido consultar el catálogo de sistemas por código: es lo que hace que un código + /// inexistente y uno sin perfil sean indistinguibles por construcción, sin dos ramas que + /// puedan divergir en un mensaje, un estado o un tiempo (ADR-0156 §6). + /// /// Cancellation token. Task> BuildAsync( UserAccountAggregate userAccount, Guid tenantId, AuthMethod authMethod, + string? systemCode = null, CancellationToken cancellationToken = default); /// /// Builds the authorization graph for a specific profile of the given user and tenant. /// Used by the admin preview flow to avoid resolving a different active profile. /// + /// + /// Acota el bloque `profiles` del grafo al sistema indicado, igual que en + /// . El PERFIL VIGENTE no lo elige este filtro —llega dado en + /// —, pero la lista de perfiles disponibles sí debe respetarlo: + /// devolverla completa entregaría a un satélite acotado a un sistema el inventario de los + /// demás sistemas en los que ese usuario trabaja (ADR-0156 §2.5). + /// Task> BuildForProfileAsync( UserAccountAggregate userAccount, Guid tenantId, Guid profileId, AuthMethod authMethod, + string? systemCode = null, CancellationToken cancellationToken = default); } diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Profile/Events/ProfileDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Authorization/Profile/Events/ProfileDomainEventsManager.cs index 9f32a64c..e0eced0d 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Profile/Events/ProfileDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Profile/Events/ProfileDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Authorization.Profile; public class ProfileDomainEventsManager : DomainEventsManager @@ -6,8 +7,14 @@ public ProfileDomainEventsManager(IAggregateRoot aggregateRoot) : base(aggregate private void Apply(ProfileCreatedEvent @event) { } private void Apply(TemplateLinkedToProfileEvent @event) { } + // Sin este Apply, CreateProfileCommandHandler.MaterializeAutoAssignedTemplateAsync revienta + // con InvalidOperationException al emitir el evento (el manager exige un Apply por tipo). + private void Apply(TemplateAutoAssignedEvent @event) { } private void Apply(PermissionOverriddenEvent @event) { } private void Apply(PermissionStatusChangedEvent @event) { } private void Apply(ProfileDeactivatedEvent @event) { } private void Apply(ProfileActivatedEvent @event) { } + private void Apply(ProfileRoleChangedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Profile/Profile.cs b/src/apps/ums.api/Ums.Domain/Authorization/Profile/Profile.cs index 2ef03ee6..33176cad 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Profile/Profile.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Profile/Profile.cs @@ -65,7 +65,12 @@ public Result AssignTemplate(PermissionTemplateEntity template, ActorId assigned BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Authorization.ProfileAlreadyInactive)); } - if (template.TenantId != TenantId) + // G-043 (causa raíz): comparar por VALOR (Guid subyacente). El operador != de los + // value objects de identidad (IdValueObject) resuelve por REFERENCIA, de modo que dos + // instancias con el mismo Guid se consideraban distintas y esta guarda marcaba SIEMPRE + // template_tenant_mismatch: AssignTemplate nunca materializaba (permissionCount=0) y el + // fallo se enmascaraba. El resto del dominio ya compara identidades vía GetValue(). + if (template.TenantId.GetValue() != TenantId.GetValue()) { BrokenRules.Add(new BrokenRule(nameof(Template), DomainErrors.Authorization.TemplateTenantMismatch)); } @@ -77,7 +82,7 @@ public Result AssignTemplate(PermissionTemplateEntity template, ActorId assigned var templateId = TemplateId.Load(template.GetId().GetValue()); - if (_permissions.Any(p => p.TemplateId.Equals(templateId))) + if (_permissions.Any(p => p.TemplateId.GetValue() == templateId.GetValue())) { BrokenRules.Add(new BrokenRule(nameof(Permissions), DomainErrors.Authorization.ProfileTemplateAlreadyLinked)); } @@ -87,7 +92,15 @@ public Result AssignTemplate(PermissionTemplateEntity template, ActorId assigned return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - foreach (var templateItem in template.Items) + // Se materializa desde ActiveItems, NO desde Items. + // + // Es la mitad que de verdad importa del borrado lógico. Mientras retirar un ítem lo borraba + // de la colección, iterar `Items` ya excluía lo retirado por construcción. Al dejar de + // borrar, `Items` pasa a incluir también las concesiones retiradas: seguir iterándola las + // copiaría al perfil como permisos ACTIVOS y el grafo resuelto concedería por ellas. Habríamos + // cambiado un borrado por una brecha — y silenciosa, porque el operador ve el ítem apagado + // en la plantilla mientras el usuario ejerce el permiso. + foreach (var templateItem in template.ActiveItems) { var targetId = IdValueObject.Load(templateItem.TargetId.GetValue()); var actionId = ActionId.Load(templateItem.ActionId.GetValue()); @@ -200,6 +213,42 @@ public Result Deactivate(ActorId updatedBy) return Result.Success(); } + /// + /// ADR-UMS-096: reasigna el rol del perfil. Es el efecto real de una promoción de rol IGA + /// (RolePromotionRequest.Execute), aplicado como paso diferido post-commit en su propia + /// transacción (D-016: un agregado por transacción). Invariantes: el perfil debe estar activo y + /// el rol destino debe diferir del actual. Devuelve (nunca excepción). + /// + public Result ChangeRole(RoleId newRoleId, ActorId updatedBy) + { + if (!IsActive) + { + BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Authorization.ProfileAlreadyInactive)); + } + + if (newRoleId.GetValue() == Props.RoleId.GetValue()) + { + BrokenRules.Add(new BrokenRule(nameof(RoleId), DomainErrors.Authorization.ProfileRoleUnchanged)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + var previousRoleId = Props.RoleId.GetValue(); + SetProps(Props.WithRoleId(newRoleId)); + DomainEvents.RaiseEvent(new ProfileRoleChangedEvent( + Props.Id.GetValue(), + Props.TenantId.GetValue(), + Props.UserId.GetValue(), + previousRoleId, + newRoleId.GetValue())); + TrackingState.MarkAsDirty(); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + public Result Activate(ActorId updatedBy) { if (IsActive) @@ -221,7 +270,11 @@ public Result Activate(ActorId updatedBy) private Result FindPermission(IdValueObject permissionId) { - var permission = _permissions.FirstOrDefault(p => p.Id.GetValue() == permissionId.GetValue()); + // AT06/F1: la identidad canónica de un permiso es Props.Id (id persistido, lo que expone el DTO + // y reenvía el cliente). El Id base de Entity<> se regenera ALEATORIO en cada construcción + // (Entity ctor: Id = IdValueObject.Create()) y la rehidratación vía Construct<> NO llama SetId, + // así que buscar por p.Id nunca casaba tras recargar → override/activate/deactivate daban 404. + var permission = _permissions.FirstOrDefault(p => p.Props.Id.GetValue() == permissionId.GetValue()); return permission is null ? Result.Failure(DomainErrors.Common.NotFound) : Result.Success(permission); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Profile/ProfileProps.cs b/src/apps/ums.api/Ums.Domain/Authorization/Profile/ProfileProps.cs index bd5640fd..706c8f66 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Profile/ProfileProps.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Profile/ProfileProps.cs @@ -57,5 +57,13 @@ public ProfileProps WithIsActive(bool isActive) return clone; } + /// ADR-UMS-096: reasigna el rol del perfil (efecto de la promoción de rol IGA). + public ProfileProps WithRoleId(RoleId roleId) + { + var clone = (ProfileProps)MemberwiseClone(); + clone.RoleId = roleId; + return clone; + } + public object Clone() => MemberwiseClone(); } diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Repositories.cs b/src/apps/ums.api/Ums.Domain/Authorization/Repositories.cs index 1202a69a..54906f6d 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Repositories.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Repositories.cs @@ -17,6 +17,15 @@ public interface IProfileRepository : IAggregateRepository Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default); Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + /// + /// Perfiles ACTIVOS de un usuario en un inquilino, filtrados en la base. + /// + /// Es la consulta del login. Filtrar el estado en memoria traía filas que se descartaban y, + /// sobre todo, impedía usar el índice parcial `IX_Profiles_UserId_Active`: PostgreSQL solo + /// aprovecha un índice filtrado cuando la consulta lleva su misma condición. + /// + Task> GetActiveByUserAndTenantAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default); + // ── Dependency guard queries (lightweight count-only) ─────────────────── /// Returns the number of active profiles assigned to a given role. Task CountActiveByRoleAsync(Guid roleId, CancellationToken cancellationToken = default); @@ -24,6 +33,14 @@ public interface IProfileRepository : IAggregateRepository Task CountActiveByTemplateAsync(Guid templateId, CancellationToken cancellationToken = default); /// Returns the number of active profiles owned by a given user. Task CountActiveByUserAsync(Guid userId, CancellationToken cancellationToken = default); + /// + /// ADR-0164 §2.2: perfiles ACTIVOS acotados a una sucursal. Es la segunda mitad de la guarda de + /// cascada del cierre definitivo —la otra son las cuentas—, y hace falta porque + /// Profiles.BranchId NO tiene clave ajena contra TenantBranches: la base no + /// impediría nada, así que la integridad la sostiene la aplicación o no la sostiene nadie. + /// Solo cuenta los activos: un perfil ya desactivado no bloquea. + /// + Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default); } public interface ISystemSuiteRepository : IAggregateRepository @@ -32,6 +49,44 @@ public interface ISystemSuiteRepository : IAggregateRepository GetByCodeAsync(Code code, CancellationToken cancellationToken = default); Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default); Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Código y nombre de varios sistemas, sin cargar el agregado. + /// + /// Deliberadamente NO devuelve SystemSuiteAggregate: cargar la suite completa cuesta + /// siete consultas por sus módulos, nodos, acciones y recursos. Para pintar un selector de + /// perfiles hacen falta dos cadenas. + /// + Task> GetSummariesByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default); + + /// + /// Una página del catálogo, filtrada, ordenada y recortada EN LA BASE. + /// + /// El listado cargaba todas las suites del inquilino con su árbol completo —módulos, nodos, + /// acciones y recursos— y después filtraba, ordenaba y paginaba en memoria para devolver + /// veinte. Con cientos de sistemas eso es traer el catálogo entero a la aplicación para + /// tirar el 95 % (G-179). + /// + Task GetPageAsync(SystemSuitePageQuery query, CancellationToken cancellationToken = default); + + /// Agregados completos de una página, en el orden que la página fijó. + Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default); + + // ── Guarda de cascada de la eliminación lógica (G-246) ────────────────── + /// + /// Cuántas referencias VIVAS —no eliminadas lógicamente— tiene el sistema, sin cargar ninguna. + /// + /// El conteo vive aquí —y no repartido en el repositorio de cada referente— porque «qué cuelga de + /// un sistema» es conocimiento del sistema: cuando aparezca una tabla nueva con + /// SystemSuiteId, se añade en un único sitio en vez de confiar en que alguien recuerde + /// tocar la guarda desde el otro extremo. + /// + Task GetDependentsAsync(Guid id, CancellationToken cancellationToken = default); + + // NO hay Delete/Remove, ni aquí ni en IAggregateRepository: la eliminación de un sistema es un + // cambio de estado a `SystemStatus.Deleted` que viaja por `UpdateAsync`, como cualquier otro. Que + // el contrato no ofrezca ninguna forma de borrar la fila es la garantía —comprobable leyendo esta + // interfaz— de que no existe una ruta de borrado físico que se pueda invocar por descuido. } public interface IPermissionTemplateRepository : IAggregateRepository @@ -39,6 +94,13 @@ public interface IPermissionTemplateRepository : IAggregateRepository GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default); Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Devuelve las plantillas existentes para la terna (tenant, rol, suite), usada por el alta + /// para calcular la versión siguiente y evitar la colisión del índice único (G-140). + /// + Task> GetByTenantRoleSuiteAsync(Guid tenantId, Guid roleId, Guid systemSuiteId, CancellationToken cancellationToken = default); + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); // ── Dependency guard queries ──────────────────────────────────────────── @@ -55,6 +117,13 @@ public interface IRoleRepository : IAggregateRepository Task> GetBySystemSuiteIdAsync(Guid systemSuiteId, CancellationToken cancellationToken = default); Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default); + /// + /// Roles por lote. La lista de perfiles del usuario necesita, de cada uno, el nivel de + /// jerarquía y el sistema al que pertenece su rol: sin esto habría que emitir una consulta + /// por perfil, o cargar todos los roles del inquilino para quedarse con tres. + /// + Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default); + // ── Dependency guard queries ──────────────────────────────────────────── /// Returns the number of active child roles for a given parent role. Task CountActiveChildRolesAsync(Guid parentRoleId, CancellationToken cancellationToken = default); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Role/Events/RoleDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Authorization/Role/Events/RoleDomainEventsManager.cs index a71bf226..0f8912c8 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Role/Events/RoleDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Role/Events/RoleDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Authorization.Role; public sealed class RoleDomainEventsManager : DomainEventsManager @@ -9,3 +10,5 @@ private void Apply(RoleUpdatedEvent @event) { } private void Apply(RoleActivatedEvent @event) { } private void Apply(RoleDeactivatedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/AppSetting/AppSetting.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/AppSetting/AppSetting.cs index f593d232..8065061f 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/AppSetting/AppSetting.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/AppSetting/AppSetting.cs @@ -6,14 +6,33 @@ public class AppSetting public ConfigurationValue Value { get; } public ConfigurationScope Scope { get; } - private AppSetting(ConfigurationKey key, ConfigurationValue value, ConfigurationScope scope) + /// + /// Si este ajuste puede viajar al cliente en el grafo de autorización. + /// + /// El default es false y eso es deliberado (fail-closed). `AppSetting` es una bolsa + /// clave/valor sin tipo: junto al color de la marca puede haber una cadena de conexión o el + /// secreto de una integración. Volcarla entera al cliente por comodidad sería publicar + /// secretos por omisión — que un ajuste no esté marcado significa «nadie ha decidido que sea + /// público», no «no pasa nada» (G-178). + /// + /// A diferencia de AppConfiguration y TenantParameter, que sí distinguen lo + /// sensible, aquí no había ninguna marca. + /// + public bool IsClientVisible { get; } + + private AppSetting(ConfigurationKey key, ConfigurationValue value, ConfigurationScope scope, bool isClientVisible) { Key = key; Value = value; Scope = scope; + IsClientVisible = isClientVisible; } - public static Result Create(ConfigurationKey key, ConfigurationValue value, ConfigurationScope scope) + public static Result Create( + ConfigurationKey key, + ConfigurationValue value, + ConfigurationScope scope, + bool isClientVisible = false) { if (string.IsNullOrWhiteSpace(key.GetValue())) { @@ -25,10 +44,11 @@ public static Result Create(ConfigurationKey key, ConfigurationValue return Result.Failure(DomainErrors.ValueObject.PropertyRequired); } - return Result.Success(new AppSetting(key, value, scope)); + return Result.Success(new AppSetting(key, value, scope, isClientVisible)); } - public AppSetting WithValue(ConfigurationValue newValue) => new(Key, newValue, Scope); + /// Cambia el valor CONSERVANDO la marca de exposición: cambiar un valor no es decidir publicarlo. + public AppSetting WithValue(ConfigurationValue newValue) => new(Key, newValue, Scope, IsClientVisible); public override bool Equals(object? obj) { diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Events/SystemSuiteDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Events/SystemSuiteDomainEventsManager.cs index c3e1ff44..12509e6a 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Events/SystemSuiteDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Events/SystemSuiteDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Authorization.SystemSuite.Events; public class SystemSuiteDomainEventsManager : DomainEventsManager @@ -6,9 +7,12 @@ public SystemSuiteDomainEventsManager(IAggregateRoot aggregateRoot) : base(aggre private void Apply(SystemSuiteRegisteredEvent @event) { } private void Apply(SystemSuiteStatusChangedEvent @event) { } + private void Apply(SystemSuiteDeletedEvent @event) { } private void Apply(SystemSuiteModuleAddedEvent @event) { } private void Apply(SystemSuiteModuleRemovedEvent @event) { } private void Apply(SystemSuiteModuleStatusChangedEvent @event) { } private void Apply(SystemSuiteActionRegisteredEvent @event) { } private void Apply(SystemSuiteActionRemovedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/Menu.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/Menu.cs deleted file mode 100644 index ae70a80e..00000000 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/Menu.cs +++ /dev/null @@ -1,131 +0,0 @@ -namespace Ums.Domain.Authorization.SystemSuite.Menu; - -using Ums.Domain.Authorization.SystemSuite.SubMenu; -using SubMenuEntity = Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu; - -public sealed class Menu : Entity -{ - private readonly List _subMenus = new(); - - private Menu(MenuProps props) : base(props) - { - } - - public ModuleId ModuleId => Props.ModuleId; - public Code Code => Props.Code; - public Name Label => Props.Label; - public Description Description => Props.Description; - public int SortOrder => Props.SortOrder; - - public IReadOnlyCollection SubMenus => _subMenus.AsReadOnly(); - - public MenuId GetId() => MenuId.Load(Props.Id.GetValue()); - - public static Result Create( - ModuleId moduleId, - Code code, - Name label, - Description description, - int sortOrder, - ActorId createdBy) - { - var props = new MenuProps(IdValueObject.Create(), moduleId, code, label, description, sortOrder, createdBy); - var menu = new Menu(props); - - if (!menu.IsValid()) - { - return Result.Failure(menu.BrokenRules.GetBrokenRulesAsString()); - } - - return Result.Success(menu); - } - - public Result Update(Name label, Description description, int sortOrder, ActorId updatedBy) - { - SetProps(Props.WithLabel(label).WithDescription(description).WithSortOrder(sortOrder)); - - if (!IsValid()) - { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); - } - - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); - } - - public Result AddSubMenu(Code code, Name label, Description description, int sortOrder, ActorId createdBy) - { - BrokenRules.Clear(); - - if (_subMenus.Any(sm => sm.Code == code)) - { - BrokenRules.Add(new BrokenRule(nameof(SubMenus), DomainErrors.SystemSuite.SubMenuCodeNotUnique)); - } - - if (!IsValid()) - { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); - } - - var subMenuResult = SubMenuEntity.Create(GetId(), code, label, description, sortOrder, createdBy); - if (subMenuResult.IsFailure) - { - return Result.Failure(subMenuResult.Error); - } - - _subMenus.Add(subMenuResult.Value); - Props.Audit.Update(createdBy.GetValue()); - return Result.Success(); - } - - public Result RemoveSubMenu(IdValueObject subMenuId, ActorId updatedBy) - { - var subMenu = FindSubMenu(subMenuId); - if (subMenu.IsFailure) - { - BrokenRules.Add(new BrokenRule(nameof(SubMenus), DomainErrors.Common.NotFound)); - } - - if (!IsValid()) - { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); - } - - _subMenus.Remove(subMenu.Value); - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); - } - - public Result UpdateSubMenu(IdValueObject subMenuId, Name label, Description description, int sortOrder, ActorId updatedBy) - { - var subMenu = FindSubMenu(subMenuId); - if (subMenu.IsFailure) - { - BrokenRules.Add(new BrokenRule(nameof(SubMenus), DomainErrors.Common.NotFound)); - } - - if (!IsValid()) - { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); - } - - var updateResult = subMenu.Value.Update(label, description, sortOrder, updatedBy); - if (updateResult.IsFailure) - { - return Result.Failure(updateResult.Error); - } - - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); - } - - private Result FindSubMenu(IdValueObject subMenuId) - { - var subMenu = _subMenus.FirstOrDefault(sm => - sm.Props.Id.GetValue() == subMenuId.GetValue() || - sm.Id.GetValue() == subMenuId.GetValue()); - return subMenu is null - ? Result.Failure(DomainErrors.Common.NotFound) - : Result.Success(subMenu); - } -} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/MenuProps.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/MenuProps.cs deleted file mode 100644 index c0452ae6..00000000 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/MenuProps.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace Ums.Domain.Authorization.SystemSuite.Menu; - -using Ums.Domain.Authorization.SystemSuite.SubMenu; -using SubMenuEntity = Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu; - -public class MenuProps : IProps -{ - public IdValueObject Id { get; private set; } - public ModuleId ModuleId { get; private set; } - public Code Code { get; private set; } - public Name Label { get; private set; } - public Description Description { get; private set; } - public int SortOrder { get; private set; } - public AuditValueObject Audit { get; private set; } - - public MenuProps( - IdValueObject id, - ModuleId moduleId, - Code code, - Name label, - Description description, - int sortOrder, - ActorId createdBy) - { - Id = id; - ModuleId = moduleId; - Code = code; - Label = label; - Description = description; - SortOrder = sortOrder; - Audit = AuditValueObject.Create(createdBy.GetValue()); - } - - public MenuProps WithLabel(Name label) - { - var clone = (MenuProps)MemberwiseClone(); - clone.Label = label; - return clone; - } - - public MenuProps WithDescription(Description description) - { - var clone = (MenuProps)MemberwiseClone(); - clone.Description = description; - return clone; - } - - public MenuProps WithSortOrder(int sortOrder) - { - var clone = (MenuProps)MemberwiseClone(); - clone.SortOrder = sortOrder; - return clone; - } - - public object Clone() => MemberwiseClone(); -} \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNode.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNode.cs new file mode 100644 index 00000000..6c23f760 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNode.cs @@ -0,0 +1,216 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Nodo recursivo de la topología de navegación (ADR-0090). Reemplaza la cadena +/// rígida Menú→SubMenú→Opción por un árbol adjacency-list de profundidad +/// arbitraria: un módulo puede tener opciones directas o jerarquías más +/// profundas, y el submenú es opcional por naturaleza. Una +/// es un nodo hoja que vincula funcionalidades (acciones) N:M. +/// +public sealed class MenuNode : Entity +{ + private readonly List _children = new(); + private readonly List _actionCodes = new(); + + private MenuNode(MenuNodeProps props) : base(props) + { + } + + public ModuleId ModuleId => Props.ModuleId; + public IdValueObject? ParentNodeId => Props.ParentNodeId; + public NodeKind Kind => Props.Kind; + public Code Code => Props.Code; + public Name Label => Props.Label; + public Description Description => Props.Description; + public ModuleStatus Status => Props.Status; + public int SortOrder => Props.SortOrder; + public MenuNodeMetadata Metadata => Props.Metadata; + + public IReadOnlyCollection Children => _children.AsReadOnly(); + public IReadOnlyCollection ActionCodes => _actionCodes.AsReadOnly(); + + public IdValueObject GetId() => Props.Id; + + public static Result Create( + ModuleId moduleId, + IdValueObject? parentNodeId, + NodeKind kind, + Code code, + Name label, + Description description, + int sortOrder, + ActorId createdBy, + MenuNodeMetadata? metadata = null, + MenuNodePresentation? presentation = null) + { + var props = new MenuNodeProps( + IdValueObject.Create(), + moduleId, + parentNodeId, + kind, + code, + label, + description, + ModuleStatus.Active, + sortOrder, + metadata ?? MenuNodeMetadata.Empty, + createdBy, + presentation ?? MenuNodePresentation.Empty); + + var node = new MenuNode(props); + if (!node.IsValid()) + { + return Result.Failure(node.BrokenRules.GetBrokenRulesAsString()); + } + + return Result.Success(node); + } + + public Result Update(Name label, Description description, int sortOrder, ActorId updatedBy) + { + SetProps(Props.WithLabel(label).WithDescription(description).WithSortOrder(sortOrder)); + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + public Result Activate(ActorId updatedBy) + { + SetProps(Props.WithStatus(ModuleStatus.Active)); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + public Result Deactivate(ActorId updatedBy) + { + SetProps(Props.WithStatus(ModuleStatus.Inactive)); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + public Result SetMetadata(MenuNodeMetadata metadata, ActorId updatedBy) + { + SetProps(Props.WithMetadata(metadata ?? MenuNodeMetadata.Empty)); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + /// Añade un hijo. Regla: una opción es hoja y no admite hijos; el código es único entre hermanos. + public Result AddChild( + NodeKind kind, + Code code, + Name label, + Description description, + int sortOrder, + ActorId createdBy, + MenuNodeMetadata? metadata = null, + MenuNodePresentation? presentation = null) + { + BrokenRules.Clear(); + + if (Kind == NodeKind.Option) + { + BrokenRules.Add(new BrokenRule(nameof(Children), "Una opción es un nodo hoja y no admite hijos.")); + } + + if (_children.Any(c => c.Code.GetValue() == code.GetValue())) + { + BrokenRules.Add(new BrokenRule(nameof(Children), DomainErrors.Common.Duplicate)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + var childResult = Create(ModuleId, Props.Id, kind, code, label, description, sortOrder, createdBy, metadata, presentation); + if (childResult.IsFailure) + { + return Result.Failure(childResult.Error); + } + + _children.Add(childResult.Value); + Props.Audit.Update(createdBy.GetValue()); + return Result.Success(childResult.Value.GetId().GetValue()); + } + + public Result RemoveChild(IdValueObject childId, ActorId updatedBy) + { + var child = _children.FirstOrDefault(c => c.Props.Id.GetValue() == childId.GetValue()); + if (child is null) + { + return Result.Failure(DomainErrors.Common.NotFound); + } + + _children.Remove(child); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + /// Vincula una funcionalidad (acción) N:M. Solo nodos hoja (Opción). + public Result LinkAction(ActionCode actionCode, ActorId updatedBy) + { + BrokenRules.Clear(); + + if (Kind != NodeKind.Option) + { + BrokenRules.Add(new BrokenRule(nameof(ActionCodes), "Solo una opción puede vincular funcionalidades.")); + } + + if (_actionCodes.Any(a => a.GetValue() == actionCode.GetValue())) + { + BrokenRules.Add(new BrokenRule(nameof(ActionCodes), DomainErrors.Common.Duplicate)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + _actionCodes.Add(actionCode); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + public Result UnlinkAction(ActionCode actionCode, ActorId updatedBy) + { + var existing = _actionCodes.FirstOrDefault(a => a.GetValue() == actionCode.GetValue()); + if (existing is null) + { + return Result.Failure(DomainErrors.Common.NotFound); + } + + _actionCodes.Remove(existing); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + /// Busca este nodo o cualquier descendiente por id (recorrido en profundidad). + public MenuNode? Find(IdValueObject id) + { + if (Props.Id.GetValue() == id.GetValue()) + { + return this; + } + + foreach (var child in _children) + { + var found = child.Find(id); + if (found is not null) + { + return found; + } + } + + return null; + } + + /// Devuelve el hijo directo cuyo id coincide, o null. + public MenuNode? FindDirectChild(IdValueObject id) + => _children.FirstOrDefault(c => c.Props.Id.GetValue() == id.GetValue()); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeMetadata.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeMetadata.cs new file mode 100644 index 00000000..dca37304 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeMetadata.cs @@ -0,0 +1,84 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Metadatos de gobernanza SDLC opcionales por nodo (ADR-0090). Todos los +/// campos son nulos por defecto: no obligan estructura donde no aporta. +/// Value object inmutable; se reemplaza entero vía . +/// +public sealed class MenuNodeMetadata : IEquatable +{ + public string? Responsable { get; } + public string? Criticidad { get; } + public string? ProductoImpactado { get; } + public string? ComponenteTecnico { get; } + public string? Dependencias { get; } + public string? Evidencias { get; } + public string? TrazabilidadSdlc { get; } + + private MenuNodeMetadata( + string? responsable, + string? criticidad, + string? productoImpactado, + string? componenteTecnico, + string? dependencias, + string? evidencias, + string? trazabilidadSdlc) + { + Responsable = Normalize(responsable); + Criticidad = Normalize(criticidad); + ProductoImpactado = Normalize(productoImpactado); + ComponenteTecnico = Normalize(componenteTecnico); + Dependencias = Normalize(dependencias); + Evidencias = Normalize(evidencias); + TrazabilidadSdlc = Normalize(trazabilidadSdlc); + } + + public static MenuNodeMetadata Create( + string? responsable = null, + string? criticidad = null, + string? productoImpactado = null, + string? componenteTecnico = null, + string? dependencias = null, + string? evidencias = null, + string? trazabilidadSdlc = null) + => new(responsable, criticidad, productoImpactado, componenteTecnico, dependencias, evidencias, trazabilidadSdlc); + + public static MenuNodeMetadata Empty { get; } = new(null, null, null, null, null, null, null); + + public bool IsEmpty => + Responsable is null && + Criticidad is null && + ProductoImpactado is null && + ComponenteTecnico is null && + Dependencias is null && + Evidencias is null && + TrazabilidadSdlc is null; + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + // G-055: value object con igualdad por valor. Antes de esto, dos instancias con el + // mismo contenido eran distintas (Equals/GetHashCode por referencia), lo que impedía + // usarlo en HashSet/Dictionary o deduplicar. La igualdad cubre los 7 campos ya + // normalizados, de modo que Create() sin argumentos equivale a Empty. + public bool Equals(MenuNodeMetadata? other) => + other is not null && + Responsable == other.Responsable && + Criticidad == other.Criticidad && + ProductoImpactado == other.ProductoImpactado && + ComponenteTecnico == other.ComponenteTecnico && + Dependencias == other.Dependencias && + Evidencias == other.Evidencias && + TrazabilidadSdlc == other.TrazabilidadSdlc; + + public override bool Equals(object? obj) => Equals(obj as MenuNodeMetadata); + + public override int GetHashCode() => HashCode.Combine( + Responsable, + Criticidad, + ProductoImpactado, + ComponenteTecnico, + Dependencias, + Evidencias, + TrazabilidadSdlc); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodePresentation.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodePresentation.cs new file mode 100644 index 00000000..0e46f27b --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodePresentation.cs @@ -0,0 +1,54 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Cómo se pinta un nodo de navegación: su icono y la ruta a la que lleva. +/// +/// Va aparte de a propósito. Esa metadata es de GOBIERNO —quién es +/// el responsable, qué criticidad tiene, a qué trazabilidad SDLC responde— y su público es el +/// auditor. Esto es presentación, y su público es el navegador. Mezclarlas obligaría a exponer al +/// cliente campos de gobierno para poder darle un icono. +/// +/// Existe porque el escenario objetivo pide que el cliente construya la navegación sin llamadas +/// adicionales, y con solo código y etiqueta no puede: no sabe qué icono pintar ni a dónde llevar +/// al usuario al pulsar. +/// +public sealed class MenuNodePresentation : IEquatable +{ + /// + /// Identificador del icono, no una URL ni un SVG: el catálogo de iconos lo decide el cliente. + /// Guardar aquí un recurso ataría el servidor a la biblioteca gráfica de un frontend concreto. + /// + public string? Icon { get; } + + /// + /// Ruta relativa a la que lleva el nodo (`/portafolio`, `/prds/:id`). Tiene sentido sobre todo + /// en las hojas, pero un menú puede tener su propia pantalla de aterrizaje, así que no se + /// restringe por tipo de nodo. + /// + public string? Route { get; } + + private MenuNodePresentation(string? icon, string? route) + { + Icon = Normalizar(icon); + Route = Normalizar(route); + } + + public static readonly MenuNodePresentation Empty = new(null, null); + + public static MenuNodePresentation Create(string? icon, string? route) + { + var presentacion = new MenuNodePresentation(icon, route); + return presentacion.Icon is null && presentacion.Route is null ? Empty : presentacion; + } + + /// Cadena vacía y solo-espacios se tratan como ausencia: «sin icono», no «icono ''». + private static string? Normalizar(string? valor) + => string.IsNullOrWhiteSpace(valor) ? null : valor.Trim(); + + public bool Equals(MenuNodePresentation? other) + => other is not null && Icon == other.Icon && Route == other.Route; + + public override bool Equals(object? obj) => Equals(obj as MenuNodePresentation); + + public override int GetHashCode() => HashCode.Combine(Icon, Route); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeProps.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeProps.cs new file mode 100644 index 00000000..e2a24bb2 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeProps.cs @@ -0,0 +1,95 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Props del nodo recursivo de navegación (ADR-0090). Adjacency list: +/// nulo ⇒ hijo directo del módulo. +/// +public class MenuNodeProps : IProps +{ + public IdValueObject Id { get; private set; } + public ModuleId ModuleId { get; private set; } + public IdValueObject? ParentNodeId { get; private set; } + public NodeKind Kind { get; private set; } + public Code Code { get; private set; } + public Name Label { get; private set; } + public Description Description { get; private set; } + public ModuleStatus Status { get; private set; } + public int SortOrder { get; private set; } + public MenuNodeMetadata Metadata { get; private set; } + + /// Icono y ruta del nodo. Nunca null: MenuNodePresentation.Empty representa la ausencia. + public MenuNodePresentation Presentation { get; private set; } + public AuditValueObject Audit { get; private set; } + + public MenuNodeProps( + IdValueObject id, + ModuleId moduleId, + IdValueObject? parentNodeId, + NodeKind kind, + Code code, + Name label, + Description description, + ModuleStatus status, + int sortOrder, + MenuNodeMetadata metadata, + ActorId createdBy, + MenuNodePresentation? presentation = null) + { + Id = id; + ModuleId = moduleId; + ParentNodeId = parentNodeId; + Kind = kind; + Code = code; + Label = label; + Description = description; + Status = status; + SortOrder = sortOrder; + Metadata = metadata; + Presentation = presentation ?? MenuNodePresentation.Empty; + Audit = AuditValueObject.Create(createdBy.GetValue()); + } + + public MenuNodeProps WithLabel(Name label) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Label = label; + return clone; + } + + public MenuNodeProps WithDescription(Description description) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Description = description; + return clone; + } + + public MenuNodeProps WithSortOrder(int sortOrder) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.SortOrder = sortOrder; + return clone; + } + + public MenuNodeProps WithStatus(ModuleStatus status) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Status = status; + return clone; + } + + public MenuNodeProps WithPresentation(MenuNodePresentation presentation) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Presentation = presentation; + return clone; + } + + public MenuNodeProps WithMetadata(MenuNodeMetadata metadata) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Metadata = metadata; + return clone; + } + + public object Clone() => MemberwiseClone(); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/NodeKind.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/NodeKind.cs new file mode 100644 index 00000000..de024fe7 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/NodeKind.cs @@ -0,0 +1,13 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Rol de un nodo de la topología de navegación (ADR-0090). Clasifica el nodo +/// sin fijar la profundidad: un o es +/// un nodo rama; una es un nodo hoja. +/// +public enum NodeKind +{ + Menu = 1, + SubMenu = 2, + Option = 3, +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/Module.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/Module.cs index b1033ce3..215df241 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/Module.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/Module.cs @@ -1,11 +1,11 @@ namespace Ums.Domain.Authorization.SystemSuite.Module; -using Ums.Domain.Authorization.SystemSuite.Menu; -using MenuEntity = Ums.Domain.Authorization.SystemSuite.Menu.Menu; +using Ums.Domain.Authorization.SystemSuite.MenuNode; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; public sealed class Module : Entity { - private readonly List _menus = new(); + private readonly List _nodes = new(); private Module(ModuleProps props) : base(props) { @@ -18,7 +18,11 @@ private Module(ModuleProps props) : base(props) public ModuleStatus Status => Props.Status; public int SortOrder => Props.SortOrder; - public IReadOnlyCollection Menus => _menus.AsReadOnly(); + /// Icono del módulo (identificador, no recurso). Nulo si no se configuró. + public string? Icon => Props.Icon; + + /// Raíces del árbol de navegación recursivo (ADR-0090). Los hijos anidan dentro de cada nodo. + public IReadOnlyCollection Nodes => _nodes.AsReadOnly(); public ModuleId GetId() => ModuleId.Load(Props.Id.GetValue()); @@ -28,9 +32,10 @@ public static Result Create( Name name, Description description, int sortOrder, - ActorId createdBy) + ActorId createdBy, + string? icon = null) { - var props = new ModuleProps(IdValueObject.Create(), systemId, code, name, description, ModuleStatus.Inactive, sortOrder, createdBy); + var props = new ModuleProps(IdValueObject.Create(), systemId, code, name, description, ModuleStatus.Inactive, sortOrder, createdBy, NormalizarIcono(icon)); var module = new Module(props); if (!module.IsValid()) @@ -54,6 +59,18 @@ public Result Update(Name name, Description description, int sortOrder, ActorId return Result.Success(); } + /// Fija o borra el icono del módulo. Cadena vacía y solo-espacios equivalen a borrarlo. + public Result SetIcon(string? icon, ActorId updatedBy) + { + SetProps(Props.WithIcon(NormalizarIcono(icon))); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + /// «Sin icono», no «icono ''»: la misma regla que aplica el nodo de navegación. + private static string? NormalizarIcono(string? icon) + => string.IsNullOrWhiteSpace(icon) ? null : icon.Trim(); + public Result Activate(ActorId updatedBy) { BrokenRules.Clear(); @@ -92,84 +109,134 @@ public Result Deactivate(ActorId updatedBy) return Result.Success(); } - public Result AddMenu(Code code, Name label, Description description, int sortOrder, ActorId createdBy) + // ── Árbol de navegación recursivo (MenuNode, ADR-0090) ───────────────────── + + /// Añade un nodo raíz (hijo directo del módulo). Código único entre raíces. + public Result AddRootNode(NodeKind kind, Code code, Name label, Description description, int sortOrder, ActorId createdBy, MenuNodeMetadata? metadata = null, MenuNodePresentation? presentation = null) { BrokenRules.Clear(); if (Status == ModuleStatus.Inactive) { - BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.SystemSuite.ModuleInactiveCannotAddMenu)); + BrokenRules.Add(new BrokenRule(nameof(Nodes), DomainErrors.SystemSuite.ModuleInactiveCannotAddMenu)); } - if (_menus.Any(m => m.Code == code)) + if (_nodes.Any(n => n.Code.GetValue() == code.GetValue())) { - BrokenRules.Add(new BrokenRule(nameof(Menus), DomainErrors.SystemSuite.MenuCodeNotUnique)); + BrokenRules.Add(new BrokenRule(nameof(Nodes), DomainErrors.SystemSuite.MenuCodeNotUnique)); } if (!IsValid()) { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - var menuResult = MenuEntity.Create(GetId(), code, label, description, sortOrder, createdBy); - if (menuResult.IsFailure) + var nodeResult = MenuNodeEntity.Create(GetId(), null, kind, code, label, description, sortOrder, createdBy, metadata, presentation); + if (nodeResult.IsFailure) { - return Result.Failure(menuResult.Error); + return Result.Failure(nodeResult.Error); } - _menus.Add(menuResult.Value); + _nodes.Add(nodeResult.Value); Props.Audit.Update(createdBy.GetValue()); - return Result.Success(); + return Result.Success(nodeResult.Value.GetId().GetValue()); } - public Result RemoveMenu(IdValueObject menuId, ActorId updatedBy) + /// Añade un nodo hijo bajo un nodo existente (a cualquier profundidad). + public Result AddChildNode(IdValueObject parentNodeId, NodeKind kind, Code code, Name label, Description description, int sortOrder, ActorId createdBy, MenuNodeMetadata? metadata = null, MenuNodePresentation? presentation = null) { - var menu = FindMenu(menuId); - if (menu.IsFailure) + var parent = FindNode(parentNodeId); + if (parent is null) { - BrokenRules.Add(new BrokenRule(nameof(Menus), DomainErrors.Common.NotFound)); + return Result.Failure(DomainErrors.Common.NotFound); } - if (!IsValid()) + var result = parent.AddChild(kind, code, label, description, sortOrder, createdBy, metadata, presentation); + if (result.IsSuccess) { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + Props.Audit.Update(createdBy.GetValue()); } - _menus.Remove(menu.Value); - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); + return result; } - public Result UpdateMenu(IdValueObject menuId, Name label, Description description, int sortOrder, ActorId updatedBy) + public Result UpdateNode(IdValueObject nodeId, Name label, Description description, int sortOrder, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.Update(label, description, sortOrder, updatedBy), updatedBy); + + public Result ActivateNode(IdValueObject nodeId, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.Activate(updatedBy), updatedBy); + + public Result DeactivateNode(IdValueObject nodeId, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.Deactivate(updatedBy), updatedBy); + + public Result LinkNodeAction(IdValueObject nodeId, ActionCode actionCode, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.LinkAction(actionCode, updatedBy), updatedBy); + + public Result UnlinkNodeAction(IdValueObject nodeId, ActionCode actionCode, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.UnlinkAction(actionCode, updatedBy), updatedBy); + + public Result SetNodeMetadata(IdValueObject nodeId, MenuNodeMetadata metadata, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.SetMetadata(metadata, updatedBy), updatedBy); + + /// Elimina un nodo (raíz o anidado) y todo su subárbol. + public Result RemoveNode(IdValueObject nodeId, ActorId updatedBy) { - var menu = FindMenu(menuId); - if (menu.IsFailure) + var node = FindNode(nodeId); + if (node is null) { - BrokenRules.Add(new BrokenRule(nameof(Menus), DomainErrors.Common.NotFound)); + return Result.Failure(DomainErrors.Common.NotFound); } - if (!IsValid()) + if (node.ParentNodeId is null) { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + _nodes.Remove(node); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); } - var updateResult = menu.Value.Update(label, description, sortOrder, updatedBy); - if (updateResult.IsFailure) + var parent = FindNode(node.ParentNodeId); + if (parent is null) { - return Result.Failure(updateResult.Error); + return Result.Failure(DomainErrors.Common.NotFound); } - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); + var result = parent.RemoveChild(nodeId, updatedBy); + if (result.IsSuccess) + { + Props.Audit.Update(updatedBy.GetValue()); + } + + return result; } - private Result FindMenu(IdValueObject menuId) + private Result DelegateToNode(IdValueObject nodeId, Func action, ActorId updatedBy) { - var menu = _menus.FirstOrDefault(m => - m.Props.Id.GetValue() == menuId.GetValue() || - m.Id.GetValue() == menuId.GetValue()); - return menu is null - ? Result.Failure(DomainErrors.Common.NotFound) - : Result.Success(menu); + var node = FindNode(nodeId); + if (node is null) + { + return Result.Failure(DomainErrors.Common.NotFound); + } + + var result = action(node); + if (result.IsSuccess) + { + Props.Audit.Update(updatedBy.GetValue()); + } + + return result; + } + + private MenuNodeEntity? FindNode(IdValueObject id) + { + foreach (var root in _nodes) + { + var found = root.Find(id); + if (found is not null) + { + return found; + } + } + + return null; } } diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/ModuleProps.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/ModuleProps.cs index 218a5ff0..3f17422f 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/ModuleProps.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/ModuleProps.cs @@ -1,8 +1,5 @@ namespace Ums.Domain.Authorization.SystemSuite.Module; -using Ums.Domain.Authorization.SystemSuite.Menu; -using MenuEntity = Ums.Domain.Authorization.SystemSuite.Menu.Menu; - public class ModuleProps : IProps { public IdValueObject Id { get; private set; } @@ -12,6 +9,13 @@ public class ModuleProps : IProps public Description Description { get; private set; } public ModuleStatus Status { get; private set; } public int SortOrder { get; private set; } + + /// + /// Identificador del icono del módulo, no un recurso: igual que en el nodo de navegación, el + /// catálogo gráfico lo resuelve el cliente. Nulo = sin icono configurado. + /// + public string? Icon { get; private set; } + public AuditValueObject Audit { get; private set; } public ModuleProps( @@ -22,7 +26,8 @@ public ModuleProps( Description description, ModuleStatus status, int sortOrder, - ActorId createdBy) + ActorId createdBy, + string? icon = null) { Id = id; SystemId = systemId; @@ -31,6 +36,7 @@ public ModuleProps( Description = description; Status = status; SortOrder = sortOrder; + Icon = icon; Audit = AuditValueObject.Create(createdBy.GetValue()); } @@ -55,6 +61,13 @@ public ModuleProps WithSortOrder(int sortOrder) return clone; } + public ModuleProps WithIcon(string? icon) + { + var clone = (ModuleProps)MemberwiseClone(); + clone.Icon = icon; + return clone; + } + public ModuleProps WithStatus(ModuleStatus status) { var clone = (ModuleProps)MemberwiseClone(); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Option/Option.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Option/Option.cs deleted file mode 100644 index 3e6bad4f..00000000 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Option/Option.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace Ums.Domain.Authorization.SystemSuite.Option; - -public sealed class Option : Entity -{ - private Option(OptionProps props) : base(props) - { - } - - public SubMenuId SubMenuId => Props.SubMenuId; - public Code Code => Props.Code; - public Name Label => Props.Label; - public Description Description => Props.Description; - public ActionCode ActionCode => Props.ActionCode; - public int SortOrder => Props.SortOrder; - - public OptionId GetId() => OptionId.Load(Props.Id.GetValue()); - - public static Result public interface IAuthMethodResolver { Task> ResolveAsync( Guid tenantId, AuthAccessScope scope, + Guid? systemSuiteId = null, + string? emailDomain = null, CancellationToken cancellationToken = default); } diff --git a/src/apps/ums.api/Ums.Domain/Identity/Auth/IIdpChainAuthenticator.cs b/src/apps/ums.api/Ums.Domain/Identity/Auth/IIdpChainAuthenticator.cs new file mode 100644 index 00000000..f15d1fea --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Identity/Auth/IIdpChainAuthenticator.cs @@ -0,0 +1,34 @@ +using Ums.Domain.Identity.Tenant.IdentityProvider; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +namespace Ums.Domain.Identity.Auth; + +/// +/// Orquesta la autenticación federada del login recorriendo la cadena de fallback +/// IdpConfiguration.FallbackToId (FR-042 · ADR-UMS-097 §2.3/§2.4, slice 2b). +/// +/// Partiendo de la configuración ganadora del selector (2a), intenta autenticar; si el intento +/// es avanza al siguiente proveedor de la cadena; +/// si es se detiene y devuelve el error (sin avanzar, +/// anti credential spraying); si es devuelve la identidad +/// externa y el proveedor que efectivamente autenticó. Detecta ciclos y aplica un tope de saltos; +/// si la cadena se agota por indisponibilidad devuelve un fallo AUTH_018 (503, no 401). Audita +/// un evento por proveedor intentado. +/// +public interface IIdpChainAuthenticator +{ + Task> AuthenticateAsync( + TenantAggregate tenant, + string credential, + Guid? systemSuiteId, + string? emailDomain, + string clientIp, + CancellationToken cancellationToken = default); +} + +/// +/// Resultado exitoso del recorrido de la cadena: la identidad externa validada y el +/// que la autenticó (puede ser un proveedor de respaldo distinto +/// al primario cuando hubo fallback por indisponibilidad). +/// +public sealed record IdpChainOutcome(ExternalIdentity Identity, IdentityProvider Provider); diff --git a/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcome.cs b/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcome.cs new file mode 100644 index 00000000..2977e2cd --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcome.cs @@ -0,0 +1,24 @@ +namespace Ums.Domain.Identity.Auth; + +/// +/// Clasificación del resultado de un intento de autenticación contra un IdP federado, +/// para el fallback encadenado de FR-042 (ADR-UMS-097 §2.3). Es la decisión de seguridad +/// que gobierna si la cadena de FallbackToId AVANZA o se DETIENE: +/// +/// : el IdP validó la credencial y devolvió una identidad externa. +/// : indisponibilidad de infraestructura del IdP +/// (timeout, 5xx, JWKS inalcanzable, adaptador no registrado). Es el único caso que +/// autoriza avanzar al siguiente proveedor de la cadena. +/// : el IdP respondió y rechazó (credencial inválida, +/// token no válido, política) o el fallo no se puede clasificar con certeza como +/// infraestructura. Es TERMINAL: NUNCA avanza la cadena. Encadenar ante un fallo de +/// credenciales permitiría credential spraying cross-IdP; por eso, y por fail-closed, +/// ante la duda se detiene el intento (ADR-UMS-097 §2.3). +/// +/// +public enum IdpAuthOutcome +{ + Success, + InfraUnavailable, + CredentialTerminal, +} diff --git a/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcomeClassifier.cs b/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcomeClassifier.cs new file mode 100644 index 00000000..8879b8f2 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcomeClassifier.cs @@ -0,0 +1,91 @@ +namespace Ums.Domain.Identity.Auth; + +/// +/// Clasificador explícito y fail-closed del resultado de un intento de autenticación +/// contra un IdP federado (FR-042 · ADR-UMS-097 §2.3). Traduce el que +/// devuelve el path IdP (IIdpAuthStrategy/adaptadores) a un , +/// que es lo que decide si el fallback encadenado avanza o se detiene. +/// +/// Señales de indisponibilidad de infraestructura (las únicas que autorizan avanzar), +/// derivadas del código real del path IdP y ambas listadas por ADR-UMS-097 §2.3: +/// +/// AUTH_012IdpAuthStrategyDispatcher: no hay adaptador registrado para la +/// estrategia del proveedor («adaptador no disponible/no registrado»). +/// AUTH_034HttpJwksProvider: no se pudo obtener el JWKS del issuer +/// (5xx/timeout/red) («error 5xx del IdP/JWKS»). +/// AUTH_035HttpOidcTokenClient: el token endpoint respondió 5xx, dio +/// timeout o falló el transporte (G-108). Es la rama de INFRA del intercambio de código, ahora +/// estructuralmente separada del 4xx de credencial (que sigue en AUTH_021). +/// +/// +/// Todo lo demás es TERMINAL (no avanza), por fail-closed. En particular: +/// +/// rechazos de credencial/token del IdP (AUTH_02x: firma, iss/aud/exp/nonce, etc.); +/// AUTH_021 (intercambio de código): 4xx del token endpoint (invalid_grant u +/// otra respuesta de cliente) o cuerpo 2xx malformado ⇒ TERMINAL. NUNCA debe entrar en la lista +/// blanca de infra: encadenar ante un fallo de credencial abriría credential spraying (§2.3); +/// AUTH_013 — código reutilizado por flujos no-IdP (stub de dev, contexto de gestión), +/// ambiguo ⇒ terminal; +/// códigos desconocidos o error sin código ⇒ terminal. +/// +/// +/// Ambigüedad resuelta (G-108): el disparo de fallback por «timeout/5xx del IdP» en el +/// token endpoint ya se puede activar con seguridad porque HttpOidcTokenClient emite un código +/// de infra estructuralmente distintoAUTH_035, por clase de status HTTP / tipo de excepción, +/// no por texto— del 4xx de credencial (AUTH_021, que sigue terminal). El 4xx +/// invalid_grant permanece TERMINAL bajo cualquier circunstancia: la separación es la que cierra el +/// vector de credential spraying (ADR-UMS-097 §2.3). +/// +public static class IdpAuthOutcomeClassifier +{ + /// + /// Lista blanca de códigos de error que se consideran indisponibilidad de infraestructura + /// (y por tanto autorizan avanzar la cadena). Cualquier código ausente de este conjunto es TERMINAL. + /// + public static readonly IReadOnlySet InfraUnavailableCodes = + new HashSet(StringComparer.Ordinal) + { + "AUTH_012", // dispatcher: sin adaptador registrado para la estrategia + "AUTH_034", // JWKS inalcanzable (5xx/timeout/red) + "AUTH_035", // token endpoint OIDC inalcanzable: 5xx/timeout/transporte (G-108). SOLO la rama de + // infra de HttpOidcTokenClient lo emite; el 4xx invalid_grant sigue en AUTH_021 + // (TERMINAL), jamás aquí ⇒ no abre credential spraying (ADR-UMS-097 §2.3). + }; + + /// + /// Clasifica el resultado de un intento IdP. ⇒ + /// ; un fallo cuyo código está en la lista blanca de infra ⇒ + /// ; cualquier otro fallo ⇒ + /// (fail-closed). + /// + public static IdpAuthOutcome Classify(Result attempt) + { + if (attempt is null) + { + return IdpAuthOutcome.CredentialTerminal; + } + + if (attempt.IsSuccess) + { + return IdpAuthOutcome.Success; + } + + return IsInfraUnavailable(attempt.Error) + ? IdpAuthOutcome.InfraUnavailable + : IdpAuthOutcome.CredentialTerminal; + } + + /// + /// Indica si el error corresponde a una señal INEQUÍVOCA de indisponibilidad de infraestructura. + /// Cualquier ambigüedad devuelve false (fail-closed). + /// + public static bool IsInfraUnavailable(string? error) + => !string.IsNullOrWhiteSpace(error) && InfraUnavailableCodes.Contains(ExtractCode(error)); + + /// Extrae el prefijo AUTH_0xx del mensaje de error (todo antes del primer «:»). + private static string ExtractCode(string error) + { + var separatorIndex = error.IndexOf(':'); + return (separatorIndex > 0 ? error[..separatorIndex] : error).Trim(); + } +} diff --git a/src/apps/ums.api/Ums.Domain/Identity/Repositories.cs b/src/apps/ums.api/Ums.Domain/Identity/Repositories.cs index 37781af5..c52c7335 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Repositories.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Repositories.cs @@ -1,5 +1,6 @@ namespace Ums.Domain.Identity; using Ums.Domain.Identity.Tenant; +using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Identity.UserAccount; using Ums.Domain.Identity.UserManagementDelegation; using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; @@ -10,6 +11,8 @@ public interface ITenantRepository : IAggregateRepository { Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); + Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default); + /// /// G-161: ¿existe ya una sucursal con este código bajo el inquilino? Consulta autoritativa que /// IGNORA el filtro global por inquilino, para que la verificación de unicidad sea correcta aun @@ -18,7 +21,19 @@ public interface ITenantRepository : IAggregateRepository /// `Tenant.AddBranch` no vería el duplicado. Es de solo lectura y se usa en la vía de escritura. /// Task BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default); - Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default); + + /// + /// ADR-0164: BITÁCORA de una sucursal — sus episodios (apertura, desactivación, reactivación, + /// cierre definitivo) en orden cronológico, con fecha, autor y la foto de cómo era la sucursal + /// en cada uno. + /// + /// Es una lectura APARTE y no una colección del agregado a propósito: ninguna invariante + /// depende de la historia, y cargarla con cada GetByIdAsync —ruta caliente, la usa hasta + /// el grafo de autorización— traería filas que nadie mira. Devuelve la bitácora aunque la + /// sucursal esté cerrada: preguntar por el pasado de algo cerrado es justamente el caso de uso. + /// + Task> GetBranchLifecycleAsync( + Guid tenantId, Guid branchId, CancellationToken cancellationToken = default); /// /// REC-12: Server-side paginated query. SQL implementations use Skip/Take at the DB level. /// InMemory implementations call GetAllAsync then apply in-memory pagination. @@ -26,9 +41,15 @@ public interface ITenantRepository : IAggregateRepository /// When tenantId is provided, only returns the matching tenant and its direct children (ParentTenantId == tenantId). /// Null tenantId means cross-tenant access (internal admins only). /// + /// + /// Campo sobre el que aplica ("code" | "name"). Es independiente de + /// (que solo ordena). Si es nulo/vacío se usa + /// por compatibilidad. Antes el campo de búsqueda se derivaba de sortBy, ignorando el parámetro + /// `criteria` del API (búsqueda por código imposible sin ordenar por código) — G-014 residual. + /// Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( int page, int pageSize, string? search, string? status, string sortBy, string sortOrder, - Guid? tenantId = null, CancellationToken cancellationToken = default); + Guid? tenantId = null, CancellationToken cancellationToken = default, string? searchField = null); /// /// REC-16: Soft-delete a tenant by ID. Marks IsDeleted=true and records who deleted it. @@ -48,6 +69,10 @@ public interface IUserAccountRepository : IAggregateRepositoryReturns the number of non-deleted users in the given tenant. Task CountActiveByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default); + /// G-046: Returns the number of active users bound to the given branch. Used as a + /// dependency guard so a branch with active accounts cannot be deactivated/removed and leave + /// dangling branch references. + Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default); /// /// REC-12: Server-side paginated query. SQL implementations use Skip/Take at the DB level. /// diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/Branch.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/Branch.cs index e589f5f7..9125e6a7 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/Branch.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/Branch.cs @@ -2,6 +2,19 @@ namespace Ums.Domain.Identity.Tenant.Branch; public sealed class Branch : Entity { + /// + /// Episodios registrados en ESTA unidad de trabajo y todavía sin persistir. + /// + /// La bitácora histórica NO se carga con el agregado a propósito: ninguna invariante de + /// Tenant depende de ella, y arrastrarla en cada lectura del inquilino —que es una ruta + /// caliente, la usa hasta el grafo de autorización— costaría filas que nadie mira. Lo que sí es + /// obligatorio es que el asiento se escriba en la MISMA transacción que el cambio de estado: un + /// manejador post-commit es, por contrato (ADR-0098 D4), best-effort, y una bitácora que puede + /// perder episodios no prueba nada. De ahí este búfer: el dominio decide y anota, el repositorio + /// vuelca en el mismo SaveChanges. + /// + private readonly List _pendingLifecycleEntries = []; + private Branch(BranchProps props) : base(props) { } @@ -12,6 +25,16 @@ private Branch(BranchProps props) : base(props) public Value? GeofencingMetadata => Props.GeofencingMetadata; public bool IsActive => Props.IsActive; + /// + /// Verdadero cuando la sucursal está CERRADA DEFINITIVAMENTE (ADR-0164 §2.1). La fila sigue en + /// la base —y su código sigue ocupado— pero la sucursal ya no existe para el negocio. + /// + public bool IsClosed => Props.IsClosed; + public DateTime? ClosedAtUtc => Props.ClosedAtUtc; + public string? ClosedBy => Props.ClosedBy; + + public IReadOnlyCollection PendingLifecycleEntries => _pendingLifecycleEntries.AsReadOnly(); + public BranchId GetId() => BranchId.Load(Props.Id.GetValue()); public static Result Create(TenantId tenantId, Code code, Name name, ActorId createdBy, Value? geofencingMetadata = null) @@ -24,12 +47,21 @@ public static Result Create(TenantId tenantId, Code code, Name name, Act return Result.Failure(branch.BrokenRules.GetBrokenRulesAsString()); } + // La apertura es el episodio cero: sin él la bitácora empezaría a media historia y no se + // podría situar el primer despacho de la sucursal en ninguna época. + branch.RecordEpisode(BranchLifecycleEpisode.Opened, createdBy, reason: null); + return Result.Success(branch); } internal Result CanDeactivate() { - if (!IsActive) + if (IsClosed) + { + // Una sucursal cerrada no admite NINGUNA transición: su ciclo de vida terminó. + BrokenRules.Add(new BrokenRule(nameof(IsClosed), DomainErrors.Tenant.BranchClosed)); + } + else if (!IsActive) { BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Common.Invalid)); } @@ -41,7 +73,15 @@ internal Result CanDeactivate() internal Result CanReactivate() { - if (IsActive) + if (IsClosed) + { + // El cierre es TERMINAL: reactivar no puede ser la puerta de atrás que resucite una + // sucursal cerrada (ADR-0164 §2.4). Si el negocio vuelve a abrir en esa plaza, se da de + // alta una sucursal NUEVA —con otro código, porque el anterior queda ocupado— y la + // cerrada permanece como histórico consultable. + BrokenRules.Add(new BrokenRule(nameof(IsClosed), DomainErrors.Tenant.BranchClosed)); + } + else if (IsActive) { BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Common.Invalid)); } @@ -51,13 +91,82 @@ internal Result CanReactivate() : Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - internal void DeactivateInternal() + /// + /// ¿Puede cerrarse definitivamente? Solo comprueba lo que el propio agregado sabe; las + /// referencias vivas de FUERA del agregado (usuarios y perfiles activos) las verifica + /// Tenant.CloseBranch con los recuentos que le pasa la aplicación. + /// + internal Result CanClose() + { + if (IsClosed) + { + BrokenRules.Add(new BrokenRule(nameof(IsClosed), DomainErrors.Tenant.BranchAlreadyClosed)); + } + + return IsValid() + ? Result.Success() + : Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + // FS-26 (G-024): actualiza los datos editables de la sucursal (nombre, geocerca). + // Invocado por el agregado raíz Tenant (Tenant.UpdateBranch). + internal void UpdateInternal(Name name, Value? geofencingMetadata) + { + Props.Update(name, geofencingMetadata); + } + + internal void DeactivateInternal(ActorId actor, string? reason) { Props.IsActive = false; + Props.Audit.Update(actor.GetValue()); + RecordEpisode(BranchLifecycleEpisode.Deactivated, actor, reason); } - internal void ReactivateInternal() + internal void ReactivateInternal(ActorId actor, string? reason) { Props.IsActive = true; + Props.Audit.Update(actor.GetValue()); + RecordEpisode(BranchLifecycleEpisode.Reactivated, actor, reason); + } + + /// + /// Cierre DEFINITIVO. Marca el estado terminal y apaga IsActive como CONSECUENCIA —una + /// sucursal cerrada evidentemente no opera—, nunca como mecanismo: lo que impide la vuelta es + /// , y por eso lo mira a él. + /// + internal void CloseInternal(ActorId closedBy, string? reason) + { + var now = DateTime.UtcNow; + + Props.IsClosed = true; + Props.ClosedAtUtc = now; + Props.ClosedBy = closedBy.GetValue(); + Props.IsActive = false; + Props.Audit.Update(closedBy.GetValue()); + + RecordEpisode(BranchLifecycleEpisode.Closed, closedBy, reason, now); + } + + /// + /// El repositorio llama a esto tras confirmar la transacción, igual que + /// DomainEvents.MarkChangesAsCommitted(): los asientos ya están en la base y el búfer no + /// debe volver a volcarlos. + /// + internal void MarkLifecycleEntriesAsCommitted() => _pendingLifecycleEntries.Clear(); + + private void RecordEpisode(BranchLifecycleEpisode episode, ActorId actor, string? reason, DateTime? occurredAtUtc = null) + { + _pendingLifecycleEntries.Add(new BranchLifecycleEntry( + Id: Guid.NewGuid(), + TenantId: Props.TenantId.GetValue(), + BranchId: Props.Id.GetValue(), + Episode: episode, + OccurredAtUtc: occurredAtUtc ?? DateTime.UtcNow, + ActorId: actor.GetValue(), + // La foto se toma DESPUÉS de aplicar el cambio de estado, así que un asiento de cierre + // guarda el nombre y la geocerca con los que la sucursal llegó a su último día. + NameSnapshot: Props.Name.GetValue(), + GeofencingSnapshot: Props.GeofencingMetadata?.GetValue(), + Reason: reason)); } } diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchLifecycleEntry.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchLifecycleEntry.cs new file mode 100644 index 00000000..47ddeb3b --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchLifecycleEntry.cs @@ -0,0 +1,41 @@ +namespace Ums.Domain.Identity.Tenant.Branch; + +/// +/// Asiento de la BITÁCORA de una sucursal: un episodio con su fecha, su autor y la foto de cómo era +/// la sucursal en ese instante. +/// +/// Por qué existe. El estado (IsActive, IsClosed) responde «cómo está la +/// sucursal HOY». La pregunta de auditoría de un operador logístico aduanero es otra: «¿cómo estaba +/// en marzo de 2024, cuando salió ESTE despacho?». Sin bitácora esa pregunta no tiene respuesta, +/// porque la fila de la sucursal solo guarda su último estado y su última marca de auditoría: dos +/// épocas distintas de la misma sucursal se ven idénticas. +/// +/// Por qué guarda una foto y no solo el evento. Reconstruir «qué había en marzo» +/// necesita el nombre y la geocerca que regían ENTONCES, no los de hoy. Guardarlos en el asiento +/// —al cerrar y al reabrir, que son las fronteras de cada época— hace la respuesta directa: se lee +/// el asiento anterior a la fecha preguntada. La alternativa, versionar la sucursal entera de forma +/// bitemporal, resuelve más casos a un coste desproporcionado para el problema que hay. +/// +/// Es inmutable y solo crece. No hay corrección ni borrado de asientos: una bitácora +/// que se puede reescribir no prueba nada. Es un record por eso mismo (ADR-0041: dominio +/// inmutable). +/// +/// Identidad del asiento. +/// Inquilino dueño de la sucursal; sostiene el aislamiento (filtro global y RLS). +/// Sucursal a la que pertenece el episodio. +/// Qué ocurrió (apertura, desactivación, reactivación, cierre definitivo). +/// Cuándo ocurrió, en UTC. +/// Quién lo hizo. Es el actor de la operación, no el usuario técnico. +/// Cómo se llamaba la sucursal en ese instante. +/// Dónde estaba (geocerca) en ese instante; nulo si no tenía. +/// Motivo declarado por quien operó, si lo dio. +public sealed record BranchLifecycleEntry( + Guid Id, + Guid TenantId, + Guid BranchId, + BranchLifecycleEpisode Episode, + DateTime OccurredAtUtc, + string ActorId, + string NameSnapshot, + string? GeofencingSnapshot, + string? Reason); diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchProps.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchProps.cs index bbe45c84..eb904d69 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchProps.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchProps.cs @@ -10,6 +10,21 @@ public class BranchProps : IProps public bool IsActive { get; set; } public AuditValueObject Audit { get; private set; } + /// + /// Marca de CIERRE DEFINITIVO (ADR-0164 §2.1). Es un eje INDEPENDIENTE de + /// y no una reutilización suya: «inactiva» es un estado vivo y reversible —Reactivate la + /// devuelve al servicio— mientras que «cerrada» es terminal y no admite vuelta. Colgar el cierre + /// de permitiría resucitar una sucursal cerrada con una simple + /// reactivación, que es justo lo que ADR-0164 §2.4 prohíbe. + /// + public bool IsClosed { get; set; } + + /// Instante del cierre definitivo, en UTC. Nulo mientras la sucursal siga existiendo. + public DateTime? ClosedAtUtc { get; set; } + + /// Actor que cerró la sucursal. Nulo mientras no se haya cerrado. + public string? ClosedBy { get; set; } + public BranchProps(IdValueObject id, TenantId tenantId, Code code, Name name, Value? geofencingMetadata, ActorId createdBy) { Id = id; @@ -18,9 +33,17 @@ public BranchProps(IdValueObject id, TenantId tenantId, Code code, Name name, Va Name = name; GeofencingMetadata = geofencingMetadata; IsActive = true; + IsClosed = false; Audit = AuditValueObject.Create(createdBy.GetValue()); } + // FS-26 (G-024): datos editables de la sucursal. El Code es único dentro del tenant y no cambia. + public void Update(Name name, Value? geofencingMetadata) + { + Name = name; + GeofencingMetadata = geofencingMetadata; + } + public object Clone() { return this.MemberwiseClone(); diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Events/TenantDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Events/TenantDomainEventsManager.cs index f47c8340..67615cf8 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Events/TenantDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Events/TenantDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Identity.Tenant.Events; public class TenantDomainEventsManager : DomainEventsManager @@ -5,20 +6,26 @@ public class TenantDomainEventsManager : DomainEventsManager public TenantDomainEventsManager(IAggregateRoot aggregateRoot) : base(aggregateRoot) { } private void Apply(TenantCreatedEvent @event) { } + private void Apply(TenantUpdatedEvent @event) { } private void Apply(TenantSuspendedEvent @event) { } private void Apply(TenantActivatedEvent @event) { } private void Apply(TenantArchivedEvent @event) { } private void Apply(BranchCreatedEvent @event) { } - private void Apply(BranchRemovedEvent @event) { } + private void Apply(BranchUpdatedEvent @event) { } + private void Apply(BranchClosedEvent @event) { } private void Apply(BranchDeactivatedEvent @event) { } private void Apply(BranchReactivatedEvent @event) { } private void Apply(IdentityProviderRegisteredEvent @event) { } private void Apply(IdentityProviderActivatedEvent @event) { } private void Apply(IdentityProviderDeactivatedEvent @event) { } private void Apply(IdentityProviderRemovedEvent @event) { } + + // Branding por inquilino: eventos propios del satélite. private void Apply(BrandingCreatedEvent @event) { } private void Apply(BrandingUpdatedEvent @event) { } private void Apply(BrandingRemovedEvent @event) { } private void Apply(BrandingDnsVerifiedEvent @event) { } private void Apply(BrandingDnsFailedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs index b2a2122c..ed4c105f 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs @@ -1,19 +1,21 @@ namespace Ums.Domain.Identity.Tenant; using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Identity.Tenant.IdentityProvider; -using Ums.Domain.Identity.Tenant.Branding; using Ums.Domain.Identity.Tenant.TenantParameter; using Ums.Domain.Identity.Tenant.Events; +using Ums.Domain.Identity.Tenant.Branding; using BranchEntity = Ums.Domain.Identity.Tenant.Branch.Branch; using IdentityProviderEntity = Ums.Domain.Identity.Tenant.IdentityProvider.IdentityProvider; -using BrandingEntity = Ums.Domain.Identity.Tenant.Branding.Branding; using TenantParameterEntity = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; +using BrandingEntity = Ums.Domain.Identity.Tenant.Branding.Branding; public sealed class Tenant : AggregateRoot { private readonly List _branches = new(); private readonly List _identityProviders = new(); private readonly List _parameters = new(); + + // Branding por inquilino: entidad hija propia del satélite (no existe en la plataforma de origen). private BrandingEntity? _branding; public new TenantDomainEventsManager DomainEvents { get; } @@ -37,11 +39,23 @@ private Tenant(TenantProps props) : base(props) public bool IsManagementOwner => Props.IsManagementOwner; public TenantStatus Status => Props.Status; + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino (null = suite única / sin filtro). + public SystemSuiteId? DefaultSystemSuiteId => Props.DefaultSystemSuiteId; + + /// + /// TODAS las sucursales del inquilino, incluidas las cerradas definitivamente. La colección no + /// encoge nunca (ADR-0164 §2.1): la vía de escritura necesita verlas para que el código siga + /// ocupado y para que la resolución por id de un perfil antiguo siga encontrando su sucursal. + /// Quien LISTA para un humano debe filtrar por !IsClosed; ver + /// GetBranchesByTenantIdQueryHandler. + /// public IReadOnlyCollection Branches => _branches.AsReadOnly(); public IReadOnlyCollection IdentityProviders => _identityProviders.AsReadOnly(); - public BrandingEntity? Branding => _branding; public IReadOnlyCollection Parameters => _parameters.AsReadOnly(); + /// Branding del inquilino (portal de login por marca). Nulo mientras no se configure. + public BrandingEntity? Branding => _branding; + public IdentityProviderEntity? GetActiveIdentityProvider() { return _identityProviders.FirstOrDefault(ip => ip.IsActive); @@ -84,6 +98,11 @@ public static Result Create( public Result AddBranch(Code code, Name name, ActorId createdBy, Value? geofencingMetadata = null) { + // La comparación NO excluye las sucursales cerradas, y es deliberado: el código de una + // sucursal cerrada queda ocupado PARA SIEMPRE (ADR-0164 §2.3). Liberarlo permitiría dos + // sucursales distintas con el mismo código bajo el mismo inquilino, y una consulta sobre un + // despacho de 2024 no podría decir a cuál de las dos se refiere. El índice único de la base + // tampoco filtra por estado, así que ambos lados dicen lo mismo. if (_branches.Any(b => b.Code.Equals(code))) { BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Tenant.BranchCodeNotUnique)); @@ -107,7 +126,31 @@ public Result AddBranch(Code code, Name name, ActorId createdBy, V return Result.Success(branchResult.Value); } - public Result RemoveBranch(IdValueObject branchId, ActorId updatedBy) + /// + /// CIERRE DEFINITIVO de una sucursal (ADR-0164 §2.1): el verbo terminal del ciclo de vida. + /// + /// Sustituye al antiguo RemoveBranch, que quitaba la sucursal de la colección y el + /// reconciliador de EF traducía en un DELETE real. Ese borrado dejaba huérfanos en + /// silencio —Profiles.BranchId y UserAccounts.BranchId no tienen clave ajena + /// contra TenantBranches— y, sobre todo, hacía imposible explicar un despacho pasado: + /// la sucursal de la que salió ya no existía. La colección ya NUNCA encoge. + /// + /// Es un verbo distinto de , no otro nombre para lo mismo + /// (ADR-0164 §2.4): desactivar es una pausa reversible; cerrar no se revierte, y no se llega a + /// él cambiando IsActive. + /// + /// + /// Cuentas ACTIVAS asignadas a la sucursal. Las cuenta la aplicación —viven en otro agregado— y + /// el dominio solo decide con ellas, igual que UserAccount.Delete(activeProfileCount). + /// Lo ya eliminado no bloquea, porque el recuento solo mira lo activo (ADR-0164 §2.2). + /// + /// Perfiles ACTIVOS acotados a la sucursal, con la misma regla. + public Result CloseBranch( + IdValueObject branchId, + ActorId closedBy, + int activeUserCount = 0, + int activeProfileCount = 0, + string? reason = null) { var branch = FindBranch(branchId); if (branch.IsFailure) @@ -115,9 +158,20 @@ public Result RemoveBranch(IdValueObject branchId, ActorId updatedBy) BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.NotFound)); } - if (branch.IsSuccess && branch.Value.IsActive) + if (branch.IsSuccess) + { + var canClose = branch.Value.CanClose(); + if (canClose.IsFailure) + { + BrokenRules.Add(new BrokenRule(nameof(Branches), canClose.Error)); + } + } + + // Guarda de cascada, el análogo de un ON DELETE RESTRICT: se verifica ANTES de actuar y se + // rechaza nombrando qué bloquea, en vez de arrastrar en cascada o de huerfanizar. + if (activeUserCount > 0 || activeProfileCount > 0) { - BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.Invalid)); + BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Tenant.BranchHasLiveReferences)); } if (!IsValid()) @@ -125,14 +179,17 @@ public Result RemoveBranch(IdValueObject branchId, ActorId updatedBy) return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - _branches.Remove(branch.Value); - DomainEvents.RaiseEvent(new BranchRemovedEvent(Props.Id.GetValue(), branch.Value.GetId().GetValue())); + branch.Value.CloseInternal(closedBy, reason); + DomainEvents.RaiseEvent(new BranchClosedEvent( + Props.Id.GetValue(), + branch.Value.GetId().GetValue(), + branch.Value.Code.GetValue())); TrackingState.MarkAsDirty(); - Props.Audit.Update(updatedBy.GetValue()); + Props.Audit.Update(closedBy.GetValue()); return Result.Success(); } - public Result DeactivateBranch(IdValueObject branchId, ActorId updatedBy) + public Result DeactivateBranch(IdValueObject branchId, ActorId updatedBy, string? reason = null) { var branch = FindBranch(branchId); if (branch.IsFailure) @@ -145,7 +202,10 @@ public Result DeactivateBranch(IdValueObject branchId, ActorId updatedBy) var canDeactivate = branch.Value.CanDeactivate(); if (canDeactivate.IsFailure) { - BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.Invalid)); + // Se propaga el código REAL en vez de colapsarlo a `common.invalid`: «ya está + // inactiva» (400) y «está cerrada» (409) son rechazos distintos, y la presentación + // solo puede distinguirlos si el código llega hasta ella. + BrokenRules.Add(new BrokenRule(nameof(Branches), canDeactivate.Error)); } } @@ -154,14 +214,14 @@ public Result DeactivateBranch(IdValueObject branchId, ActorId updatedBy) return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - branch.Value.DeactivateInternal(); + branch.Value.DeactivateInternal(updatedBy, reason); DomainEvents.RaiseEvent(new BranchDeactivatedEvent(Props.Id.GetValue(), branch.Value.GetId().GetValue())); TrackingState.MarkAsDirty(); Props.Audit.Update(updatedBy.GetValue()); return Result.Success(); } - public Result ReactivateBranch(IdValueObject branchId, ActorId updatedBy) + public Result ReactivateBranch(IdValueObject branchId, ActorId updatedBy, string? reason = null) { var branch = FindBranch(branchId); if (branch.IsFailure) @@ -174,7 +234,8 @@ public Result ReactivateBranch(IdValueObject branchId, ActorId updatedBy) var canReactivate = branch.Value.CanReactivate(); if (canReactivate.IsFailure) { - BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.Invalid)); + // Igual que arriba: una sucursal CERRADA no se reactiva, y ese rechazo debe verse. + BrokenRules.Add(new BrokenRule(nameof(Branches), canReactivate.Error)); } } @@ -183,16 +244,28 @@ public Result ReactivateBranch(IdValueObject branchId, ActorId updatedBy) return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - branch.Value.ReactivateInternal(); + branch.Value.ReactivateInternal(updatedBy, reason); DomainEvents.RaiseEvent(new BranchReactivatedEvent(Props.Id.GetValue(), branch.Value.GetId().GetValue())); TrackingState.MarkAsDirty(); Props.Audit.Update(updatedBy.GetValue()); return Result.Success(); } + /// + /// Vacía el búfer de asientos de bitácora ya persistidos. Lo llama el repositorio tras confirmar + /// la transacción, en paralelo a DomainEvents.MarkChangesAsCommitted(). + /// + public void MarkBranchLifecycleAsCommitted() + { + foreach (var branch in _branches) + { + branch.MarkLifecycleEntriesAsCommitted(); + } + } + public Result RegisterIdentityProvider(Code code, Name name, Description description, IdpStrategy strategy, ActorId createdBy) { - if (_identityProviders.Any(ip => ip.Code == code)) + if (_identityProviders.Any(ip => ip.Code.Equals(code))) { BrokenRules.Add(new BrokenRule(nameof(IdentityProviders), DomainErrors.Tenant.IdpCodeNotUnique)); } @@ -303,6 +376,7 @@ public Result RemoveIdentityProvider(IdValueObject identityProviderId, ActorId u return Result.Success(); } + // ── Branding por inquilino (activo propio del satélite; ausente en la plataforma de origen) ── public Result SetBranding(BrandingSettings settings, ActorId createdBy) { if (_branding is not null) @@ -585,6 +659,56 @@ public Result SetManagementOwner(bool value, ActorId updatedBy) return Result.Success(); } + // FR-042 (ADR-UMS-097 §2.2): fija/limpia la suite por defecto del inquilino, que alimenta la + // procedencia de la suite pre-autenticación cuando el AccessScope no la fija. Pasar null la limpia + // (inquilino de suite única → la resolución omite el filtro por suite, comportamiento previo). + public Result SetDefaultSystemSuite(SystemSuiteId? defaultSystemSuiteId, ActorId updatedBy) + { + SetProps(Props.WithDefaultSystemSuite(defaultSystemSuiteId)); + TrackingState.MarkAsDirty(); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + // FS-26 (G-024): actualiza los datos generales editables del tenant (nombre, tipo, + // referencia fiscal). El Code permanece inmutable (identificador único). + public Result UpdateGeneralData(Name name, OrganizationType type, CompanyReference? companyReference, ActorId updatedBy) + { + SetProps(Props.WithGeneralData(name, type, companyReference)); + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + DomainEvents.RaiseEvent(new TenantUpdatedEvent(Props.Id.GetValue())); + TrackingState.MarkAsDirty(); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + // FS-26 (G-024): actualiza los datos editables de una sucursal (nombre, geocerca). + // El Code de sucursal es único dentro del tenant y permanece inmutable. + public Result UpdateBranch(IdValueObject branchId, Name name, Value? geofencingMetadata, ActorId updatedBy) + { + var branch = FindBranch(branchId); + if (branch.IsFailure) + { + BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.NotFound)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + branch.Value.UpdateInternal(name, geofencingMetadata); + DomainEvents.RaiseEvent(new BranchUpdatedEvent(Props.Id.GetValue(), branch.Value.GetId().GetValue())); + TrackingState.MarkAsDirty(); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + public Result Activate(ActorId updatedBy) { if (Props.Status == TenantStatus.Archived) diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/Events/TenantParameterDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/Events/TenantParameterDomainEventsManager.cs index fd7b52e0..bf32313e 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/Events/TenantParameterDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/Events/TenantParameterDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Identity.Tenant.TenantParameter.Events; public class TenantParameterDomainEventsManager : DomainEventsManager @@ -8,4 +9,6 @@ private void Apply(TenantParameterCreatedEvent @event) { } private void Apply(TenantParameterUpdatedEvent @event) { } private void Apply(TenantParameterDeactivatedEvent @event) { } private void Apply(TenantParameterReactivatedEvent @event) { } -} \ No newline at end of file + private void Apply(TenantParameterDeletedEvent @event) { } +} +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameter.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameter.cs index 197b7c0a..0b69166b 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameter.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameter.cs @@ -25,6 +25,8 @@ private TenantParameter(TenantParameterProps props) : base(props) public TenantParameterValueType ValueType => Props.ValueType; public TenantParameterCategory Category => Props.Category; public bool IsActive => Props.IsActive; + /// Verdadero cuando el parámetro está lógicamente eliminado; las lecturas deben ocultarlo. + public bool IsDeleted => Props.IsDeleted; public bool IsSensitive => Props.IsSensitive; public string? DefaultValue => Props.DefaultValue; public string? AllowedValues => Props.AllowedValues; @@ -109,6 +111,13 @@ public Result UpdateValue(string newValue, ActorId updatedBy) public Result Deactivate(ActorId updatedBy) { + if (IsDeleted) + { + // Un parámetro eliminado ya no admite transiciones: su ciclo de vida terminó. + BrokenRules.Add(new BrokenRule(nameof(IsDeleted), DomainErrors.TenantParameter.AlreadyDeleted)); + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + SetProps(Props.WithIsActive(false)); Props.Audit.Update(updatedBy.GetValue()); TrackingState.MarkAsDirty(); @@ -118,6 +127,15 @@ public Result Deactivate(ActorId updatedBy) public Result Reactivate(ActorId updatedBy) { + if (IsDeleted) + { + // El borrado lógico es TERMINAL: reactivar no puede ser una puerta trasera para resucitar + // un parámetro eliminado. Si el inquilino vuelve a necesitar ese código, se da de alta uno + // nuevo y el eliminado queda como histórico consultable. + BrokenRules.Add(new BrokenRule(nameof(IsDeleted), DomainErrors.TenantParameter.AlreadyDeleted)); + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + SetProps(Props.WithIsActive(true)); Props.Audit.Update(updatedBy.GetValue()); TrackingState.MarkAsDirty(); @@ -125,6 +143,39 @@ public Result Reactivate(ActorId updatedBy) return Result.Success(); } + /// + /// Borrado LÓGICO: marca el parámetro como eliminado sin quitar la fila de la base. Solo existe + /// borrado lógico porque el negocio consulta la configuración histórica de un inquilino (qué valor + /// regía en una fecha dada) y un DELETE real la perdería para siempre. + /// + /// Regla de cascada: un parámetro ACTIVO es una referencia VIVA —la configuración del inquilino lo + /// resuelve ahora mismo por su código a través de ITenantParameterProvider— y no se puede + /// eliminar mientras lo sea. Hay que desactivarlo antes con : esa + /// desactivación ES la eliminación lógica del vínculo, y una vez hecha el borrado sí procede. + /// + public Result Delete(ActorId deletedBy) + { + if (IsDeleted) + { + BrokenRules.Add(new BrokenRule(nameof(IsDeleted), DomainErrors.TenantParameter.AlreadyDeleted)); + } + else if (IsActive) + { + BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.TenantParameter.HasActiveBinding)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + SetProps(Props.WithIsDeleted(true)); + Props.Audit.Update(deletedBy.GetValue()); + TrackingState.MarkAsDirty(); + DomainEvents.RaiseEvent(new TenantParameterDeletedEvent(Props.TenantId.GetValue(), GetId().GetValue(), Props.Code.GetValue())); + return Result.Success(); + } + public string GetTypedValue() { return Props.Value; diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameterProps.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameterProps.cs index 52a5d751..eb274ce4 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameterProps.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameterProps.cs @@ -18,7 +18,8 @@ public TenantParameterProps( bool isSensitive, string? defaultValue, string? allowedValues, - AuditValueObject audit) + AuditValueObject audit, + bool isDeleted = false) { Id = id; TenantId = tenantId; @@ -32,6 +33,7 @@ public TenantParameterProps( DefaultValue = defaultValue; AllowedValues = allowedValues; Audit = audit; + IsDeleted = isDeleted; } public IdValueObject Id { get; private set; } @@ -47,6 +49,14 @@ public TenantParameterProps( public string? AllowedValues { get; private set; } public AuditValueObject Audit { get; private set; } + /// + /// Marca de borrado LÓGICO. Es un eje INDEPENDIENTE de : «inactivo» es un + /// estado vivo y reversible —Reactivate lo devuelve al servicio— mientras que «eliminado» + /// es terminal y desaparece de toda lectura. Reutilizar IsActive para el borrado permitiría + /// resucitar un parámetro eliminado con una simple reactivación. + /// + public bool IsDeleted { get; private set; } + public TenantParameterProps WithValue(string value) { var clone = (TenantParameterProps)MemberwiseClone(); @@ -61,6 +71,13 @@ public TenantParameterProps WithIsActive(bool isActive) return clone; } + public TenantParameterProps WithIsDeleted(bool isDeleted) + { + var clone = (TenantParameterProps)MemberwiseClone(); + clone.IsDeleted = isDeleted; + return clone; + } + public object Clone() { return MemberwiseClone(); diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantProps.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantProps.cs index 0ca0ebe5..441a5eb2 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantProps.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantProps.cs @@ -11,6 +11,11 @@ public class TenantProps : IProps public TenantId? ParentTenantId { get; private set; } public bool IsManagementOwner { get; private set; } public TenantStatus Status { get; private set; } + + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino. Es la procedencia de la suite + // pre-autenticación cuando el AccessScope no la fija. Nullable/retrocompatible: un inquilino de + // suite única la deja en null y la resolución omite el filtro por suite (comportamiento previo). + public SystemSuiteId? DefaultSystemSuiteId { get; private set; } public AuditValueObject Audit { get; private set; } public TenantProps( @@ -45,6 +50,7 @@ public TenantProps( CompanyReference? companyReference, TenantId? parentTenantId, bool isManagementOwner, + SystemSuiteId? defaultSystemSuiteId, TenantStatus status, AuditValueObject audit) { @@ -56,6 +62,7 @@ public TenantProps( CompanyReference = companyReference; ParentTenantId = parentTenantId; IsManagementOwner = isManagementOwner; + DefaultSystemSuiteId = defaultSystemSuiteId; Status = status; Audit = audit; } @@ -67,6 +74,17 @@ public TenantProps WithManagementOwner(bool isManagementOwner) return clone; } + // FS-26 (G-024): actualización de los datos generales editables del tenant. + // El Code es el identificador único y permanece inmutable. + public TenantProps WithGeneralData(Name name, OrganizationType type, CompanyReference? companyReference) + { + var clone = (TenantProps)MemberwiseClone(); + clone.Name = name; + clone.Type = type; + clone.CompanyReference = companyReference; + return clone; + } + public TenantProps WithStatus(TenantStatus status) { var clone = (TenantProps)MemberwiseClone(); @@ -74,6 +92,14 @@ public TenantProps WithStatus(TenantStatus status) return clone; } + // FR-042 (ADR-UMS-097 §2.2): fija/limpia la suite por defecto del inquilino. + public TenantProps WithDefaultSystemSuite(SystemSuiteId? defaultSystemSuiteId) + { + var clone = (TenantProps)MemberwiseClone(); + clone.DefaultSystemSuiteId = defaultSystemSuiteId; + return clone; + } + public object Clone() { return this.MemberwiseClone(); diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/Events/UserAccountDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/Events/UserAccountDomainEventsManager.cs index b781d86a..170a50fb 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/Events/UserAccountDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/Events/UserAccountDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Identity.UserAccount; public class UserAccountDomainEventsManager : DomainEventsManager @@ -16,3 +17,5 @@ private void Apply(MfaEnrollmentRevokedEvent @event) { } private void Apply(ValidityPeriodModifiedEvent @event) { } private void Apply(AuthenticationAttemptedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccount.cs b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccount.cs index 63d771da..5ee2f2bd 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccount.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccount.cs @@ -39,6 +39,10 @@ private UserAccount(UserAccountProps props) : base(props) public IdentityReferenceType? IdentityReferenceType => Props.IdentityReferenceType; public DateTimeOffset? ExpiresAt => Props.ExpiresAt; + // ADR-UMS-095: estado del bloqueo temporal por intentos fallidos. + public int FailedLoginAttempts => Props.FailedLoginAttempts; + public DateTimeOffset? LockedUntil => Props.LockedUntil; + public IReadOnlyCollection MfaEnrollments => _mfaEnrollments.AsReadOnly(); public IReadOnlyCollection PasswordCredentials => _passwordCredentials.AsReadOnly(); @@ -415,8 +419,51 @@ public bool HasVerifiedMfaEnrollment(IEnumerable? allowedMethods = nu return verifiedEnrollments.Any(enrollment => allowedMethodNames.Contains(enrollment.Method.Name)); } - public Result RecordAuthenticationAttempt(bool success, string reason, string ipAddress, ActorId actor) + /// + /// ADR-UMS-095: registra un intento de autenticación y aplica el bloqueo temporal por + /// intentos fallidos. El dominio es determinista: recibe el instante actual () + /// y los parámetros de política (, ) + /// desde la aplicación; no lee reloj ni configuración por su cuenta. + /// + /// + /// - success == false: incrementa ; al alcanzar + /// fija = + + /// . No incrementa si ya está bloqueado (). + /// - success == true: resetea contador y bloqueo. + /// El bloqueo temporal es independiente del bloqueo administrativo permanente ( / + /// ), que prevalece porque el flujo de autenticación rechaza antes + /// a las cuentas no activas. + /// + public Result RecordAuthenticationAttempt( + bool success, + DateTimeOffset now, + int maxAttempts, + int lockoutMinutes, + string reason, + string ipAddress, + ActorId actor) { + if (success) + { + if (Props.FailedLoginAttempts != 0 || Props.LockedUntil is not null) + { + SetProps(Props.WithLockoutState(0, null)); + TrackingState.MarkAsDirty(); + Props.Audit.Update(actor.GetValue()); + } + } + else if (!IsLockedOut(now)) + { + var attempts = Props.FailedLoginAttempts + 1; + var lockedUntil = maxAttempts > 0 && attempts >= maxAttempts + ? (DateTimeOffset?)now.AddMinutes(lockoutMinutes) + : Props.LockedUntil; + + SetProps(Props.WithLockoutState(attempts, lockedUntil)); + TrackingState.MarkAsDirty(); + Props.Audit.Update(actor.GetValue()); + } + DomainEvents.RaiseEvent(new AuthenticationAttemptedEvent( Props.Id.GetValue(), Props.TenantId.GetValue(), @@ -426,9 +473,18 @@ public Result RecordAuthenticationAttempt(bool success, string reason, string ip return Result.Success(); } + /// + /// ADR-UMS-095: consulta pura — indica si la cuenta está bajo bloqueo temporal en el instante dado. + /// + public bool IsLockedOut(DateTimeOffset now) + => Props.LockedUntil is not null && Props.LockedUntil > now; + private Result FindPasswordCredential(IdValueObject credentialId) { - var credential = _passwordCredentials.FirstOrDefault(c => c.Id.GetValue() == credentialId.GetValue()); + // AT06/F1 (misma clase de bug): identidad canónica = Props.Id; el Id base de Entity<> se + // regenera aleatorio en cada construcción y la rehidratación no llama SetId → buscar por c.Id + // fallaba tras recargar (activate/remove de credencial por id → 404). Cf. FindMfaEnrollment. + var credential = _passwordCredentials.FirstOrDefault(c => c.Props.Id.GetValue() == credentialId.GetValue()); return credential is null ? Result.Failure(DomainErrors.Common.NotFound) : Result.Success(credential); @@ -436,7 +492,7 @@ private Result FindPasswordCredential(IdValueObject cr private Result FindMfaEnrollment(IdValueObject enrollmentId) { - var enrollment = _mfaEnrollments.FirstOrDefault(e => e.Id.GetValue() == enrollmentId.GetValue()); + var enrollment = _mfaEnrollments.FirstOrDefault(e => e.GetId().GetValue() == enrollmentId.GetValue()); return enrollment is null ? Result.Failure(DomainErrors.Common.NotFound) : Result.Success(enrollment); diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccountProps.cs b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccountProps.cs index 05b89c09..73048ab9 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccountProps.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccountProps.cs @@ -12,6 +12,13 @@ public class UserAccountProps : IProps public IdentityReference? IdentityReference { get; private set; } public IdentityReferenceType? IdentityReferenceType { get; private set; } public DateTimeOffset? ExpiresAt { get; private set; } + + // ADR-UMS-095: bloqueo temporal de cuenta por intentos fallidos de autenticación. + // Distinto del bloqueo administrativo permanente (Status == Blocked): este es + // auto-expirable vía LockedUntil y no requiere intervención de un administrador. + public int FailedLoginAttempts { get; private set; } + public DateTimeOffset? LockedUntil { get; private set; } + public AuditValueObject Audit { get; private set; } public UserAccountProps( @@ -34,6 +41,8 @@ public UserAccountProps( Status = UserStatus.Pending; IdentityReference = identityReference; IdentityReferenceType = identityReferenceType; + FailedLoginAttempts = 0; + LockedUntil = null; Audit = AuditValueObject.Create(createdBy.GetValue()); } @@ -48,7 +57,9 @@ public UserAccountProps( IdentityReferenceType? identityReferenceType, AuditValueObject audit, Name? displayName = null, - DateTimeOffset? expiresAt = null) + DateTimeOffset? expiresAt = null, + int failedLoginAttempts = 0, + DateTimeOffset? lockedUntil = null) { Id = id; TenantId = tenantId; @@ -60,6 +71,8 @@ public UserAccountProps( IdentityReference = identityReference; IdentityReferenceType = identityReferenceType; ExpiresAt = expiresAt; + FailedLoginAttempts = failedLoginAttempts; + LockedUntil = lockedUntil; Audit = audit; } @@ -77,5 +90,14 @@ public UserAccountProps WithExpiresAt(DateTimeOffset expiresAt) return clone; } + // ADR-UMS-095: muta el estado de bloqueo temporal de forma atómica (contador + vencimiento). + public UserAccountProps WithLockoutState(int failedLoginAttempts, DateTimeOffset? lockedUntil) + { + var clone = (UserAccountProps)MemberwiseClone(); + clone.FailedLoginAttempts = failedLoginAttempts; + clone.LockedUntil = lockedUntil; + return clone; + } + public object Clone() => MemberwiseClone(); } diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/Events/UserManagementDelegationEventsManager.cs b/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/Events/UserManagementDelegationEventsManager.cs index ded30b1a..d634e6f7 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/Events/UserManagementDelegationEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/Events/UserManagementDelegationEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Identity.UserManagementDelegation; using Ums.Domain.Events; @@ -13,3 +14,5 @@ private void Apply(DelegationExpiredEvent @event) { } private void Apply(DelegationRejectedEvent @event) { } private void Apply(DelegationArchivedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/UserManagementDelegation.cs b/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/UserManagementDelegation.cs index 664da8c6..35dbab3c 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/UserManagementDelegation.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/UserManagementDelegation.cs @@ -97,6 +97,13 @@ public static Result Create( public Result Activate(ActorId actorId) { + // FAIL-CLOSED: si la delegación requiere aprobación, la única vía legítima a Active es + // SubmitForApproval → Approve. La activación directa queda vedada. + if (Props.RequiresApproval) + { + BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.Delegation.ApprovalRequired)); + } + // INV-DEL7: REVOKED/EXPIRED → cannot re-activate if (Props.Status == DelegationStatus.Revoked || Props.Status == DelegationStatus.Expired) { @@ -149,6 +156,16 @@ public Result Approve(ActorId actorId) BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.Delegation.CannotActivateFromCurrentStatus)); } + // INV-DEL8 (SoD / separación de funciones): el aprobador no puede ser el administrador + // delegante (quien solicita/crea la delegación). Auto-aprobar una delegación que otorga + // autoridad de administrador es escalada de privilegios. Invariante de dominio, hecha + // cumplir aquí (además de la guarda de autorización de la capa de aplicación). + if (Guid.TryParse(actorId.GetValue(), out var approverId) && + approverId == Props.DelegatingAdminId.GetValue()) + { + BrokenRules.Add(new BrokenRule(nameof(DelegatingAdminId), DomainErrors.Delegation.SelfApprovalNotAllowed)); + } + if (!IsValid()) { return Result.Failure(BrokenRules.GetBrokenRulesAsString()); diff --git a/src/apps/ums.api/Ums.Domain/Kernel/DomainErrors.cs b/src/apps/ums.api/Ums.Domain/Kernel/DomainErrors.cs index 4237c1bb..773d83e4 100644 --- a/src/apps/ums.api/Ums.Domain/Kernel/DomainErrors.cs +++ b/src/apps/ums.api/Ums.Domain/Kernel/DomainErrors.cs @@ -20,6 +20,11 @@ public static class Tenant public const string BranchCodeNotUnique = "tenant.branch_code_not_unique"; public const string BranchNotFound = "tenant.branch_not_found"; public const string BranchActive = "tenant.branch_active"; + // ADR-0164: cierre definitivo de sucursal (verbo terminal del ciclo de vida). + /// La sucursal ya está cerrada: reintentar el cierre choca con su estado actual. + public const string BranchAlreadyClosed = "tenant.branch_already_closed"; + /// La sucursal está cerrada y por eso no admite desactivarse ni reactivarse. + public const string BranchClosed = "tenant.branch_closed"; public const string ArchivedCannotSuspend = "tenant.archived_cannot_suspend"; public const string ArchivedCannotActivate = "tenant.archived_cannot_activate"; public const string AlreadyActive = "tenant.already_active"; @@ -28,16 +33,32 @@ public static class Tenant // ── Dependency guard errors ────────────────────────────────────────── public const string HasActiveUsers = "TENANT_HAS_ACTIVE_USERS"; public const string HasActiveBranches = "TENANT_HAS_ACTIVE_BRANCHES"; + public const string BranchHasActiveUsers = "BRANCH_HAS_ACTIVE_USERS"; + /// + /// ADR-0164 §2.2: no se cierra una sucursal con referencias VIVAS. Es un código único aunque + /// las referencias sean de dos clases (cuentas y perfiles) porque el desglose de QUÉ bloquea + /// viaja aparte, en las BlockingDependency: así la respuesta nombra las dos a la vez + /// en lugar de obligar a quien opera a descubrirlas de una en una. + /// + public const string BranchHasLiveReferences = "BRANCH_HAS_LIVE_REFERENCES"; public const string HasActiveIdpConfig = "TENANT_HAS_ACTIVE_IDP"; public const string IdpCodeNotUnique = "tenant.idp_code_not_unique"; public const string IdpNotFound = "tenant.idp_not_found"; public const string IdpAlreadyActive = "tenant.idp_already_active"; public const string IdpAlreadyInactive = "tenant.idp_already_inactive"; public const string NoActiveIdp = "tenant.no_active_idp"; + // G-037/G-045: la propiedad de gestión (management owner) es única en todo el sistema. + // Otorgarla a un segundo inquilino debe resolver 409 con este código estable y su + // regla de negocio, no una violación de índice único que colapsa a 500. + public const string ManagementOwnerAlreadyExists = "TENANT_OWNER_ALREADY_EXISTS"; + // Branding por inquilino: activo propio del satélite. public const string BrandingAlreadyExists = "tenant.branding_already_exists"; public const string BrandingNotFound = "tenant.branding_not_found"; } + /// + /// Branding por inquilino: activo propio del satélite (no existe en la plataforma de origen). + /// public static class Branding { public const string InvalidHexColor = "branding.invalid_hex_color"; @@ -92,6 +113,7 @@ public static class SystemSuite public const string RoleAlreadyInactive = "system_suite.role_already_inactive"; public const string ActionAlreadyGranted = "system_suite.action_already_granted"; public const string ActionNotGranted = "system_suite.action_not_granted"; + public const string ActionNotRegistered = "system_suite.action_not_registered"; public const string ConfigurationKeyAlreadyExists = "system_suite.configuration_key_already_exists"; public const string ConfigurationKeyNotFound = "system_suite.configuration_key_not_found"; public const string ActionRequiresOwner = "system_suite.action_requires_owner"; @@ -106,10 +128,37 @@ public static class Authorization public const string TemplateAlreadyPublished = "authorization.template_already_published"; public const string TemplateAlreadyDeprecated = "authorization.template_already_deprecated"; public const string TemplateNotDeletable = "authorization.template_not_deletable"; + /// La plantilla ya está en el estado terminal Deleted: el borrado lógico no es repetible. + public const string TemplateAlreadyDeleted = "authorization.template_already_deleted"; public const string TemplateItemTargetAlreadyExists = "authorization.template_item_target_already_exists"; + /// + /// Ya existe un ítem RETIRADO para esa terna (destino, tipo de destino, acción) en la plantilla. + /// + /// Es un conflicto distinto de a propósito: + /// la clave natural del ítem no se libera al retirarlo (ADR-0164 §2.3), así que el alta no puede + /// proceder; pero el operador no está ante un duplicado sino ante una concesión suya que + /// desactivó, y el verbo que la devuelve es reactivar, no volver a añadir. Un único mensaje para + /// los dos casos le diría «ya existe» sobre algo que no ve en la lista de vigentes. + /// + public const string TemplateItemTargetRetired = "authorization.template_item_target_retired"; public const string InvalidPermissionEffect = "authorization.invalid_permission_effect"; + /// + /// Ya existe un perfil ACTIVO del mismo usuario, rol y sucursal en el inquilino. + /// + /// No existía, y nada lo impedía: cada llamada a `POST /profiles` con los mismos + /// datos creaba otro perfil. Un aprovisionamiento reejecutado dejaba al usuario con dos + /// perfiles del mismo rol, uno con permisos y otro vacío —porque la plantilla solo se + /// asigna a uno—, y el selector de perfil le ofrecía ambos. Elegir el vacío es entrar a un + /// sistema sin una sola concesión (G-215). + /// + /// Se restaura tras perderse en la resolución de conflictos del merge de la firma + /// RS256 (PR #192): el fichero volvió a su versión anterior, pero el manejador que la usa + /// no, así que `develop` dejó de compilar. + /// + public const string ProfileAlreadyExistsForRole = "authorization.profile_already_exists_for_role"; public const string ProfileAlreadyActive = "authorization.profile_already_active"; public const string ProfileAlreadyInactive = "authorization.profile_already_inactive"; + public const string ProfileRoleUnchanged = "authorization.profile_role_unchanged"; // ADR-UMS-096: ChangeRole exige rol distinto public const string PermissionAlreadyExists = "authorization.permission_already_exists"; public const string PermissionNotFound = "authorization.permission_not_found"; public const string TemplateNotPublishedForProfile = "authorization.template_not_published_for_profile"; @@ -133,6 +182,20 @@ public static class Authorization public const string TemplateHasActiveProfiles = "TEMPLATE_HAS_ACTIVE_PROFILES"; public const string DomainResourceHasTemplateItems = "DOMAIN_RESOURCE_HAS_TEMPLATE_ITEMS"; public const string ModuleHasActiveMenus = "MODULE_HAS_ACTIVE_MENUS"; + // G-246: eliminación LÓGICA de un sistema. El borrado físico no existe: se hacen consultas + // sobre datos antiguos y una fila borrada de verdad no se recupera. + // · NotDeprecated → hay que archivar antes de eliminar; un sistema en servicio no se va + // por un DELETE suelto. + // · HasDependents → regla de cascada: algo VIVO todavía apunta al sistema. La respuesta + // 409 enumera qué, para que el llamador sepa qué eliminar primero. + // · AlreadyDeleted → ya está eliminado lógicamente; el estado es terminal e idempotente. + public const string SystemSuiteNotDeprecated = "SYSTEM_SUITE_NOT_DEPRECATED"; + public const string SystemSuiteHasDependents = "SYSTEM_SUITE_HAS_DEPENDENTS"; + public const string SystemSuiteAlreadyDeleted = "SYSTEM_SUITE_ALREADY_DELETED"; + // Dos puertas que hay que cerrar para que la guarda de cascada no se pueda esquivar por + // `PUT /system-suites/{id}/status`: ni se entra a «eliminado» por ahí, ni se sale de él. + public const string SystemSuiteDeletedNotSettable = "SYSTEM_SUITE_DELETED_NOT_SETTABLE"; + public const string SystemSuiteDeletedIsTerminal = "SYSTEM_SUITE_DELETED_IS_TERMINAL"; public const string TemplateItemsRequired = "authorization.template_items_required"; public const string AssignmentRulePriorityMustBePositive = "authorization.assignment_rule_priority_must_be_positive"; public const string AssignmentRuleAlreadyActive = "authorization.assignment_rule_already_active"; @@ -147,11 +210,15 @@ public static class Approvals public const string DocumentAlreadyExpired = "approvals.document_already_expired"; public const string PolicyRequiresProfileOrRole = "approvals.policy_requires_profile_or_role"; public const string PolicyAlreadyInactive = "approvals.policy_already_inactive"; + public const string PolicyInactiveCannotUpdate = "approvals.policy_inactive_cannot_update"; public const string RuleAlreadyInactive = "approvals.rule_already_inactive"; public const string RuleAlreadyActive = "approvals.rule_already_active"; public const string DuplicateNotificationRule = "approvals.duplicate_notification_rule"; public const string RequiresDocumentsIfApprovalRequired = "approvals.requires_documents_if_approval_required"; public const string WorkflowNotAllowedForUserCategory = "approvals.workflow_not_allowed_for_user_category"; + public const string RequiredDocumentsIncomplete = "approvals.required_documents_incomplete"; + public const string SelfApprovalNotAllowed = "approvals.self_approval_not_allowed"; + public const string GracePeriodInvalid = "approvals.grace_period_invalid"; } public static class Configuration @@ -163,9 +230,11 @@ public static class Configuration public const string IdpConfigAlreadyArchived = "configuration.idp_config_already_archived"; public const string IdpConfigArchivedCannotChange = "configuration.idp_config_archived_cannot_change"; public const string IdpConfigPayloadInvalid = "configuration.idp_config_payload_invalid"; + public const string IdpFallbackNotFound = "configuration.idp_fallback_not_found"; public const string AppConfigNotDraft = "configuration.app_config_not_draft"; public const string AppConfigNotPublished = "configuration.app_config_not_published"; public const string AppConfigAlreadyArchived = "configuration.app_config_already_archived"; + public const string AppConfigAlreadyDeleted = "configuration.app_config_already_deleted"; public const string AppConfigNonOverridable = "configuration.app_config_non_overridable"; public const string FlagArchivedCannotChange = "configuration.flag_archived_cannot_change"; public const string FlagAlreadyActive = "configuration.flag_already_active"; @@ -179,6 +248,7 @@ public static class Configuration public const string CriteriaValueRequired = "configuration.criteria_value_required"; public const string ParameterCodeNotUnique = "configuration.parameter_code_not_unique"; public const string ParameterHasActiveValues = "configuration.parameter_has_active_values"; + public const string ParameterAlreadyDeleted = "configuration.parameter_already_deleted"; public const string ParameterValueInvalidType = "configuration.parameter_value_invalid_type"; public const string ParameterOverrideNotAllowed = "configuration.parameter_override_not_allowed"; public const string ParameterGlobalValueInUse = "configuration.parameter_global_value_in_use"; @@ -208,6 +278,8 @@ public static class Delegation public const string RevocationReasonRequired = "delegation.revocation_reason_required"; public const string CannotArchiveFromCurrentStatus = "delegation.cannot_archive_from_current_status"; public const string NotActive = "delegation.not_active"; + public const string ApprovalRequired = "delegation.approval_required"; + public const string SelfApprovalNotAllowed = "delegation.self_approval_not_allowed"; } public static class TenantParameter @@ -218,6 +290,18 @@ public static class TenantParameter public const string ValueNotInAllowedList = "tenant_parameter.value_not_in_allowed_list"; public const string CannotDeactivateActive = "tenant_parameter.cannot_deactivate_active"; public const string CannotDeleteWithChildren = "tenant_parameter.cannot_delete_with_children"; + /// El parámetro ya está en el estado terminal de borrado lógico. + public const string AlreadyDeleted = "tenant_parameter.already_deleted"; + // ── Dependency guard errors ────────────────────────────────────────── + /// + /// Guardia de cascada del borrado lógico: un parámetro ACTIVO es una referencia VIVA de la + /// configuración del inquilino —ITenantParameterProvider lo resuelve ahora mismo por su + /// código y ocupa el índice único parcial IX_TenantParameters_TenantId_Code_IsActive—. Primero + /// hay que desactivarlo, que es la eliminación lógica de ese vínculo. Se nombra en MAYÚSCULAS + /// porque es un código de operación bloqueada (409 + BlockedOperationResponse), no un error de + /// validación de dominio. + /// + public const string HasActiveBinding = "TENANT_PARAMETER_HAS_ACTIVE_BINDING"; } public static class ValueObject @@ -226,4 +310,35 @@ public static class ValueObject public const string PropertyRequired = "value_object.property_required"; public const string DateRangeInvalid = "value_object.date_range_invalid"; } + + /// + /// Errores del contexto acotado IGA (Identity Governance & Administration), ADR-UMS-093. + /// Cubren los invariantes del agregado de elegibilidad (RoleMaturityStatus, INV-RMS1..3) + /// y del agregado de promoción (RolePromotionRequest, INV-RPR1..5). + /// + public static class IGA + { + // ── RoleMaturityStatus (INV-RMS1..3) ───────────────────────────────── + public const string InvalidPerformanceScore = "iga.invalid_performance_score"; // INV-RMS1 + public const string MaturityLevelUnchanged = "iga.maturity_level_unchanged"; // INV-RMS2 + public const string MaturityLevelAlreadyMax = "iga.maturity_level_already_max"; // INV-RMS3 (Principal no promocionable) + public const string ComplianceIssuesBlockPromotion = "iga.compliance_issues_block_promotion"; // INV-RMS3 + public const string InsufficientPerformanceScore = "iga.insufficient_performance_score"; // INV-RMS3 + public const string InsufficientTimeInLevel = "iga.insufficient_time_in_level"; // INV-RMS3 + public const string BlockingFactorRequired = "iga.blocking_factor_required"; + + // ── RolePromotionRequest (INV-RPR1..5) ─────────────────────────────── + public const string InvalidStateTransition = "iga.invalid_state_transition"; // INV-RPR1 / INV-RPR5 + public const string RiskScoreOutOfRange = "iga.risk_score_out_of_range"; // VO RiskScore + public const string RiskScoreAlreadyFrozen = "iga.risk_score_already_frozen"; // INV-RPR2 + public const string SegregationOfDutiesViolation = "iga.segregation_of_duties_violation"; // INV-RPR3 + public const string NotEligibleForPromotion = "iga.not_eligible_for_promotion"; // INV-RPR4 (fail-closed) + public const string SelfPromotionNotAllowed = "iga.self_promotion_not_allowed"; // INV-RPR3 (solicitante ≠ objetivo) + public const string DecisionReasonRequired = "iga.decision_reason_required"; + public const string SameRolePromotion = "iga.same_role_promotion"; + // G-100: código estable e idioma-agnóstico para «solicitud de promoción no hallada por id». + // Reemplaza el mensaje en español que los handlers devolvían crudo y que el mapeador HTTP, + // al buscar el substring en inglés «not found», clasificaba erróneamente como 400 en vez de 404. + public const string RolePromotionRequestNotFound = "iga.role_promotion_request_not_found"; + } } diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddress.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddress.cs index f199e8c4..4aa61183 100644 --- a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddress.cs +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddress.cs @@ -11,7 +11,7 @@ public static Result Create(string value) var email = new EmailAddress(value.Trim().ToLowerInvariant()); if (!email.IsValid) { - return Result.Failure(email.BrokenRules.GetBrokenRules().First().Message); + return Result.Failure(email.BrokenRules.GetBrokenRules()[0].Message); } return Result.Success(email); } diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddressValidator.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddressValidator.cs index d82d84d8..99518458 100644 --- a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddressValidator.cs +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddressValidator.cs @@ -6,7 +6,8 @@ public class EmailAddressValidator : AbstractRuleValidator> { private static readonly Regex EmailRegex = new( @"^[^@\s]+@[^@\s]+\.[^@\s]+$", - RegexOptions.Compiled | RegexOptions.IgnoreCase); + RegexOptions.Compiled | RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); public EmailAddressValidator(ValueObject subject) : base(subject) { } diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RiskScore.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RiskScore.cs new file mode 100644 index 00000000..b3534afb --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RiskScore.cs @@ -0,0 +1,35 @@ +namespace Ums.Domain.Kernel.ValueObjects; + +using BeyondNetCode.Shell.Ddd.ValueObjects.Common; + +/// +/// Objeto de valor inmutable que representa el RiskScore de impacto tóxico de una +/// promoción de rol (FR-061, ADR-UMS-093). Su rango válido es [0, 100]. Una vez congelado +/// dentro de RolePromotionRequest al salir de Draft, no puede recalcularse: +/// recalcular exige una nueva solicitud (inmutabilidad por solicitud, INV-RPR2). +/// +public sealed class RiskScore : IntValueObject +{ + public const int Min = 0; + public const int Max = 100; + + private RiskScore(int value) : base(value) { } + + /// + /// Crea un validando el rango [0, 100]. Fuera de rango + /// devuelve Result.Failure con + /// (nunca excepción). + /// + public static Result Create(int value) + { + if (value < Min || value > Max) + { + return Result.Failure(DomainErrors.IGA.RiskScoreOutOfRange); + } + + return Result.Success(new RiskScore(value)); + } + + /// Rehidrata un valor ya persistido (se asume dentro de rango). + public static RiskScore Load(int value) => new RiskScore(value); +} diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RoleMaturityStatusId.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RoleMaturityStatusId.cs new file mode 100644 index 00000000..78a8315a --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RoleMaturityStatusId.cs @@ -0,0 +1,9 @@ +namespace Ums.Domain.Kernel.ValueObjects; + +public class RoleMaturityStatusId : IdValueObject +{ + private RoleMaturityStatusId(Guid value) : base(value) { } + public static new RoleMaturityStatusId Create() => new RoleMaturityStatusId(Guid.NewGuid()); + public static new RoleMaturityStatusId Load(Guid value) => new RoleMaturityStatusId(value); + public static new RoleMaturityStatusId Load(string value) => new RoleMaturityStatusId(Guid.Parse(value)); +} diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RolePromotionRequestId.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RolePromotionRequestId.cs new file mode 100644 index 00000000..9595eabd --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RolePromotionRequestId.cs @@ -0,0 +1,9 @@ +namespace Ums.Domain.Kernel.ValueObjects; + +public class RolePromotionRequestId : IdValueObject +{ + private RolePromotionRequestId(Guid value) : base(value) { } + public static new RolePromotionRequestId Create() => new RolePromotionRequestId(Guid.NewGuid()); + public static new RolePromotionRequestId Load(Guid value) => new RolePromotionRequestId(value); + public static new RolePromotionRequestId Load(string value) => new RolePromotionRequestId(Guid.Parse(value)); +} diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/TemplateVersion.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/TemplateVersion.cs index 90863263..d24bb113 100644 --- a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/TemplateVersion.cs +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/TemplateVersion.cs @@ -1,6 +1,6 @@ namespace Ums.Domain.Kernel.ValueObjects; -public class TemplateVersion : StringValueObject +public class TemplateVersion : StringValueObject, IComparable { private TemplateVersion(string value) : base(value) { } @@ -11,6 +11,40 @@ public static TemplateVersion Create(int major, int minor, int patch) public static TemplateVersion Initial() => new TemplateVersion("0.1.0"); + /// Segmento MAJOR del semver (0 si el valor no es parseable). + public int Major => Segment(0); + + /// Segmento MINOR del semver (0 si el valor no es parseable). + public int Minor => Segment(1); + + /// Segmento PATCH del semver (0 si el valor no es parseable). + public int Patch => Segment(2); + + /// + /// Devuelve la versión inmediatamente siguiente incrementando el segmento MINOR y + /// reiniciando PATCH (p. ej. 0.1.0 → 0.2.0). Cf. G-140 / ADR-UMS-140: el alta de una + /// plantilla sobre una terna (tenant, rol, suite) ya plantillada genera una revisión nueva. + /// + public TemplateVersion Next() => Create(Major, Minor + 1, 0); + + public int CompareTo(TemplateVersion? other) + { + if (other is null) return 1; + var major = Major.CompareTo(other.Major); + if (major != 0) return major; + var minor = Minor.CompareTo(other.Minor); + if (minor != 0) return minor; + return Patch.CompareTo(other.Patch); + } + + private int Segment(int index) + { + var value = GetValue(); + if (string.IsNullOrWhiteSpace(value)) return 0; + var parts = value.Split('.'); + return index < parts.Length && int.TryParse(parts[index], out var n) ? n : 0; + } + public override void AddValidators() { base.AddValidators(); diff --git a/src/apps/ums.api/Ums.Globalization/Access/StringLocalizer.cs b/src/apps/ums.api/Ums.Globalization/Access/StringLocalizer.cs index 7c8453b3..e1642435 100644 --- a/src/apps/ums.api/Ums.Globalization/Access/StringLocalizer.cs +++ b/src/apps/ums.api/Ums.Globalization/Access/StringLocalizer.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 using System.Collections.Concurrent; using System.Reflection; using System.Text.Json; @@ -59,3 +60,5 @@ private static Dictionary LoadResources(string language) return merged; } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailChannelSink.cs b/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailChannelSink.cs deleted file mode 100644 index 2e0f8e27..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailChannelSink.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Ums.Infrastructure.Aop; - -public sealed class AuditTrailChannelSink : IAuditTrailSink -{ - private readonly Channel _channel; - - public AuditTrailChannelSink(Channel channel) - { - _channel = channel; - } - - public bool TryWrite(AuditTrailEntry entry) => _channel.Writer.TryWrite(entry); -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailOutboxSink.cs b/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailOutboxSink.cs new file mode 100644 index 00000000..125b1680 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailOutboxSink.cs @@ -0,0 +1,62 @@ +using MassTransit; +using Microsoft.Extensions.DependencyInjection; +using Ums.Infrastructure.Persistence; + +namespace Ums.Infrastructure.Aop; + +/// +/// G-040: encola el registro de auditoría automática por el Transactional Outbox de +/// MassTransit (ADR-0098 D6: efecto durable cuya pérdida es irreparable — no-repudiación). +/// Reemplaza el canal en memoria con descarte silencioso. +/// +/// La auditoría se captura DESPUÉS del commit del caso de uso (el AuditTrailAspect es el +/// aspecto más interno, Order=100). Por eso: +/// - Publica por el con ámbito de petición. Bajo kind/prod con +/// UseBusOutbox() ese endpoint escribe en la tabla de salida; el mensaje se materializa +/// al llamar SaveChangesAsync sobre el de la petición +/// (flush) y el servicio de entrega lo despacha POST-commit al bróker; el consumidor lo +/// persiste append-only con idempotencia por el inbox EF. +/// - En dev/tests (bus en memoria, sin outbox EF) no hay +/// registrado: el publish entrega directamente al consumidor en proceso y el flush se omite. +/// - Cualquier fallo se registra como error alertable; nunca se descarta (fin del +/// TryWrite==false silencioso). +/// +public sealed class AuditTrailOutboxSink( + IPublishEndpoint publishEndpoint, + IServiceProvider serviceProvider, + ILogger logger, + Ums.Application.Common.Interfaces.IFunctionalTransaction functionalTransaction) : IAuditTrailSink +{ + public async Task PublishAsync(AuditTrailEntry entry, CancellationToken cancellationToken = default) + { + try + { + // G-040 (FR-072): desinfecta la metadata ANTES de encolar — la traza es append-only e + // inmutable (G-081), así que un secreto (hash/PIN/llave/token) que se filtrase quedaría + // irreparable. Este sink es el único punto por el que pasan TODAS las emisiones automáticas + // (AuditTrailAspect y ConfigurationAuditService), así que el saneo aquí las cubre a todas y, + // al hacerlo antes del Publish, el secreto tampoco llega a la tabla de salida del outbox. + var sanitizedEntry = entry with { Metadata = AuditMetadataSanitizer.Sanitize(entry.Metadata) }; + + functionalTransaction.RecordEffect("message.publish", "outbox", $"AuditTrailEntry-{sanitizedEntry.EventType}", Ums.Application.Common.Interfaces.EffectReversibility.PendingCompensation); + await publishEndpoint.Publish(sanitizedEntry, cancellationToken); + + // Flush del bus-outbox: materializa el mensaje de salida en la conexión de la petición + // para que el servicio de entrega lo despache POST-commit. En modos sin outbox EF + // (dev/tests en memoria) el contexto de escritura no está registrado y el publish ya + // entregó en proceso, así que se omite. + var writeDbContext = serviceProvider.GetService(); + if (writeDbContext is not null) + await writeDbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + // Sin descarte silencioso (G-040): la auditoría es no repudiable; el fallo se alerta. + logger.LogError(ex, + "Auditoría no confiable: fallo al encolar el registro de auditoría {EventType} para {AffectedEntityType}/{AffectedEntityId}. Evento alertable.", + entry.EventType, + entry.AffectedEntityType, + entry.AffectedEntityId); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Aop/FactoryLoggingInterceptor.cs b/src/apps/ums.api/Ums.Infrastructure/Aop/FactoryLoggingInterceptor.cs index 3ab5bf08..8ca5fc14 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Aop/FactoryLoggingInterceptor.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Aop/FactoryLoggingInterceptor.cs @@ -30,9 +30,9 @@ public override void OnSuccess(TTarget target, string name, I services.Count, typeof(TService).Name, typeof(TTarget).Name); } - public override void OnError(TTarget target, string name, IList services, Exception ex) + public override void OnError(TTarget target, string name, IList services, Exception exception) { - _logger.LogError(ex, "Factory resolution failed for Service={ServiceType}, Target={TargetType}, Group={GroupName}", + _logger.LogError(exception, "Factory resolution failed for Service={ServiceType}, Target={TargetType}, Group={GroupName}", typeof(TService).Name, typeof(TTarget).Name, name ?? "default"); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Aop/UmsSerilogLogger.cs b/src/apps/ums.api/Ums.Infrastructure/Aop/UmsSerilogLogger.cs index 70707c58..dabf78c9 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Aop/UmsSerilogLogger.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Aop/UmsSerilogLogger.cs @@ -1,7 +1,9 @@ using System; +using System.Diagnostics; using System.Linq; using Microsoft.Extensions.Logging; using Ums.Application.Common.Aop; +using Ums.Application.Common.Interfaces; using BeyondNetCode.Shell.Aop; using BeyondNetCode.Shell.Aop.Aspects; @@ -11,62 +13,75 @@ namespace Ums.Infrastructure.Aop; /// -/// Serilog-backed, observability-aware implementation of . -/// -/// Enriches every AOP log entry with the full UMS observability envelope: -/// -/// TenantId — from (multi-tenant dimension) -/// CorrelationId — from W3C Activity baggage key "correlation.id" -/// (written by CorrelationIdMiddleware) -/// TraceId / SpanId — from (W3C trace context); -/// also emitted automatically by the Serilog OTel sink, but made explicit here so -/// non-OTel sinks (Console, Loki, Seq) carry the same fields -/// BoundedContext — inferred from the second namespace segment of the -/// handler type (e.g. Ums.Application.Identity.Tenant.Commands → "Identity") -/// -/// -/// Log levels: +/// Implementación de respaldada por Serilog y consciente de la +/// observabilidad. Enriquece cada línea de log AOP con la envolvente de UMS: /// -/// — method entry and successful exit -/// — unhandled exceptions +/// TenantId — de (dimensión multi-tenant) +/// TraceId / SpanId — del contexto de traza W3C +/// ( / traceparent), tras la unificación W3C (ADR-0046). +/// También los emite el enricher de Serilog en todas las líneas; aquí se hacen explícitos +/// para las líneas AOP. +/// CorrelationId — es el trace_id W3C: la correlación deja de +/// generarse a mano (se retiró el shim vendorizado y el X-Correlation-Id). +/// SessionTrackingId — de (rastreo de sesión de UMS) +/// BoundedContext — inferido del namespace del handler /// /// -/// PII safety: argument values are never emitted — only parameter names and CLR types. -/// -/// Registration (Infrastructure DI): -/// -/// services.AddKeyedTransient<AopILogger, UmsSerilogLogger>(typeof(IUmsLogger)); -/// +/// Seguridad PII: nunca se emiten los valores de los argumentos, solo su nombre y tipo CLR. /// public sealed class UmsSerilogLogger( MelILoggerFactory loggerFactory, IUserContext userContext, - IExecutionContextAccessor executionContextAccessor) : StructuredAopLoggerBase(executionContextAccessor), IUmsLogger + IRequestContext requestContext) : IUmsLogger { // ── Helpers ─────────────────────────────────────────────────────────────────────────── private MelILogger Logger(IJoinPoint jp) => loggerFactory.CreateLogger(jp.TargetType); - /// Tenant from scoped IUserContext, or "system" when running outside a user request. + /// Tenant del IUserContext scoped, o "system" fuera de una petición de usuario. private string TenantId() => userContext.TenantId ?? "system"; - private static string BoundedContext(Type targetType) => InferBoundedContext(targetType); + private (string CorrelationId, string SessionTrackingId, string TraceId, string SpanId) Correlation() + { + var activity = Activity.Current; + var traceId = activity is { IdFormat: ActivityIdFormat.W3C } ? activity.TraceId.ToString() : string.Empty; + var spanId = activity is { IdFormat: ActivityIdFormat.W3C } ? activity.SpanId.ToString() : string.Empty; + + return ( + CorrelationId: requestContext.CorrelationId ?? traceId, + SessionTrackingId: requestContext.SessionTrackingId ?? string.Empty, + TraceId: traceId, + SpanId: spanId); + } + + private static string BoundedContext(Type targetType) + { + // Ums.Application..… → el contexto acotado es el 4.º segmento + // (p. ej. Ums.Application.Identity.Tenant.Commands → "Identity"). + var parts = targetType.Namespace?.Split('.') ?? Array.Empty(); + if (parts.Length >= 4) + { + return parts[3]; + } + + return parts.Length >= 3 ? parts[2] : targetType.Name; + } - // ── ILogger contract ───────────────────────────────────────────────────────────────── + // ── Contrato ILogger ───────────────────────────────────────────────────────────────── /// - public override void OnEntry(IJoinPoint joinPoint, Argument[] arguments, string requestId) + public void OnEntry(IJoinPoint joinPoint, Argument[] arguments, string requestId) { - var log = Logger(joinPoint); + var log = Logger(joinPoint); if (!log.IsEnabled(LogLevel.Information)) return; - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); - var bc = BoundedContext(joinPoint.TargetType); + var (correlationId, sessionTrackingId, traceId, spanId) = Correlation(); + var tenantId = TenantId(); + var bc = BoundedContext(joinPoint.TargetType); - // PII-safe: only names + CLR types, never values. + // PII-safe: solo nombres + tipos CLR, nunca valores. var argSummary = arguments is { Length: > 0 } ? string.Join(", ", arguments.Select(a => $"{a.Name}:{a.Type}")) : string.Empty; @@ -76,87 +91,65 @@ public override void OnEntry(IJoinPoint joinPoint, Argument[] arguments, string log.LogInformation( "→ {BoundedContext} {Handler}.{Method} | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", bc, joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); + tenantId, correlationId, sessionTrackingId, traceId, spanId); } else { log.LogInformation( "→ {BoundedContext} {Handler}.{Method} params=[{Params}] | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", bc, joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, argSummary, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); + tenantId, correlationId, sessionTrackingId, traceId, spanId); } } /// - public override void OnExit(IJoinPoint joinPoint, Return @return, string requestId, long duration) - { - var log = Logger(joinPoint); - if (!log.IsEnabled(LogLevel.Information)) return; - - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); - - log.LogInformation( - "← {BoundedContext} {Handler}.{Method} in {Duration}ms | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", - BoundedContext(joinPoint.TargetType), - joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - duration, tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); - } + public void OnExit(IJoinPoint joinPoint, Return @return, string requestId, long duration) + => LogExit(joinPoint, duration); /// - public override void OnExit(IJoinPoint joinPoint, string requestId, long duration) - { - var log = Logger(joinPoint); - if (!log.IsEnabled(LogLevel.Information)) return; - - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); - - log.LogInformation( - "← {BoundedContext} {Handler}.{Method} in {Duration}ms | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", - BoundedContext(joinPoint.TargetType), - joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - duration, tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); - } + public void OnExit(IJoinPoint joinPoint, string requestId, long duration) + => LogExit(joinPoint, duration); /// - public override void OnExit(IJoinPoint joinPoint, Return @return, string requestId) - { - var log = Logger(joinPoint); - if (!log.IsEnabled(LogLevel.Information)) return; - - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); - - log.LogInformation( - "← {BoundedContext} {Handler}.{Method} | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", - BoundedContext(joinPoint.TargetType), - joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); - } + public void OnExit(IJoinPoint joinPoint, Return @return, string requestId) + => LogExit(joinPoint, duration: null); /// - public override void OnExit(IJoinPoint joinPoint, string requestId) + public void OnExit(IJoinPoint joinPoint, string requestId) + => LogExit(joinPoint, duration: null); + + private void LogExit(IJoinPoint joinPoint, long? duration) { - var log = Logger(joinPoint); + var log = Logger(joinPoint); if (!log.IsEnabled(LogLevel.Information)) return; - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); + var (correlationId, sessionTrackingId, traceId, spanId) = Correlation(); + var tenantId = TenantId(); - log.LogInformation( - "← {BoundedContext} {Handler}.{Method} | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", - BoundedContext(joinPoint.TargetType), - joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); + if (duration is { } ms) + { + log.LogInformation( + "← {BoundedContext} {Handler}.{Method} in {Duration}ms | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", + BoundedContext(joinPoint.TargetType), + joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, + ms, tenantId, correlationId, sessionTrackingId, traceId, spanId); + } + else + { + log.LogInformation( + "← {BoundedContext} {Handler}.{Method} | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", + BoundedContext(joinPoint.TargetType), + joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, + tenantId, correlationId, sessionTrackingId, traceId, spanId); + } } /// - public override void OnException(IJoinPoint joinPoint, string requestId, Exception ex) + public void OnException(IJoinPoint joinPoint, string requestId, Exception ex) { - var log = Logger(joinPoint); - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); + var log = Logger(joinPoint); + var (correlationId, sessionTrackingId, traceId, spanId) = Correlation(); + var tenantId = TenantId(); log.LogError( ex, @@ -164,6 +157,6 @@ public override void OnException(IJoinPoint joinPoint, string requestId, Excepti BoundedContext(joinPoint.TargetType), joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, ex.GetType().Name, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); + tenantId, correlationId, sessionTrackingId, traceId, spanId); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Approvals/NotificationRule/NotificationRecipientStrategies.cs b/src/apps/ums.api/Ums.Infrastructure/Approvals/NotificationRule/NotificationRecipientStrategies.cs index b917f445..4cfc87a1 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Approvals/NotificationRule/NotificationRecipientStrategies.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Approvals/NotificationRule/NotificationRecipientStrategies.cs @@ -30,7 +30,7 @@ public override Result Normalize(string recipient) internal sealed class SmsNotificationRecipientStrategy : NotificationRecipientStrategyBase { - private static readonly Regex AllowedCharacters = new(@"^[\d\+\-\(\)\s]+$", RegexOptions.Compiled); + private static readonly Regex AllowedCharacters = new(@"^[\d\+\-\(\)\s]+$", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); public override Result Normalize(string recipient) { diff --git a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/CsvAuthorizationGraphSerializer.cs b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/CsvAuthorizationGraphSerializer.cs index 8d353dff..1bfeb53b 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/CsvAuthorizationGraphSerializer.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/CsvAuthorizationGraphSerializer.cs @@ -14,38 +14,41 @@ public sealed class CsvAuthorizationGraphSerializer : IAuthorizationGraphSeriali public string ContentType => "text/csv"; public string FileExtension => "csv"; - public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options = null) + public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) { var opts = options ?? GraphSerializationOptions.Default; var sb = new StringBuilder(); - sb.AppendLine($"# UMS Authorization Graph — {g.Context.Tenant.Code} / {g.Context.SystemSuite.Code} / {g.Context.Role.Code}"); - sb.AppendLine($"# Generated: {g.GeneratedAt:O} ValidUntil: {g.ValidUntil:O}"); - sb.AppendLine($"# Auth: {g.Authentication.Method} MFA: {g.Authentication.MfaRequired}"); + sb.AppendLine($"# UMS Authorization Graph — {graph.Context.Tenant.Code} / {graph.Context.SystemSuite.Code} / {graph.Context.Role.Code}"); + sb.AppendLine($"# Generated: {graph.GeneratedAt:O} ValidUntil: {graph.ValidUntil:O}"); + sb.AppendLine($"# Auth: {graph.Authentication.Method} MFA: {graph.Authentication.MfaRequired}"); sb.AppendLine(); - sb.AppendLine("Section,ModuleCode,ModuleValue,MenuCode,MenuValue,SubMenuCode,SubMenuValue,OptionCode,OptionValue,ActionCode,Effect,Source"); - foreach (var module in g.MenuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options) + // El árbol admite cualquier profundidad, así que la columna que lo identifica es la RUTA + // completa (`MENU/SUBMENU/OPCION`), no tres columnas fijas que se quedarían cortas. + sb.AppendLine("Section,ModuleCode,ModuleValue,NodePath,NodeCode,NodeValue,NodeKind,ActionCode,Effect,Source"); + foreach (var module in graph.MenuAccess) { - sb.AppendLine(string.Join(",", - "Menu", - Esc(module.Code), Esc(module.Name), - Esc(menu.Code), Esc(menu.Label), - Esc(sub.Code), Esc(sub.Label), - Esc(opt.Code), Esc(opt.Label), - Esc(opt.ActionCode), - opt.Effect.ToString(), - opt.Source.ToString())); + foreach (var (nodo, ruta) in Aplanar(module.Nodes, string.Empty)) + { + foreach (var accion in nodo.Actions) + { + sb.AppendLine(string.Join(",", + "Menu", + Esc(module.Code), Esc(module.Name), + Esc(ruta), Esc(nodo.Code), Esc(nodo.Name), Esc(nodo.Kind), + Esc(accion.ActionCode), + accion.Effect.ToString(), + accion.Source.ToString())); + } + } } sb.AppendLine(); sb.AppendLine("Section,ResourceType,ResourceCode,ResourceValue,ActionCode,ActionValue,Effect,Source"); - foreach (var res in g.DomainPermissions) - foreach (var act in res.Actions) + foreach (var (res, act) in graph.DomainPermissions + .SelectMany(res => res.Actions, (res, act) => (res, act))) { sb.AppendLine(string.Join(",", "Domain", @@ -57,7 +60,7 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options sb.AppendLine(); sb.AppendLine("Section,FlagCode,IsEnabled,MatchedCriteria"); - foreach (var f in g.FeatureFlags) + foreach (var f in graph.FeatureFlags) { sb.AppendLine(string.Join(",", "Feature", @@ -69,24 +72,36 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options sb.AppendLine(); sb.AppendLine("Section,Scope"); - foreach (var s in g.Scopes) + foreach (var s in graph.Scopes) sb.AppendLine($"Scope,{Esc(s)}"); if (opts.IncludeTechnicalMetadata) { sb.AppendLine(); sb.AppendLine("Section,TechnicalField,Value"); - sb.AppendLine($"Technical,UserId,{Esc(g.Context.User.Id.ToString())}"); - sb.AppendLine($"Technical,TenantId,{Esc(g.Context.Tenant.Id.ToString())}"); - sb.AppendLine($"Technical,SystemSuiteId,{Esc(g.Context.SystemSuite.Id.ToString())}"); - sb.AppendLine($"Technical,RoleId,{Esc(g.Context.Role.Id.ToString())}"); - if (g.Context.Branch is not null) - sb.AppendLine($"Technical,BranchId,{Esc(g.Context.Branch.Id.ToString())}"); + sb.AppendLine($"Technical,UserId,{Esc(graph.Context.User.Id.ToString())}"); + sb.AppendLine($"Technical,TenantId,{Esc(graph.Context.Tenant.Id.ToString())}"); + sb.AppendLine($"Technical,SystemSuiteId,{Esc(graph.Context.SystemSuite.Id.ToString())}"); + sb.AppendLine($"Technical,RoleId,{Esc(graph.Context.Role.Id.ToString())}"); + if (graph.Context.Branch is not null) + sb.AppendLine($"Technical,BranchId,{Esc(graph.Context.Branch.Id.ToString())}"); } return sb.ToString(); } + /// Recorre el árbol devolviendo cada nodo con su ruta de códigos. + private static IEnumerable<(Ums.Domain.Authorization.Graph.GraphNavigationNode Nodo, string Ruta)> Aplanar( + IEnumerable nodos, string prefijo) + { + foreach (var nodo in nodos) + { + var ruta = string.IsNullOrEmpty(prefijo) ? nodo.Code : $"{prefijo}/{nodo.Code}"; + yield return (nodo, ruta); + foreach (var hijo in Aplanar(nodo.Children, ruta)) yield return hijo; + } + } + private static string Esc(string? v) { if (v is null) return ""; diff --git a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/JsonAuthorizationGraphSerializer.cs b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/JsonAuthorizationGraphSerializer.cs index ae691471..adb86824 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/JsonAuthorizationGraphSerializer.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/JsonAuthorizationGraphSerializer.cs @@ -1,11 +1,25 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Ums.Application.Authorization.Graph; using Ums.Application.Authorization.Graph.Serializers; using Ums.Domain.Authorization.Graph; namespace Ums.Infrastructure.Authorization.Graph; -/// Serializes AuthorizationGraph to JSON (default format). +/// +/// Serializa el a JSON (formato por defecto). +/// +/// La FORMA la define , compartida con el login +/// web: este serializador solo elige la representación. Antes proyectaba su +/// propio modelo y por eso el mismo grafo salía distinto según el endpoint +/// (G-167). +/// +/// No se ignoran los nulos: branch, systemSuite, provider o +/// matchedCriteriaType viajan explícitamente como null. Los +/// campos verdaderamente opcionales —los identificadores técnicos— los omite el +/// mapeador, no el serializador, para que un cliente pueda distinguir «este +/// inquilino no publica ids» de «este objeto no tiene valor». +/// public sealed class JsonAuthorizationGraphSerializer : IAuthorizationGraphSerializer { public string ContentType => "application/json"; @@ -13,131 +27,30 @@ public sealed class JsonAuthorizationGraphSerializer : IAuthorizationGraphSerial private static readonly JsonSerializerOptions _options = new() { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new JsonStringEnumConverter() }, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, }; - public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) + private static readonly JsonSerializerOptions _compact = new() { - var opts = options ?? GraphSerializationOptions.Default; - var model = BuildModel(graph, opts); - return JsonSerializer.Serialize(model, _options); - } + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; - private static object BuildModel(AuthorizationGraph g, GraphSerializationOptions opts) + public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) { - var includeMeta = opts.IncludeTechnicalMetadata; + var opts = options ?? GraphSerializationOptions.Default; + var model = AuthGraphPayload.Build(graph, opts); + var json = JsonSerializer.Serialize(model, opts.PrettyPrint ? _options : _compact); + + // Tamaño ANTES de comprimir: es el que crece con el catálogo y el que hay que vigilar. + // Lo que viaja por el cable lo mide la capa HTTP, y la diferencia entre ambos es + // justamente el ahorro de la compresión. + GraphMetrics.TamanoPayload.Record( + System.Text.Encoding.UTF8.GetByteCount(json), + new KeyValuePair("system", graph.Context.SystemSuite?.Code ?? "n/a"), + new KeyValuePair("role", graph.Context.Role?.Code ?? "n/a")); - return new - { - context = new - { - user = new - { - id = includeMeta ? g.Context.User.Id.ToString() : null, - g.Context.User.Email, - g.Context.User.Username, - value = g.Context.User.DisplayName, - g.Context.User.Status, - }, - tenant = new - { - id = includeMeta ? g.Context.Tenant.Id.ToString() : null, - g.Context.Tenant.Code, - value = g.Context.Tenant.Name, - g.Context.Tenant.Status, - g.Context.Tenant.IsManagementOwner, - }, - systemSuite = new - { - id = includeMeta ? g.Context.SystemSuite.Id.ToString() : null, - g.Context.SystemSuite.Code, - value = g.Context.SystemSuite.Name, - g.Context.SystemSuite.Status, - }, - role = new - { - id = includeMeta ? g.Context.Role.Id.ToString() : null, - g.Context.Role.Code, - value = g.Context.Role.Name, - g.Context.Role.HierarchyLevel, - }, - profile = new - { - id = includeMeta ? g.Context.Profile.Id.ToString() : null, - g.Context.Profile.Scope, - g.Context.Profile.IsActive, - }, - branch = g.Context.Branch is null ? null : new - { - id = includeMeta ? g.Context.Branch.Id.ToString() : null, - g.Context.Branch.Code, - value = g.Context.Branch.Name, - }, - }, - authentication = new - { - method = g.Authentication.Method, - provider = g.Authentication.Provider is null ? null : new - { - id = includeMeta ? g.Authentication.Provider.Id.ToString() : null, - g.Authentication.Provider.Name, - g.Authentication.Provider.Code, - value = g.Authentication.Provider.Strategy, - }, - mfaRequired = g.Authentication.MfaRequired, - issuedAt = g.Authentication.IssuedAt.ToString("O"), - sessionExpiresAt = g.Authentication.SessionExpiresAt.ToString("O"), - }, - actions = g.Actions.Select(a => new { a.Code, value = a.Name }), - menuAccess = g.MenuAccess.Select(m => new - { - m.Code, value = m.Name, m.Status, - menus = m.Menus.Select(menu => new - { - menu.Code, value = menu.Label, - subMenus = menu.SubMenus.Select(sub => new - { - sub.Code, value = sub.Label, - options = sub.Options.Select(o => new - { - o.Code, value = o.Label, o.ActionCode, - effect = o.Effect.ToString(), - source = o.Source.ToString(), - }) - }) - }) - }), - domainPermissions = g.DomainPermissions.Select(r => new - { - resourceId = includeMeta ? r.ResourceId.ToString() : null, - r.ResourceType, - code = r.ResourceCode, - value = r.ResourceName, - moduleId = includeMeta && r.ModuleId.HasValue ? r.ModuleId.Value.ToString() : null, - parentResourceId = includeMeta && r.ParentResourceId.HasValue ? r.ParentResourceId.Value.ToString() : null, - actions = r.Actions.Select(a => new - { - code = a.ActionCode, - value = a.ActionName, - effect = a.Effect.ToString(), - source = a.Source.ToString(), - }) - }), - featureFlags = g.FeatureFlags.Select(f => new { code = f.FlagCode, isEnabled = f.IsEnabled, f.MatchedCriteriaType }), - effectiveConfig = new - { - g.EffectiveConfig.SessionTimeoutMinutes, - g.EffectiveConfig.MaxLoginAttempts, - g.EffectiveConfig.MinPasswordLength, - g.EffectiveConfig.MfaRequiredForAdmin, - g.EffectiveConfig.MfaAllowedMethods, - g.EffectiveConfig.AuthUseExternalIdp, - }, - scopes = g.Scopes, - generatedAt = g.GeneratedAt.ToString("O"), - validUntil = g.ValidUntil.ToString("O"), - }; + return json; } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/XmlAuthorizationGraphSerializer.cs b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/XmlAuthorizationGraphSerializer.cs index a8d6e4dc..33d7e5d7 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/XmlAuthorizationGraphSerializer.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/XmlAuthorizationGraphSerializer.cs @@ -11,9 +11,12 @@ public sealed class XmlAuthorizationGraphSerializer : IAuthorizationGraphSeriali public string ContentType => "application/xml"; public string FileExtension => "xml"; - public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options = null) + public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) { var opts = options ?? GraphSerializationOptions.Default; + var g = graph; + XAttribute? FormatId(Guid id) => + opts.IncludeTechnicalMetadata ? new XAttribute("id", id) : null; XElement? BuildBranch() { @@ -21,7 +24,7 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options return null; return new XElement("branch", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.Branch.Id) : null, + FormatId(g.Context.Branch.Id), new XAttribute("code", g.Context.Branch.Code), new XAttribute("value", g.Context.Branch.Name)); } @@ -32,7 +35,7 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options return null; return new XElement("provider", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Authentication.Provider.Id) : null, + FormatId(g.Authentication.Provider.Id), new XAttribute("code", g.Authentication.Provider.Code), new XAttribute("name", g.Authentication.Provider.Name), new XAttribute("value", g.Authentication.Provider.Strategy)); @@ -41,29 +44,30 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options XElement BuildContext() => new("context", new XElement("user", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.User.Id) : null, + FormatId(g.Context.User.Id), new XAttribute("email", g.Context.User.Email), new XAttribute("username", g.Context.User.Username), new XAttribute("value", g.Context.User.DisplayName), new XAttribute("status", g.Context.User.Status)), new XElement("tenant", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.Tenant.Id) : null, + FormatId(g.Context.Tenant.Id), new XAttribute("code", g.Context.Tenant.Code), new XAttribute("value", g.Context.Tenant.Name), new XAttribute("status", g.Context.Tenant.Status), new XAttribute("isManagementOwner", g.Context.Tenant.IsManagementOwner)), - new XElement("systemSuite", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.SystemSuite.Id) : null, + // G-043: en el grafo lobby SystemSuite/Role/Profile son null → se omiten los elementos. + g.Context.SystemSuite is null ? null : new XElement("systemSuite", + FormatId(g.Context.SystemSuite.Id), new XAttribute("code", g.Context.SystemSuite.Code), new XAttribute("value", g.Context.SystemSuite.Name), new XAttribute("status", g.Context.SystemSuite.Status)), - new XElement("role", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.Role.Id) : null, + g.Context.Role is null ? null : new XElement("role", + FormatId(g.Context.Role.Id), new XAttribute("code", g.Context.Role.Code), new XAttribute("value", g.Context.Role.Name), new XAttribute("level", g.Context.Role.HierarchyLevel)), - new XElement("profile", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.Profile.Id) : null, + g.Context.Profile is null ? null : new XElement("profile", + FormatId(g.Context.Profile.Id), new XAttribute("scope", g.Context.Profile.Scope), new XAttribute("isActive", g.Context.Profile.IsActive)), BuildBranch()); @@ -82,29 +86,30 @@ XElement BuildActions() new XAttribute("code", a.Code), new XAttribute("value", a.Name)))); + // Un solo elemento `node` recursivo: el árbol no tiene profundidad fija. + XElement BuildNode(Ums.Domain.Authorization.Graph.GraphNavigationNode n) + => new("node", + new XAttribute("code", n.Code), + new XAttribute("value", n.Name), + new XAttribute("kind", n.Kind), + n.Actions.Select(a => new XElement("action", + new XAttribute("actionCode", a.ActionCode), + new XAttribute("effect", a.Effect.ToString()), + new XAttribute("source", a.Source.ToString()))), + n.Children.Select(BuildNode)); + XElement BuildMenuAccess() => new("menuAccess", g.MenuAccess.Select(m => new XElement("module", new XAttribute("code", m.Code), new XAttribute("value", m.Name), new XAttribute("status", m.Status), - m.Menus.Select(menu => new XElement("menu", - new XAttribute("code", menu.Code), - new XAttribute("value", menu.Label), - menu.SubMenus.Select(sub => new XElement("subMenu", - new XAttribute("code", sub.Code), - new XAttribute("value", sub.Label), - sub.Options.Select(o => new XElement("option", - new XAttribute("code", o.Code), - new XAttribute("value", o.Label), - new XAttribute("actionCode", o.ActionCode), - new XAttribute("effect", o.Effect.ToString()), - new XAttribute("source", o.Source.ToString())))))))))); + m.Nodes.Select(BuildNode)))); XElement BuildDomainPermissions() => new("domainPermissions", g.DomainPermissions.Select(r => new XElement("resource", - opts.IncludeTechnicalMetadata ? new XAttribute("id", r.ResourceId) : null, + FormatId(r.ResourceId), opts.IncludeTechnicalMetadata && r.ModuleId.HasValue ? new XAttribute("moduleId", r.ModuleId.Value) : null, opts.IncludeTechnicalMetadata && r.ParentResourceId.HasValue ? new XAttribute("parentResourceId", r.ParentResourceId.Value) : null, new XAttribute("type", r.ResourceType), diff --git a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/YamlAuthorizationGraphSerializer.cs b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/YamlAuthorizationGraphSerializer.cs index 60ea4487..89fd086f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/YamlAuthorizationGraphSerializer.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/YamlAuthorizationGraphSerializer.cs @@ -10,69 +10,71 @@ public sealed class YamlAuthorizationGraphSerializer : IAuthorizationGraphSerial public string ContentType => "application/x-yaml"; public string FileExtension => "yaml"; - public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options = null) + public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) { var opts = options ?? GraphSerializationOptions.Default; var sb = new StringBuilder(); sb.AppendLine("# UMS Authorization Graph"); - sb.AppendLine($"# Generated: {g.GeneratedAt:O}"); - sb.AppendLine($"# Valid Until: {g.ValidUntil:O}"); + sb.AppendLine($"# Generated: {graph.GeneratedAt:O}"); + sb.AppendLine($"# Valid Until: {graph.ValidUntil:O}"); + sb.AppendLine(); + sb.AppendLine($"schemaVersion: {graph.SchemaVersion}"); sb.AppendLine(); sb.AppendLine("context:"); sb.AppendLine(" user:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.User.Id}"); - sb.AppendLine($" email: {g.Context.User.Email}"); - sb.AppendLine($" username: {g.Context.User.Username}"); - sb.AppendLine($" value: {g.Context.User.DisplayName}"); - sb.AppendLine($" status: {g.Context.User.Status}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.User.Id}"); + sb.AppendLine($" email: {graph.Context.User.Email}"); + sb.AppendLine($" username: {graph.Context.User.Username}"); + sb.AppendLine($" value: {graph.Context.User.DisplayName}"); + sb.AppendLine($" status: {graph.Context.User.Status}"); sb.AppendLine(" tenant:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.Tenant.Id}"); - sb.AppendLine($" code: {g.Context.Tenant.Code}"); - sb.AppendLine($" value: {g.Context.Tenant.Name}"); - sb.AppendLine($" status: {g.Context.Tenant.Status}"); - sb.AppendLine($" isManagementOwner: {g.Context.Tenant.IsManagementOwner.ToString().ToLower()}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.Tenant.Id}"); + sb.AppendLine($" code: {graph.Context.Tenant.Code}"); + sb.AppendLine($" value: {graph.Context.Tenant.Name}"); + sb.AppendLine($" status: {graph.Context.Tenant.Status}"); + sb.AppendLine($" isManagementOwner: {graph.Context.Tenant.IsManagementOwner.ToString().ToLower()}"); sb.AppendLine(" systemSuite:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.SystemSuite.Id}"); - sb.AppendLine($" code: {g.Context.SystemSuite.Code}"); - sb.AppendLine($" value: {g.Context.SystemSuite.Name}"); - sb.AppendLine($" status: {g.Context.SystemSuite.Status}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.SystemSuite.Id}"); + sb.AppendLine($" code: {graph.Context.SystemSuite.Code}"); + sb.AppendLine($" value: {graph.Context.SystemSuite.Name}"); + sb.AppendLine($" status: {graph.Context.SystemSuite.Status}"); sb.AppendLine(" role:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.Role.Id}"); - sb.AppendLine($" code: {g.Context.Role.Code}"); - sb.AppendLine($" value: {g.Context.Role.Name}"); - sb.AppendLine($" hierarchyLevel: {g.Context.Role.HierarchyLevel}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.Role.Id}"); + sb.AppendLine($" code: {graph.Context.Role.Code}"); + sb.AppendLine($" value: {graph.Context.Role.Name}"); + sb.AppendLine($" hierarchyLevel: {graph.Context.Role.HierarchyLevel}"); sb.AppendLine(" profile:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.Profile.Id}"); - sb.AppendLine($" scope: {g.Context.Profile.Scope}"); - sb.AppendLine($" isActive: {g.Context.Profile.IsActive.ToString().ToLower()}"); - if (g.Context.Branch is not null) + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.Profile.Id}"); + sb.AppendLine($" scope: {graph.Context.Profile.Scope}"); + sb.AppendLine($" isActive: {graph.Context.Profile.IsActive.ToString().ToLower()}"); + if (graph.Context.Branch is not null) { sb.AppendLine(" branch:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.Branch.Id}"); - sb.AppendLine($" code: {g.Context.Branch.Code}"); - sb.AppendLine($" value: {g.Context.Branch.Name}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.Branch.Id}"); + sb.AppendLine($" code: {graph.Context.Branch.Code}"); + sb.AppendLine($" value: {graph.Context.Branch.Name}"); } sb.AppendLine(); sb.AppendLine("authentication:"); - sb.AppendLine($" method: {g.Authentication.Method}"); - sb.AppendLine($" mfaRequired: {g.Authentication.MfaRequired.ToString().ToLower()}"); - sb.AppendLine($" issuedAt: {g.Authentication.IssuedAt:O}"); - sb.AppendLine($" sessionExpiresAt: {g.Authentication.SessionExpiresAt:O}"); - if (g.Authentication.Provider is not null) + sb.AppendLine($" method: {graph.Authentication.Method}"); + sb.AppendLine($" mfaRequired: {graph.Authentication.MfaRequired.ToString().ToLower()}"); + sb.AppendLine($" issuedAt: {graph.Authentication.IssuedAt:O}"); + sb.AppendLine($" sessionExpiresAt: {graph.Authentication.SessionExpiresAt:O}"); + if (graph.Authentication.Provider is not null) { sb.AppendLine(" provider:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Authentication.Provider.Id}"); - sb.AppendLine($" code: {g.Authentication.Provider.Code}"); - sb.AppendLine($" name: {g.Authentication.Provider.Name}"); - sb.AppendLine($" value: {g.Authentication.Provider.Strategy}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Authentication.Provider.Id}"); + sb.AppendLine($" code: {graph.Authentication.Provider.Code}"); + sb.AppendLine($" name: {graph.Authentication.Provider.Name}"); + sb.AppendLine($" value: {graph.Authentication.Provider.Strategy}"); } sb.AppendLine(); sb.AppendLine("actions:"); - foreach (var a in g.Actions) + foreach (var a in graph.Actions) { sb.AppendLine($" - code: {a.Code}"); sb.AppendLine($" value: {a.Name}"); @@ -80,37 +82,18 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options sb.AppendLine(); sb.AppendLine("menuAccess:"); - foreach (var module in g.MenuAccess) + foreach (var module in graph.MenuAccess) { sb.AppendLine($" - code: {module.Code}"); sb.AppendLine($" value: {module.Name}"); sb.AppendLine($" status: {module.Status}"); - sb.AppendLine(" menus:"); - foreach (var menu in module.Menus) - { - sb.AppendLine($" - code: {menu.Code}"); - sb.AppendLine($" value: {menu.Label}"); - sb.AppendLine(" subMenus:"); - foreach (var sub in menu.SubMenus) - { - sb.AppendLine($" - code: {sub.Code}"); - sb.AppendLine($" value: {sub.Label}"); - sb.AppendLine(" options:"); - foreach (var opt in sub.Options) - { - sb.AppendLine($" - code: {opt.Code}"); - sb.AppendLine($" value: {opt.Label}"); - sb.AppendLine($" actionCode: {opt.ActionCode}"); - sb.AppendLine($" effect: {opt.Effect}"); - sb.AppendLine($" source: {opt.Source}"); - } - } - } + sb.AppendLine(" nodes:"); + EscribirNodos(sb, module.Nodes, " "); } sb.AppendLine(); sb.AppendLine("domainPermissions:"); - foreach (var res in g.DomainPermissions) + foreach (var res in graph.DomainPermissions) { sb.AppendLine(" -"); if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {res.ResourceId}"); @@ -135,27 +118,65 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options sb.AppendLine(); sb.AppendLine("featureFlags:"); - foreach (var f in g.FeatureFlags) + foreach (var f in graph.FeatureFlags) { sb.AppendLine(" -"); - sb.AppendLine($" code: {f.FlagCode}"); + sb.AppendLine($" flagCode: {f.FlagCode}"); + sb.AppendLine($" systemSuiteId: {f.SystemSuiteId}"); sb.AppendLine($" isEnabled: {f.IsEnabled.ToString().ToLower()}"); if (!string.IsNullOrWhiteSpace(f.MatchedCriteriaType)) - sb.AppendLine($" matchedCriteria: {f.MatchedCriteriaType}"); + sb.AppendLine($" matchedCriteriaType: {f.MatchedCriteriaType}"); } sb.AppendLine(); sb.AppendLine("effectiveConfig:"); - sb.AppendLine($" sessionTimeoutMinutes: {g.EffectiveConfig.SessionTimeoutMinutes}"); - sb.AppendLine($" maxLoginAttempts: {g.EffectiveConfig.MaxLoginAttempts}"); - sb.AppendLine($" mfaRequiredForAdmin: {g.EffectiveConfig.MfaRequiredForAdmin.ToString().ToLower()}"); - sb.AppendLine($" mfaAllowedMethods: [{string.Join(", ", g.EffectiveConfig.MfaAllowedMethods)}]"); + sb.AppendLine($" sessionTimeoutMinutes: {graph.EffectiveConfig.SessionTimeoutMinutes}"); + sb.AppendLine($" maxLoginAttempts: {graph.EffectiveConfig.MaxLoginAttempts}"); + sb.AppendLine($" minPasswordLength: {graph.EffectiveConfig.MinPasswordLength}"); + sb.AppendLine($" mfaRequiredForAdmin: {graph.EffectiveConfig.MfaRequiredForAdmin.ToString().ToLower()}"); + sb.AppendLine($" accessTokenDurationMs: {graph.EffectiveConfig.AccessTokenDurationMs}"); + sb.AppendLine($" authUseExternalIdp: {graph.EffectiveConfig.AuthUseExternalIdp.ToString().ToLower()}"); sb.AppendLine(); sb.AppendLine("scopes:"); - foreach (var scope in g.Scopes) + foreach (var scope in graph.Scopes) sb.AppendLine($" - {scope}"); return sb.ToString(); } + + /// + /// Escribe el árbol de navegación con sangría creciente. Recursivo porque el árbol no tiene + /// profundidad fija (ADR-0090): la versión anterior tenía tres bucles anidados y perdía en + /// silencio cualquier nodo que no encajara en ellos. + /// + private static void EscribirNodos( + System.Text.StringBuilder sb, + IEnumerable nodos, + string sangria) + { + foreach (var nodo in nodos) + { + sb.AppendLine($"{sangria}- code: {nodo.Code}"); + sb.AppendLine($"{sangria} value: {nodo.Name}"); + sb.AppendLine($"{sangria} kind: {nodo.Kind}"); + + if (nodo.Actions.Count > 0) + { + sb.AppendLine($"{sangria} actions:"); + foreach (var accion in nodo.Actions) + { + sb.AppendLine($"{sangria} - actionCode: {accion.ActionCode}"); + sb.AppendLine($"{sangria} effect: {accion.Effect}"); + sb.AppendLine($"{sangria} source: {accion.Source}"); + } + } + + if (nodo.Children.Count > 0) + { + sb.AppendLine($"{sangria} children:"); + EscribirNodos(sb, nodo.Children, sangria + " "); + } + } + } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/CadenaDeRedis.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/CadenaDeRedis.cs new file mode 100644 index 00000000..d8de3d01 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/CadenaDeRedis.cs @@ -0,0 +1,43 @@ +namespace Ums.Infrastructure.Configuration; + +/// +/// La cadena de conexión a Redis, en la forma que entiende StackExchange.Redis. +/// +/// POR QUÉ EXISTE. El chart de Helm declara `REDIS_CONNECTION` como URI —`redis://ums-redis:6379`, +/// que es la convención de casi todo el mundo— y ese valor se pasaba TAL CUAL a +/// `ConnectionMultiplexer.Connect`, que no entiende el esquema: interpreta `redis` como host y +/// acaba componiendo `redis://ums-redis:6379:6379`, con el puerto duplicado. El síntoma es una +/// `RedisConnectionException` que acusa a Redis —vivo y sano— en vez de a la notación del valor. +/// +/// Medido el 2026-08-02 al desplegar una imagen actual en el clúster: el proceso NO arrancaba. El +/// defecto llevaba latente desde que se escribió el camino de código, porque el clúster corría una +/// imagen anterior y nunca se ejecutaba. +/// +/// VIVE AQUÍ, y no junto a uno de sus consumidores, porque son DOS: el registro de caché e +/// invalidación (`DependencyInjection`) y la protección de datos (`AuthenticationExtensions`). +/// Normalizar en uno solo es lo que ya pasó: el arranque siguió fallando desde el otro, con el +/// mismo mensaje y treinta minutos de diagnóstico por delante. +/// +public static class CadenaDeRedis +{ + /// + /// Quita el esquema de una URI `redis://` o `rediss://` y deja intacto lo demás — puerto y + /// cualquier cadena de opciones que venga detrás—, porque recortar de más cambiaría la + /// conexión en vez de solo su notación. Un valor ya en forma `host:puerto` se devuelve igual. + /// + public static string? Normalizar(string? valor) + { + if (string.IsNullOrWhiteSpace(valor)) return valor; + + var limpio = valor.Trim(); + foreach (var esquema in new[] { "redis://", "rediss://" }) + { + if (limpio.StartsWith(esquema, StringComparison.OrdinalIgnoreCase)) + { + return limpio[esquema.Length..]; + } + } + + return limpio; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationLoader.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationLoader.cs index 884c4e17..749dabdc 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationLoader.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationLoader.cs @@ -27,9 +27,8 @@ public static class ConfigurationLoaderExtensions { public static IServiceCollection AddConfigurationProvider(this IServiceCollection services) { - // TODO(TD-003): Swap InMemoryConfigurationCache for a Redis-backed IConfigurationCache - // when distributed cache infrastructure is available. - services.AddSingleton(); + // El caché se inyecta dinámicamente en DependencyInjection.cs + // (RedisConfigurationCache si hay cadena de conexión, sino InMemoryConfigurationCache). services.AddSingleton(); services.AddHostedService(); return services; diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationProvider.cs index 5dbbc42f..fe52612f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationProvider.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationProvider.cs @@ -50,6 +50,13 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) .Where(c => c.Scope.Id == 1 && c.Props.Status.Id == 2) .Select(DecryptIfNeeded)); + // Suite (scope 4) and Module (scope 5) configs are cross-tenant by nature and + // typically carry no TenantId. The per-tenant load below only reaches configs that + // belong to a tenant, so these tenant-less scoped configs would otherwise never be + // bucketed and would silently drop out of precedence resolution (G-048). Bucket them + // first so any tenant-specific override can still win afterwards. + BucketTenantlessScopedConfigs(allConfigs.Where(c => c.Props.TenantId is null)); + // Populate per-tenant entries — scope 2 = Tenant, 4 = Suite, 5 = Module (ConfigurationScope). var tenantIds = allConfigs .Where(c => c.Props.TenantId is not null) @@ -189,7 +196,18 @@ public void Set(string code, string value, Guid? tenantId = null) ConfigurationChanged?.Invoke(this, new ConfigurationChangedEventArgs(code, tenantId, oldValue, value)); } - public void Dispose() => _cache.InvalidateAll(); + /// + /// No invalida nada. La caché vive y muere con el proceso: vaciar sus diccionarios al + /// apagar no libera nada que el recolector no vaya a liberar igual. + /// + /// Antes llamaba a InvalidateAll(), y con la caché distribuida activa eso publicaba + /// un aviso de invalidación total: CADA apagado de pod en un despliegue progresivo forzaba + /// una recarga completa de configuración en todos los pods restantes, justo cuando el resto + /// del clúster absorbe el tráfico del que se va (G-170). + /// + public void Dispose() + { + } // ── Private helpers ─────────────────────────────────────────────────────── @@ -217,6 +235,28 @@ private void BucketTenantConfigs(Guid tenantId, IReadOnlyList + /// Buckets Suite (scope 4) and Module (scope 5) configs that carry no TenantId. + /// These cross-tenant overrides are otherwise never populated by the per-tenant load, + /// which would silently drop them from precedence resolution (G-048). Only Published + /// entries participate (BR-1); encrypted values are decrypted to plaintext. + /// + private void BucketTenantlessScopedConfigs(IEnumerable configs) + { + var published = configs + .Where(c => c.Props.Status.Id == 2) + .Select(DecryptIfNeeded) + .ToList(); + + foreach (var grp in published.Where(c => c.Scope.Id == 4 && c.Props.SystemSuiteId is not null) + .GroupBy(c => c.Props.SystemSuiteId!.GetValue())) + _cache.PopulateSuite(grp.Key, grp); + + foreach (var grp in published.Where(c => c.Scope.Id == 5 && c.Props.ModuleId is not null) + .GroupBy(c => c.Props.ModuleId!.GetValue())) + _cache.PopulateModule(grp.Key, grp); + } + /// /// Returns a copy of the aggregate with the value decrypted when IsEncrypted=true. /// Used during cache population so the runtime resolver always works with plaintext. diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/FeatureFlagEvaluator.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/FeatureFlagEvaluator.cs index 94ef7594..c22fdff6 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/FeatureFlagEvaluator.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/FeatureFlagEvaluator.cs @@ -6,9 +6,35 @@ namespace Ums.Infrastructure.Configuration; public sealed class FeatureFlagEvaluator : IFeatureFlagEvaluator { public FlagEvaluationResult Evaluate(FeatureFlag flag, EvaluationContext context) + { + // Fail-closed lifecycle (G-048): only an Active flag can resolve as enabled. + // A non-Active flag (Inactive/Archived) always evaluates as disabled. + if (flag.Status != FlagStatus.Active) + return new FlagEvaluationResult(false, null, $"Flag is not active (status: {flag.Status.Name})"); + + // All targeting criteria must match before any rollout is considered. + var criteriaFailure = EvaluateCriteria(flag, context); + if (criteriaFailure is not null) + return criteriaFailure; + + // Percentage rollout (G-048): FlagType.Percentage honours RolloutPercentage + // through deterministic per-subject bucketing. + if (flag.FlagType == FlagType.Percentage) + return EvaluatePercentage(flag, context); + + return flag.Criteria.Any() + ? new FlagEvaluationResult(true, null, "All criteria matched") + : new FlagEvaluationResult(true, null, "No restrictions — active for all"); + } + + /// + /// Evaluates targeting criteria. Returns a disabled result when a criteria group + /// fails to match; returns null when there are no criteria or all groups pass. + /// + private static FlagEvaluationResult? EvaluateCriteria(FeatureFlag flag, EvaluationContext context) { if (!flag.Criteria.Any()) - return new FlagEvaluationResult(true, null, "No restrictions — active for all"); + return null; var groups = flag.Criteria.GroupBy(c => c.CriteriaType); @@ -33,7 +59,55 @@ public FlagEvaluationResult Evaluate(FeatureFlag flag, EvaluationContext context return new FlagEvaluationResult(false, group.Key, $"No match for CriteriaType {group.Key}"); } - return new FlagEvaluationResult(true, null, "All criteria matched"); + return null; + } + + /// + /// Applies the percentage rollout for flags. + /// Uses a stable per-subject bucket so a given subject is consistently in or out. + /// Fail-closed: when no identifying context is available the flag is disabled. + /// + private static FlagEvaluationResult EvaluatePercentage(FeatureFlag flag, EvaluationContext context) + { + var percentage = flag.RolloutPercentage ?? 0; + + if (percentage <= 0) + return new FlagEvaluationResult(false, null, "Rollout percentage is 0"); + if (percentage >= 100) + return new FlagEvaluationResult(true, null, "Rollout percentage is 100"); + + var subject = context.ProfileId?.ToString() + ?? context.TenantId?.ToString() + ?? context.BranchId?.ToString() + ?? context.RoleCode + ?? context.Environment; + + if (string.IsNullOrEmpty(subject)) + return new FlagEvaluationResult(false, null, "Percentage rollout requires identifying context (fail-closed)"); + + var bucket = ComputeBucket($"{flag.FlagCode}:{subject}"); + return bucket < percentage + ? new FlagEvaluationResult(true, null, $"In rollout: bucket {bucket} < {percentage}%") + : new FlagEvaluationResult(false, null, $"Out of rollout: bucket {bucket} >= {percentage}%"); + } + + /// + /// Deterministic 0..99 bucket via FNV-1a — independent of the process hash seed, + /// so the same subject always lands in the same bucket across runs and nodes. + /// + private static int ComputeBucket(string key) + { + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + + var hash = offsetBasis; + foreach (var b in System.Text.Encoding.UTF8.GetBytes(key)) + { + hash ^= b; + hash *= prime; + } + + return (int)(hash % 100); } private static string? GetContextValue(EvaluationContext context, string criteriaType) => @@ -71,7 +145,7 @@ private static bool EvaluateDateRange(string contextValue, string criteriaValue) { var range = JsonSerializer.Deserialize(criteriaValue); if (range is null) return false; - var current = DateTime.Parse(contextValue, null, System.Globalization.DateTimeStyles.RoundtripKind); + var current = DateTime.Parse(contextValue, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind); return current >= range.From && current <= range.To; } catch { return false; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/IdpResolution/IdpConfigurationResolver.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/IdpResolution/IdpConfigurationResolver.cs index 7c799e32..b50c17f8 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/IdpResolution/IdpConfigurationResolver.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/IdpResolution/IdpConfigurationResolver.cs @@ -27,27 +27,16 @@ public async Task> ResolveAsync( { var configurations = await _repository.GetByTenantIdAsync(tenantId, cancellationToken); - var candidates = configurations - .Where(configuration => configuration.Status == IdpConfigStatus.Active) - .Where(configuration => !systemSuiteId.HasValue || configuration.SystemSuiteId.GetValue() == systemSuiteId.Value) - .Where(configuration => string.IsNullOrWhiteSpace(providerType) || string.Equals(configuration.ProviderType.Name, providerType, StringComparison.OrdinalIgnoreCase)) - .ToList(); + // La regla de selección de FR-042 es única y vive en el dominio; el motor de consulta OIDC + // y la resolución del login la comparten para aplicar exactamente el mismo criterio. + var selection = IdpConfigurationSelector.Select(configurations, systemSuiteId, emailDomain, providerType); - if (candidates.Count == 0) + if (selection is null) { return Result.Failure("Active IdP configuration not found."); } - var normalizedDomain = NormalizeDomain(emailDomain); - var domainMatchedCandidates = string.IsNullOrWhiteSpace(normalizedDomain) - ? [] - : candidates.Where(configuration => MatchesDomain(configuration, normalizedDomain)).ToList(); - - var domainMatched = domainMatchedCandidates.Count > 0; - var selected = (domainMatched ? domainMatchedCandidates : candidates) - .OrderBy(configuration => configuration.ResolutionPriority) - .ThenByDescending(configuration => configuration.Version) - .First(); + var selected = selection.Value.Configuration; var strategy = _factory.Create( new IdpResolutionStrategyCriteria(selected.ProviderType.Name)) @@ -58,21 +47,6 @@ public async Task> ResolveAsync( return Result.Failure($"No IdP resolution strategy is registered for provider type '{selected.ProviderType.Name}'."); } - return Result.Success(strategy.Resolve(new IdpResolutionContext(selected, domainMatched))); - } - - private static bool MatchesDomain(IdpConfiguration configuration, string normalizedDomain) - => configuration.Props.DomainHints.Any(hint => string.Equals(hint.Trim(), normalizedDomain, StringComparison.OrdinalIgnoreCase)); - - private static string? NormalizeDomain(string? emailDomain) - { - if (string.IsNullOrWhiteSpace(emailDomain)) - { - return null; - } - - var trimmed = emailDomain.Trim(); - var atIndex = trimmed.IndexOf('@'); - return atIndex >= 0 ? trimmed[(atIndex + 1)..].Trim().ToLowerInvariant() : trimmed.ToLowerInvariant(); + return Result.Success(strategy.Resolve(new IdpResolutionContext(selected, selection.Value.DomainMatched))); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/InMemoryConfigurationCache.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/InMemoryConfigurationCache.cs index 0f0ed6ca..6ab4f82a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/InMemoryConfigurationCache.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/InMemoryConfigurationCache.cs @@ -7,8 +7,9 @@ namespace Ums.Infrastructure.Configuration; /// /// In-process, thread-safe implementation of . /// -/// TODO(TD-003): Replace this phase-1 in-memory cache with a Redis-backed implementation +/// TODO(G-069): Replace this phase-1 in-memory cache with a Redis-backed implementation /// that wraps IDistributedCache when distributed cache infrastructure is available. +/// No es coherente entre réplicas al escalar horizontalmente. Ver GAPS.md G-069. /// /// Resolution order (BR-1): Module → Suite → Tenant → Global (most specific wins). /// Each scope has its own dictionary keyed by the scope's natural ID: diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/ParameterResolutionService.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/ParameterResolutionService.cs index 8d5cb15d..85d94711 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/ParameterResolutionService.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/ParameterResolutionService.cs @@ -27,14 +27,21 @@ public interface IParameterResolutionService public sealed class ParameterResolutionService : IParameterResolutionService { private readonly UmsPlatformDbContext _dbContext; - private readonly ITenantContext _tenantContext; - public ParameterResolutionService(UmsPlatformDbContext dbContext, ITenantContext tenantContext) + public ParameterResolutionService(UmsPlatformDbContext dbContext) { _dbContext = dbContext; - _tenantContext = tenantContext; } + /// + /// Estado terminal de borrado lógico. Todas las lecturas de esta clase lo excluyen, y no es un + /// detalle cosmético: desde que el índice único pasó a ser PARCIAL, una definición puede tener + /// una fila de valor viva y N lápidas. Indexar por `ParameterDefinitionId` sin este filtro haría + /// estallar `ToDictionaryAsync` con clave duplicada, y `FirstOrDefault` resolvería un valor + /// retirado hace meses. El filtro es lo que garantiza que la resolución devuelve LA VIVA. + /// + private static readonly int DeletedStatusId = ConfigStatus.Deleted.Id; + public async Task> GetGlobalParametersAsync(CancellationToken cancellationToken = default) { var definitions = await _dbContext.ParameterDefinitions @@ -45,6 +52,7 @@ public async Task> GetGlobalParametersAsync(Can var globalValues = await _dbContext.ParameterGlobalValues .IgnoreQueryFilters() + .Where(v => v.StatusId != DeletedStatusId) .ToDictionaryAsync(v => v.ParameterDefinitionId, cancellationToken); var results = new List(); @@ -80,22 +88,25 @@ public async Task> GetTenantParametersAsync(Gui var globalValues = await _dbContext.ParameterGlobalValues .IgnoreQueryFilters() + .Where(v => v.StatusId != DeletedStatusId) .ToDictionaryAsync(v => v.ParameterDefinitionId, cancellationToken); var tenantValues = await _dbContext.ParameterTenantValues .IgnoreQueryFilters() - .Where(v => v.TenantId == tenantId) + .Where(v => v.TenantId == tenantId && v.StatusId != DeletedStatusId) .ToDictionaryAsync(v => v.ParameterDefinitionId, cancellationToken); var results = new List(); foreach (var def in definitions) { var hasTenantOverride = tenantValues.TryGetValue(def.Id, out var tenantValue); - var effectiveValue = hasTenantOverride - ? tenantValue.OverrideValue - : def.ScopeId == 3 && globalValues.TryGetValue(def.Id, out var globalValue) - ? globalValue.EffectiveValue - : def.DefaultValue; + string effectiveValue; + if (hasTenantOverride) + effectiveValue = tenantValue.OverrideValue; + else if (def.ScopeId == 3 && globalValues.TryGetValue(def.Id, out var globalValue)) + effectiveValue = globalValue.EffectiveValue; + else + effectiveValue = def.DefaultValue; string status; if (hasTenantOverride) @@ -133,7 +144,11 @@ public async Task GetEffectiveValueAsync(Guid? tenantId, string code, Ca { var tenantValue = await _dbContext.ParameterTenantValues .IgnoreQueryFilters() - .FirstOrDefaultAsync(v => v.TenantId == tenantId.Value && v.ParameterDefinitionId == definition.Id, cancellationToken); + .Where(v => v.TenantId == tenantId.Value + && v.ParameterDefinitionId == definition.Id + && v.StatusId != DeletedStatusId) + .OrderByDescending(v => v.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); if (tenantValue is not null) return tenantValue.OverrideValue; @@ -143,7 +158,9 @@ public async Task GetEffectiveValueAsync(Guid? tenantId, string code, Ca { var globalValue = await _dbContext.ParameterGlobalValues .IgnoreQueryFilters() - .FirstOrDefaultAsync(v => v.ParameterDefinitionId == definition.Id, cancellationToken); + .Where(v => v.ParameterDefinitionId == definition.Id && v.StatusId != DeletedStatusId) + .OrderByDescending(v => v.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); if (globalValue is not null) return globalValue.EffectiveValue; @@ -151,4 +168,4 @@ public async Task GetEffectiveValueAsync(Guid? tenantId, string code, Ca return definition.DefaultValue; } -} \ No newline at end of file +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/RedisConfigurationCache.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/RedisConfigurationCache.cs new file mode 100644 index 00000000..f70638ac --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/RedisConfigurationCache.cs @@ -0,0 +1,291 @@ +namespace Ums.Infrastructure.Configuration; + +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using Ums.Application.Configuration.Services; +using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; + +/// +/// Hybrid implementation of backed by Redis Pub/Sub (G-069). +/// +/// Provides lightning-fast synchronous reads using local memory dictionaries (0 network latency), +/// while ensuring cross-pod consistency by subscribing to Redis Pub/Sub invalidation channels. +/// When a configuration is populated or invalidated, an event is broadcasted so other pods +/// drop their local cache and reload from the database. +/// +public sealed class RedisConfigurationCache : IConfigurationCache, IDisposable +{ + private readonly ConcurrentDictionary _global + = new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary> _tenant = new(); + private readonly ConcurrentDictionary> _suite = new(); + private readonly ConcurrentDictionary> _module = new(); + + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + private ISubscriber? _subscriber; + + private const string ChannelPrefix = "ums:config:invalidate"; + + /// + /// Identidad de ESTA instancia. Viaja en cada mensaje publicado para que un pod + /// descarte sus propios avisos: la suscripción es por patrón, así que sin esto cada + /// pod se escucha a sí mismo (G-170). + /// + private static readonly string OriginId = Guid.NewGuid().ToString("N"); + + /// + /// Marca «estoy aplicando una invalidación que llegó de otro pod». Mientras está activa, + /// no publica. + /// + /// Es la pieza que rompe el ciclo: el manejador de la suscripción llama a + /// ReloadAsync/ReloadTenantAsync, que a su vez invocan + /// /. Sin esta marca, aplicar un + /// aviso genera otro aviso, y con N réplicas el tráfico se autoamplifica hasta la tormenta + /// (G-170). y no un campo: el manejador es asíncrono y la marca + /// debe seguir al flujo lógico, no al hilo. + /// + private static readonly AsyncLocal AplicandoRemoto = new(); + + public RedisConfigurationCache( + IConnectionMultiplexer redis, + ILogger logger, + IServiceScopeFactory scopeFactory) + { + _redis = redis; + _logger = logger; + _scopeFactory = scopeFactory; + + SubscribeToInvalidations(); + } + + private void SubscribeToInvalidations() + { + _subscriber = _redis.GetSubscriber(); + _subscriber.Subscribe(new RedisChannel($"{ChannelPrefix}:*", RedisChannel.PatternMode.Pattern), async (channel, message) => + { + try + { + var channelName = (string)channel!; + var (origen, payload) = DesempaquetarMensaje((string)message!); + + // Un pod no se recarga por su propio aviso. + if (origen == OriginId) + { + return; + } + + using var scope = _scopeFactory.CreateScope(); + var provider = scope.ServiceProvider.GetRequiredService(); + + AplicandoRemoto.Value = true; + try + { + if (channelName.EndsWith(":all")) + { + _logger.LogInformation("Received Redis invalidation for ALL configurations. Reloading..."); + await provider.ReloadAsync(); + } + else if (channelName.EndsWith(":tenant") && Guid.TryParse(payload, out var tenantId)) + { + _logger.LogInformation("Received Redis invalidation for Tenant {TenantId}. Reloading...", tenantId); + await provider.ReloadTenantAsync(tenantId); + } + } + finally + { + AplicandoRemoto.Value = false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing Redis configuration invalidation message."); + } + }); + } + + private void PublishInvalidation(string suffix, string payload = "") + { + // Aplicar un aviso ajeno NO genera un aviso nuevo (G-170). + if (AplicandoRemoto.Value) + { + return; + } + + try + { + // Fire and forget to avoid blocking the caller + _ = _subscriber?.PublishAsync( + new RedisChannel($"{ChannelPrefix}:{suffix}", RedisChannel.PatternMode.Literal), + $"{OriginId}|{payload}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish Redis invalidation for {Suffix}", suffix); + } + } + + /// + /// Separa `origen|carga`. Tolera el formato antiguo sin origen para que una actualización + /// progresiva —pods viejos y nuevos conviviendo— no pierda invalidaciones. + /// + private static (string Origen, string Payload) DesempaquetarMensaje(string mensaje) + { + var corte = mensaje.IndexOf('|'); + return corte < 0 + ? (string.Empty, mensaje) + : (mensaje[..corte], mensaje[(corte + 1)..]); + } + + // ── Read (Local Memory) ────────────────────────────────────────────────── + + public AppConfigurationAggregate? GetGlobal(string code) + { + _global.TryGetValue(code, out var config); + return config; + } + + public AppConfigurationAggregate? GetForTenant(Guid tenantId, string code) + => TryGet(_tenant, tenantId, code); + + public AppConfigurationAggregate? GetForSuite(Guid suiteId, string code) + => TryGet(_suite, suiteId, code); + + public AppConfigurationAggregate? GetForModule(Guid moduleId, string code) + => TryGet(_module, moduleId, code); + + public AppConfigurationAggregate? GetWithPrecedence( + string code, + Guid? tenantId, + Guid? suiteId = null, + Guid? moduleId = null) + { + if (moduleId.HasValue) + { + var moduleValue = TryGet(_module, moduleId.Value, code); + if (moduleValue is not null) return moduleValue; + } + + if (suiteId.HasValue) + { + var suiteValue = TryGet(_suite, suiteId.Value, code); + if (suiteValue is not null) return suiteValue; + } + + if (tenantId.HasValue) + { + var tenantValue = TryGet(_tenant, tenantId.Value, code); + if (tenantValue is not null) return tenantValue; + } + + _global.TryGetValue(code, out var global); + return global; + } + + public IReadOnlyList GetAllGlobal() + => _global.Values.ToList(); + + public IReadOnlyList GetAllForTenant(Guid tenantId) + => _tenant.TryGetValue(tenantId, out var cache) ? cache.Values.ToList() : []; + + public bool HasTenantOverride(string code, Guid tenantId) + => TryGet(_tenant, tenantId, code) is not null; + + // ── Write (Local Memory + PubSub) ──────────────────────────────────────── + + public void PopulateGlobal(IEnumerable configs) + { + foreach (var config in configs) + _global[config.Code.GetValue()] = config; + } + + public void PopulateTenant(Guid tenantId, IEnumerable configs) + => PopulateScope(_tenant, tenantId, configs); + + public void PopulateSuite(Guid suiteId, IEnumerable configs) + => PopulateScope(_suite, suiteId, configs); + + public void PopulateModule(Guid moduleId, IEnumerable configs) + => PopulateScope(_module, moduleId, configs); + + /// + /// Invalida la configuración de un inquilino y AVISA SIEMPRE a los demás pods. + /// + /// El aviso estaba condicionado a que este pod tuviera algo que borrar + /// (if (_tenant.TryRemove(...))), y esa condición es justo la que no se cumple en el + /// caso que importa: un inquilino dado de alta DESPUÉS del arranque no está en la memoria de + /// este pod, así que no había nada que quitar y el aviso nunca salía. Los demás pods no se + /// enteraban de su configuración nunca —hasta reiniciar—, no con un desfase. + /// + /// Medido en vivo el 2026-08-04 con dos réplicas: al publicar + /// AUTH_REFRESH_TOKEN_ENABLED para un inquilino recién creado, el pod que lo publicó + /// emitía refresh token y el otro no; con un inquilino que ya existía al arrancar, ambos + /// coincidían al instante. La diferencia era esta línea. + /// + /// El aviso es un hecho del mundo —«la configuración de este inquilino cambió»—, no una + /// nota sobre el estado local de quien lo emite. Condicionarlo a la memoria propia era mezclar + /// las dos cosas. El coste de publicar de más es un mensaje por escritura de configuración; el + /// de publicar de menos era servir permisos obsoletos en la mitad del clúster. + /// + /// No hay riesgo de tormenta: ya calla mientras se + /// aplica un aviso ajeno (G-170), así que un pod que recarga por un mensaje remoto no genera + /// otro. + /// + public void InvalidateTenant(Guid tenantId) + { + _tenant.TryRemove(tenantId, out _); + PublishInvalidation("tenant", tenantId.ToString()); + } + + // Suite y módulo no publican a propósito: sus únicos llamadores en producción están DENTRO + // de ConfigurationProvider.ReloadTenantAsync, que ya viaja como invalidación de inquilino y + // repuebla ambos ámbitos en el pod remoto. Publicar por separado exigiría un manejador de + // recarga por suite —que hoy no existe— y dejaría el bucket vacío resolviendo por herencia + // al valor del inquilino: un valor incorrecto, no una ausencia. Si algún día se invalida una + // suite fuera de esa ruta, hay que añadir el canal Y su recarga, no solo el canal. + public void InvalidateSuite(Guid suiteId) => _suite.TryRemove(suiteId, out _); + + public void InvalidateModule(Guid moduleId) => _module.TryRemove(moduleId, out _); + + public void InvalidateAll() + { + _global.Clear(); + _tenant.Clear(); + _suite.Clear(); + _module.Clear(); + PublishInvalidation("all"); + } + + public void Dispose() + { + if (_subscriber != null) + { + _subscriber.UnsubscribeAll(); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static AppConfigurationAggregate? TryGet( + ConcurrentDictionary> store, + Guid key, + string code) + => store.TryGetValue(key, out var inner) && inner.TryGetValue(code, out var config) + ? config + : null; + + private static void PopulateScope( + ConcurrentDictionary> store, + Guid key, + IEnumerable configs) + { + var bucket = store.GetOrAdd(key, _ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)); + foreach (var config in configs) + bucket[config.Code.GetValue()] = config; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs b/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs index c95b5471..13f4382e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs +++ b/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs @@ -1,5 +1,7 @@ namespace Ums.Infrastructure; +#pragma warning disable S125 + using MediatR; using MassTransit; using System.Reflection; @@ -16,6 +18,8 @@ namespace Ums.Infrastructure; using Ums.Domain.Configuration; using Ums.Domain.Identity; using Ums.Domain.Identity.Repositories.TenantParameter; +using Ums.Domain.IGA; +using Ums.Infrastructure.Persistence.Iga; using Ums.Infrastructure.Persistence.Audit; using Ums.Infrastructure.Persistence.Approvals; using Ums.Infrastructure.Persistence.Authorization; @@ -57,7 +61,7 @@ public static IServiceCollection AddInfrastructure( .Validate( options => options.Provider == PersistenceProvider.InMemory || !string.IsNullOrWhiteSpace(configuration.GetConnectionString("DefaultConnection")), - "ConnectionStrings:DefaultConnection is required when Persistence.Provider is Sqlite or PostgreSql.") + "ConnectionStrings:DefaultConnection is required when Persistence.Provider is PostgreSql.") .ValidateOnStart(); services.AddHttpContextAccessor(); @@ -65,28 +69,51 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); services.AddSingleton(); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); - services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + // Trazabilidad funcional (ADR-0096; ADR-UMS-084/085): localizador legible + transacción + // funcional con narrativa y desenlace. Concreta e interfaz comparten la misma instancia + // con alcance de petición para que middleware y handlers narren la misma transacción. + services.AddScoped(); + services.AddScoped(sp => + sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(sp => + sp.GetRequiredService()); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddSingleton(Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false, - AllowSynchronousContinuations = false, - })); - services.AddSingleton(); + // G-040: la auditoría automática se encola por el Transactional Outbox de MassTransit + // (entrega POST-commit al AuditTrailPersistenceConsumer), no por un canal en memoria con + // descarte silencioso. Scoped porque publica con el IPublishEndpoint de la petición. + services.AddScoped(); + // G-066 / ADR-0098 D7: única vía de salida al bróker inter-sistema (eventos de integración + // explícitos). Los eventos de dominio se despachan en proceso (MediatR, post-commit). + services.AddScoped(); var isNotProduction = environment is null || !environment.IsProduction(); services.AddUmsFactories(isNotProduction); services.AddScoped(); services.AddScoped(); + // Política de refresh token resuelta desde la config jerárquica, fail-closed (ADR-UMS-091 / FR-015). + services.AddScoped(); + // Almacén de refresh tokens (solo hash) sobre UmsPlatformDbContext (ADR-UMS-091 / FR-015). + services.AddScoped(); + // Almacén de tokens de restablecimiento de contraseña (solo hash) — G-188. + services.AddScoped(); + // Nivelador del tiempo de respuesta de los flujos anónimos indistinguibles (G-188). + // Singleton: no tiene estado y su presupuesto es una constante del proceso. + services.AddSingleton(); // Auth Graph Engine services services.AddScoped(); services.AddScoped(); + // FR-042 (ADR-UMS-097 §2.3/§2.4, slice 2b): orquestador del fallback encadenado por indisponibilidad. + services.AddScoped(); services.AddScoped(); + + // --- Adaptador OIDC real (Authorization Code + PKCE) — ADR-UMS-094 / G-049 (slice 1) --- + // Puertos HTTP inyectables (token endpoint + JWKS): en producción hablan con el IdP; + // en unit tests se sustituyen por fakes con llaves/respuestas controladas. + services.AddHttpClient(); + services.AddHttpClient(); + // Endpoints OIDC leídos de la configuración del inquilino (IdpConfiguration), no hardcodeados. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // El adaptador lo instancia Shell.Factory (por estrategia); registrado también aquí + // para que sus dependencias resuelvan vía el proveedor de servicios. + services.AddScoped(); // Default serializer (JSON) — injected directly into CommandHandler services.AddTransient(); // OPS-01 / HARDENING-03: Token revocation store. - // When Redis:Connection is configured → use RedisTokenRevocationStore (all pods share state). - // Fallback → InMemoryTokenRevocationStore (fine for single-node / dev / tests). - var redisConnection = configuration["Redis:Connection"]; + // G-069: Redis-backed configuration cache (Pub/Sub). + // Con Redis configurado → estado e invalidaciones compartidos entre pods. + // Sin él → InMemory, correcto solo en nodo único / dev / pruebas. + // + // G-169: se acepta también `REDIS_CONNECTION` porque es la variable que inyecta el chart + // de Helm. ASP.NET solo mapea variables de entorno a claves jerárquicas con doble guion + // bajo (`Redis__Connection`), así que leer únicamente `Redis:Connection` hacía que en + // Kubernetes NUNCA se activara Redis: el despliegue creía tener caché distribuida y + // corría con las implementaciones en memoria, sin un solo aviso. + // Normalizado: el chart entrega una URI y `ConnectionMultiplexer` quiere `host:puerto`. + // Ver `CadenaDeRedis` — el puerto duplicado que tumbaba el arranque salía de aquí. + var redisConnection = Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar( + configuration["Redis:Connection"] ?? configuration["REDIS_CONNECTION"]); if (!string.IsNullOrWhiteSpace(redisConnection)) { services.AddStackExchangeRedisCache(options => @@ -116,18 +172,37 @@ public static IServiceCollection AddInfrastructure( options.Configuration = redisConnection; options.InstanceName = "ums:"; // Namespace prefix to avoid key collisions in shared Redis. }); + + // Required for Pub/Sub in RedisConfigurationCache + services.AddSingleton(sp => + StackExchange.Redis.ConnectionMultiplexer.Connect(redisConnection)); + services.AddSingleton(); + services.AddSingleton(); + // G-248: el cupo de peticiones se cuenta donde lo ven todas las réplicas. + services.AddSingleton(); } else { + // Respaldo en proceso: `IDistributedCache` debe resolver siempre porque la + // idempotencia depende de él (G-169). Sin Redis solo deduplica dentro del pod. + services.AddDistributedMemoryCache(); services.AddSingleton(); + services.AddSingleton(); + // Sin Redis el cupo vuelve a ser por proceso: correcto solo con UNA réplica, y el + // arranque ya lo declara en el log (G-248). + services.AddSingleton(); } + // G-247: las sesiones cerradas van sobre `IDistributedCache`, que aquí arriba ya quedó + // resuelto a Redis o a memoria del proceso. Una sola implementación: lo que cambia entre + // los dos modos es el alcance, no el código. + services.AddSingleton(); + // HARDENING-03: Register Infrastructure MediatR notification handlers (UserDeleted, UserBlocked → revoke tokens). // MediatR.AddApplication() only scans Ums.Application; Infrastructure handlers must be registered here. services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); services.AddHostedService(); - services.AddHostedService(); var persistence = configuration.GetSection(PersistenceOptions.SectionName).Get() ?? new(); @@ -175,8 +250,10 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => "the MMS tenant projection must not fall back to the platform DB or localhost."); services.AddDbContext(options => +#pragma warning disable S2068 // Cadena de conexión de FALLBACK local de desarrollo; la real llega por configuración (masterDataDb). No es un secreto de producción. options.UseNpgsql( masterDataDb ?? "Host=localhost;Port=5432;Database=ums;Username=postgres;Password=postgres")); +#pragma warning restore S2068 // GAP-001 / DS-08: migrate the projection store at startup. The platform context is // migrated in InitializeUmsPlatformAsync, but the isolated projection context had no @@ -200,6 +277,15 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => // Cross-service broker (kind/prod): receive MMS tenant events over RabbitMQ, // with the EF inbox for exactly-once-effective consumption (ADR-0033/ADR-0063). x.AddEntityFrameworkOutbox(o => o.UsePostgres()); + + // G-066 / ADR-0098 D4 / KB-TXN-001: transactional bus-outbox on the WRITE context so + // domain events are staged with the aggregate change set and delivered AFTER commit + // (no «phantom messages»). UseBusOutbox() routes IPublishEndpoint through the outbox. + x.AddEntityFrameworkOutbox(o => + { + o.UsePostgres(); + o.UseBusOutbox(); + }); x.UsingRabbitMq((context, cfg) => { cfg.Host(rabbitMqConnection); @@ -219,41 +305,19 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => }); // REC-04: Cross-aggregate transaction scope - if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) services.AddScoped(); else services.AddSingleton(); - if (persistence.Provider == PersistenceProvider.Sqlite) - { - var connectionString = configuration.GetConnectionString("DefaultConnection") - ?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection must be configured for SQLite persistence."); - - if (connectionString.Contains("Server=", StringComparison.OrdinalIgnoreCase)) - { - connectionString = "Data Source=umsdev.db"; - } - - services.AddScoped(); - services.AddScoped(); // FIX-08: auto-stamp audit columns - - services.AddDbContext((serviceProvider, options) => - { - options.UseSqlite(connectionString); - - options.AddInterceptors( - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService()); - }); - } - - else if (persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { var connectionString = configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection must be configured for PostgreSQL persistence."); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // G-081: rechaza UPDATE/DELETE de la traza de auditoría (no repudio) services.AddScoped(); // rotate bytea RowVersion on UPDATE (no rowversion type in PostgreSQL) services.AddResiliencePipeline("ums-postgres", pipelineBuilder => @@ -285,6 +349,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => options.AddInterceptors( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService()); options.ConfigureWarnings(warnings => @@ -296,8 +361,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => options.UseNpgsql(connectionString, pgOptions => pgOptions.EnableRetryOnFailure(3))); } - if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteIdentityStores) || - (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlIdentityStores)) + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlIdentityStores) { services.AddScoped(); services.AddScoped(); @@ -323,8 +387,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteAuthorizationStores) || - (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlAuthorizationStores)) + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlAuthorizationStores) { services.AddScoped(); services.AddScoped(); @@ -350,8 +413,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteConfigurationStores) || - (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlConfigurationStores)) + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlConfigurationStores) { services.AddScoped(); services.AddScoped(); @@ -377,7 +439,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { services.AddScoped(); } @@ -387,8 +449,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteApprovalsStores) || - (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlApprovalsStores)) + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlApprovalsStores) { services.AddScoped(); services.AddScoped(); @@ -418,6 +479,22 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } + // IGA (ADR-UMS-093): repositorios del contexto de gobierno de identidad. Bajo PostgreSQL usan + // UmsPlatformDbContext (scoped); en dev/tests la variante en memoria (singleton). + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlIgaStores) + { + services.AddScoped(); + services.AddScoped(); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + // ── AOP: DispatchProxy aspect-oriented infrastructure ────────────────────── // AddAop() registers the built-in aspects (LoggerAspect, AdviceAspect, RetryAspect), // the PointCut, AspectExecutor and IFactory / IFactory singletons. @@ -498,21 +575,16 @@ public static IServiceCollection AddInfrastructureHealthChecks( var builder = services.AddHealthChecks(); - if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { - if (persistence.Provider == PersistenceProvider.PostgreSql) + var connectionString = configuration.GetConnectionString("DefaultConnection"); + if (!string.IsNullOrWhiteSpace(connectionString)) { - var connectionString = configuration.GetConnectionString("DefaultConnection"); - if (!string.IsNullOrWhiteSpace(connectionString)) - { - builder.AddNpgSql( - connectionString, - name: "postgresql", - tags: ["ready", "db"]); - } + builder.AddNpgSql( + connectionString, + name: "postgresql", + tags: ["ready", "db"]); } - - } return services; diff --git a/src/apps/ums.api/Ums.Infrastructure/GlobalUsings.cs b/src/apps/ums.api/Ums.Infrastructure/GlobalUsings.cs index d7291448..4467a700 100644 --- a/src/apps/ums.api/Ums.Infrastructure/GlobalUsings.cs +++ b/src/apps/ums.api/Ums.Infrastructure/GlobalUsings.cs @@ -17,4 +17,4 @@ global using Ums.Application.Common.Aop; global using Ums.Application.Common.Interfaces; global using Ums.Infrastructure.Aop; -global using BeyondNetCode.Shell.Aop.Aspects.Logger.Serilog; +global using Ums.Infrastructure.Observability; diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceBackgroundService.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceBackgroundService.cs deleted file mode 100644 index eb700f4f..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceBackgroundService.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Ums.Domain.Audit.AuditRecord; - -namespace Ums.Infrastructure.Hosting; - -internal sealed class AuditTrailPersistenceBackgroundService( - Channel auditTrailChannel, - IServiceScopeFactory scopeFactory, - ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await foreach (var entry in auditTrailChannel.Reader.ReadAllAsync(stoppingToken)) - { - try - { - await PersistAsync(entry, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogWarning(ex, - "AuditTrailPersistence: failed to persist audit entry {EventType} for {AffectedEntityType}/{AffectedEntityId}.", - entry.EventType, - entry.AffectedEntityType, - entry.AffectedEntityId); - } - } - } - - private async Task PersistAsync(AuditTrailEntry entry, CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - - var subjectType = ResolveSubjectType(entry.SubjectType); - var auditResult = ResolveAuditResult(entry.AuditResult); - - var auditRecordResult = AuditRecord.Record( - entry.WhoActed, - subjectType, - entry.WhatChanged, - entry.EventType, - auditResult, - entry.AffectedEntityId, - entry.AffectedEntityType, - entry.RootTenantId, - entry.Metadata); - - if (auditRecordResult.IsFailure) - { - logger.LogWarning( - "AuditTrailPersistence: discarded invalid audit entry {EventType} for {AffectedEntityType}/{AffectedEntityId}: {Error}", - entry.EventType, - entry.AffectedEntityType, - entry.AffectedEntityId, - auditRecordResult.Error); - return; - } - - await repository.AppendAsync(auditRecordResult.Value, cancellationToken); - await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - } - - private static SubjectType ResolveSubjectType(string value) - => value switch - { - nameof(SubjectType.User) => SubjectType.User, - nameof(SubjectType.Admin) => SubjectType.Admin, - nameof(SubjectType.System) => SubjectType.System, - "BACKGROUND_WORKER" => SubjectType.BackgroundWorker, - _ => SubjectType.System, - }; - - private static AuditResult ResolveAuditResult(string value) - => value switch - { - nameof(AuditResult.Failure) => AuditResult.Failure, - nameof(AuditResult.Partial) => AuditResult.Partial, - _ => AuditResult.Success, - }; -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceConsumer.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceConsumer.cs new file mode 100644 index 00000000..7dc757c6 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceConsumer.cs @@ -0,0 +1,67 @@ +using MassTransit; +using Ums.Application.Common.Aop; +using Ums.Domain.Audit.AuditRecord; + +namespace Ums.Infrastructure.Hosting; + +/// +/// G-040: consumidor que persiste el registro de auditoría automática entregado POST-commit por el +/// Transactional Outbox de MassTransit. Reemplaza el BackgroundService que leía un canal en +/// memoria (con descarte silencioso). Persistencia append-only vía +/// (ADR-0016). La idempotencia ante reentrega la aporta el inbox EF de MassTransit. +/// +public sealed class AuditTrailPersistenceConsumer( + IAuditRecordRepository repository, + ILogger logger) : IConsumer +{ + public async Task Consume(ConsumeContext context) + { + var entry = context.Message; + + var subjectType = ResolveSubjectType(entry.SubjectType); + var auditResult = ResolveAuditResult(entry.AuditResult); + + var auditRecordResult = AuditRecord.Record( + entry.WhoActed, + subjectType, + entry.WhatChanged, + entry.EventType, + auditResult, + entry.AffectedEntityId, + entry.AffectedEntityType, + entry.RootTenantId, + entry.Metadata); + + if (auditRecordResult.IsFailure) + { + logger.LogWarning( + "AuditTrailPersistence: descarta registro de auditoría inválido {EventType} para {AffectedEntityType}/{AffectedEntityId}: {Error}", + entry.EventType, + entry.AffectedEntityType, + entry.AffectedEntityId, + auditRecordResult.Error); + return; + } + + await repository.AppendAsync(auditRecordResult.Value, context.CancellationToken); + await repository.UnitOfWork.SaveEntitiesAsync(context.CancellationToken); + } + + private static SubjectType ResolveSubjectType(string value) + => value switch + { + nameof(SubjectType.User) => SubjectType.User, + nameof(SubjectType.Admin) => SubjectType.Admin, + nameof(SubjectType.System) => SubjectType.System, + "BACKGROUND_WORKER" => SubjectType.BackgroundWorker, + _ => SubjectType.System, + }; + + private static AuditResult ResolveAuditResult(string value) + => value switch + { + nameof(AuditResult.Failure) => AuditResult.Failure, + nameof(AuditResult.Partial) => AuditResult.Partial, + _ => AuditResult.Success, + }; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/EventHandlers/UserRevocationEventHandlers.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/EventHandlers/UserRevocationEventHandlers.cs index c0a83037..010daaf3 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Hosting/EventHandlers/UserRevocationEventHandlers.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/EventHandlers/UserRevocationEventHandlers.cs @@ -1,29 +1,32 @@ -using MassTransit; +using MediatR; using Microsoft.Extensions.Logging; using Ums.Domain.Events; -using BeyondNetCode.Shell.Ddd.Interfaces; namespace Ums.Infrastructure.Hosting.EventHandlers; +// G-066 / ADR-0098 D4/D7: la revocación de tokens es un handoff EN PROCESO (efecto intra-servicio, +// no cruza frontera de sistema). Se maneja como manejador de notificación MediatR despachado +// post-commit por UmsPlatformDbContext, no como consumidor del bróker inter-sistema (antes +// IConsumer<> sobre MassTransit, lo que exponía el evento de dominio crudo al transporte). + /// -/// HARDENING-03: Revokes tokens immediately when a user is deleted. -/// The revocation expiry is set to 24 hours — well beyond any reasonable JWT TTL. -/// Once the revocation entry expires, the user cannot have a valid token anyway -/// (they are deleted, so no IdP will issue them a new one). +/// HARDENING-03: revoca los tokens de inmediato cuando se elimina un usuario. La ventana de +/// revocación es de 24 h — muy por encima de cualquier TTL razonable de JWT. Cuando la entrada +/// expira, el usuario (eliminado) ya no puede tener token válido. /// public sealed class UserDeletedTokenRevocationHandler( ITokenRevocationStore revocationStore, ILogger logger) - : IConsumer + : INotificationHandler { private static readonly TimeSpan RevocationWindow = TimeSpan.FromHours(24); - public async Task Consume(ConsumeContext context) + public async Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken) { - var userId = context.Message.UserId.ToString(); + var userId = notification.UserId.ToString(); var revokeUntil = DateTime.UtcNow.Add(RevocationWindow); - await revocationStore.RevokeAsync(userId, revokeUntil, context.CancellationToken); + await revocationStore.RevokeAsync(userId, revokeUntil, cancellationToken); logger.LogInformation( "HARDENING-03: Revoked tokens for deleted user {UserId} until {RevokeUntil:O}.", @@ -32,27 +35,26 @@ public async Task Consume(ConsumeContext context) } /// -/// HARDENING-03: Revokes tokens immediately when a user is blocked. -/// Unlike deletion, blocking may be temporary, so the revocation window is shorter (4 h). -/// If the user is later restored, their next successful login will issue a fresh token -/// that is not in the revocation list. +/// HARDENING-03: revoca los tokens de inmediato cuando se bloquea un usuario. A diferencia de la +/// eliminación, el bloqueo puede ser temporal, así que la ventana es más corta (4 h). Si luego se +/// restaura, su próximo login exitoso emitirá un token nuevo fuera de la lista de revocación. /// public sealed class UserBlockedTokenRevocationHandler( ITokenRevocationStore revocationStore, ILogger logger) - : IConsumer + : INotificationHandler { private static readonly TimeSpan RevocationWindow = TimeSpan.FromHours(4); - public async Task Consume(ConsumeContext context) + public async Task Handle(UserBlockedEvent notification, CancellationToken cancellationToken) { - var userId = context.Message.UserId.ToString(); + var userId = notification.UserId.ToString(); var revokeUntil = DateTime.UtcNow.Add(RevocationWindow); - await revocationStore.RevokeAsync(userId, revokeUntil, context.CancellationToken); + await revocationStore.RevokeAsync(userId, revokeUntil, cancellationToken); logger.LogInformation( "HARDENING-03: Revoked tokens for blocked user {UserId} until {RevokeUntil:O}. Reason: {Reason}", - userId, revokeUntil, context.Message.Reason); + userId, revokeUntil, notification.Reason); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs index 4e2288c7..9a863b17 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs @@ -14,7 +14,7 @@ public Task StartAsync(CancellationToken cancellationToken) var options = persistenceOptions.Value; logger.LogInformation( - "UMS persistence configured with provider {Provider}, aggregate store mode {AggregateStoreMode}, identity PostgreSQL stores {UsePostgreSqlIdentityStores}, authorization PostgreSQL stores {UsePostgreSqlAuthorizationStores}, outbox enabled {EnableOutbox}.", + "UMS persistence configured with provider {Provider}, aggregate store mode {AggregateStoreMode}, PostgreSQL identity stores {UsePostgreSqlIdentityStores}, PostgreSQL authorization stores {UsePostgreSqlAuthorizationStores}, outbox enabled {EnableOutbox}.", options.Provider, options.AggregateStoreMode, options.UsePostgreSqlIdentityStores, @@ -29,16 +29,6 @@ public Task StartAsync(CancellationToken cancellationToken) "PostgreSQL is configured as the platform provider, but aggregate repositories still run in-memory. This is a valid transitional modular-monolith mode, not the final production persistence model."); } - if (options.Provider == PersistenceProvider.PostgreSql && options.UsePostgreSqlIdentityStores) - { - logger.LogInformation("Identity aggregates are configured to run on PostgreSQL repositories while the remaining contexts stay in transitional mode."); - } - - if (options.Provider == PersistenceProvider.PostgreSql && options.UsePostgreSqlAuthorizationStores) - { - logger.LogInformation("Authorization profile aggregates are configured to run on PostgreSQL repositories."); - } - return Task.CompletedTask; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/RolePromotionRoleAssignmentConsumer.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/RolePromotionRoleAssignmentConsumer.cs new file mode 100644 index 00000000..2a5c45dd --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/RolePromotionRoleAssignmentConsumer.cs @@ -0,0 +1,122 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Ums.Domain.Authorization; +using Ums.Domain.Events; + +namespace Ums.Infrastructure.Hosting; + +// G-094 (endurece ADR-UMS-096 / G-093): el EFECTO de la promoción de rol IGA —reasignar Profile.RoleId— +// ya NO se aplica por un manejador in-process best-effort (INotificationHandler despachado +// post-commit), cuyo fallo dejaba la promoción Executed sin privilegio aplicado y solo con una +// advertencia en log (fallo silencioso, riesgo de cumplimiento). Ahora llega por el Transactional +// Outbox de MassTransit: ExecuteRolePromotionCommandHandler publica +// RolePromotionExecutedIntegrationEvent dentro de la transacción que confirma Execute, y este +// consumidor lo aplica con ENTREGA GARANTIZADA (reintentos + dead-letter, ver +// RolePromotionRoleAssignmentConsumerDefinition). Calca el patrón de AuditTrailPersistenceConsumer. +// +// La reasignación se hace en la PROPIA transacción del agregado Profile (Autorización), separada de +// la de RolePromotionRequest — respeta D-016 (un agregado por transacción). Es idempotente ante +// reentrega: tras aplicarse, el perfil ya no tiene el rol origen (CurrentRoleId), de modo que la +// regla de selección deja de encontrarlo y el reconsumo termina sin efecto. + +/// +/// Aplica el cambio de rol del usuario objetivo cuando una promoción IGA se ejecuta +/// (). +/// +/// Regla de selección de perfil (ADR-UMS-096): se reasignan los perfiles ACTIVOS del usuario objetivo, +/// en el inquilino de la solicitud, cuyo rol actual coincide con el rol origen de la promoción +/// (CurrentRoleId). En el caso normal es exactamente uno. El modelo de promoción no lleva +/// dimensión de sucursal (BranchId): un usuario con varios perfiles del mismo rol en distintas +/// sucursales verá reasignados TODOS (la promoción eleva «ese rol» del usuario). +/// +/// Semántica de fallo (G-094): si un ChangeRole falla se LANZA excepción para que MassTransit +/// reintente y, agotados los reintentos, mueva el mensaje a la dead-letter — el efecto nunca se +/// descarta en silencio. Si no hay perfil coincidente no hay sujeto sobre el que actuar: se registra +/// advertencia y se confirma el mensaje (reintentar no encontraría un perfil inexistente); el efecto +/// es reconstruible (misma decisión que ADR-UMS-096). +/// +public sealed class RolePromotionRoleAssignmentConsumer( + IProfileRepository profileRepository, + ILogger logger) + : IConsumer +{ + public Task Consume(ConsumeContext context) + => ApplyRoleAssignmentAsync(context.Message, context.CancellationToken); + + /// + /// Núcleo del efecto, expuesto para pruebas aisladas del consumidor (dado el evento, aplica la + /// reasignación; ante un ChangeRole fallido lanza y no confirma éxito). + /// + public async Task ApplyRoleAssignmentAsync( + RolePromotionExecutedIntegrationEvent message, + CancellationToken cancellationToken) + { + var profiles = await profileRepository.GetByUserIdAsync(message.TargetUserId, cancellationToken); + + var toReassign = profiles + .Where(p => p.TenantId.GetValue() == message.TenantId + && p.IsActive + && p.RoleId.GetValue() == message.CurrentRoleId) + .ToList(); + + if (toReassign.Count == 0) + { + logger.LogWarning( + "G-094: promoción {RequestId} ejecutada, pero el usuario objetivo {TargetUserId} no tiene " + + "perfil ACTIVO con el rol origen {CurrentRoleId} en el inquilino {TenantId}; no se reasignó " + + "ningún rol. El efecto es reconstruible.", + message.RequestId, message.TargetUserId, message.CurrentRoleId, message.TenantId); + return; + } + + var actor = ActorId.Create(message.ExecutorId.ToString()); + var newRoleId = RoleId.Load(message.TargetRoleId); + + foreach (var profile in toReassign) + { + var result = profile.ChangeRole(newRoleId, actor); + if (result.IsFailure) + { + // G-094: NO silenciar. Lanzar hace que MassTransit reintente y, agotados los intentos, + // envíe el mensaje a la dead-letter — el efecto de la promoción no se descarta. + throw new InvalidOperationException( + $"G-094: no se pudo reasignar el rol del perfil {profile.GetId().GetValue()} " + + $"(promoción {message.RequestId}): {result.Error}"); + } + + await profileRepository.UpdateAsync(profile, cancellationToken); + } + + // D-016: transacción propia del agregado Profile, separada de la de RolePromotionRequest. + await profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + logger.LogInformation( + "G-094: promoción {RequestId} aplicada — {Count} perfil(es) del usuario {TargetUserId} reasignado(s) " + + "del rol {CurrentRoleId} al {TargetRoleId}.", + message.RequestId, toReassign.Count, message.TargetUserId, message.CurrentRoleId, message.TargetRoleId); + } +} + +/// +/// G-094: política de reintentos explícita para . +/// ConfigureEndpoints no aplica reintentos por defecto, así que ante un fallo transitorio +/// (BD, concurrencia) el mensaje iría directo a la dead-letter. Con backoff exponencial se reintenta +/// varias veces y solo entonces se descarta a _error para inspección — nunca en silencio. +/// Auto-registrada por AddConsumers(Assembly.GetExecutingAssembly()) junto al consumidor. +/// +public sealed class RolePromotionRoleAssignmentConsumerDefinition + : ConsumerDefinition +{ + protected override void ConfigureConsumer( + IReceiveEndpointConfigurator endpointConfigurator, + IConsumerConfigurator consumerConfigurator, + IRegistrationContext context) + { + endpointConfigurator.UseMessageRetry(r => + r.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromMilliseconds(200), + maxInterval: TimeSpan.FromSeconds(10), + intervalDelta: TimeSpan.FromSeconds(1))); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/AuthAuditService.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/AuthAuditService.cs index 98576a61..768aa651 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/AuthAuditService.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/AuthAuditService.cs @@ -43,6 +43,12 @@ public async Task RecordAuthEventAsync(AuthAuditEvent evt, CancellationToken can rootTenantId: rootTenantId); if (record.IsSuccess) + { await _auditRepo.AppendAsync(record.Value, cancellationToken); + // Auto-commit: los eventos Auth.* deben dejar traza siempre, incluso si el + // flujo que los origina no cierra una unidad de trabajo (p. ej. logout, o un + // login fallido que no persiste nada más). Persistir aquí lo garantiza. + await _auditRepo.UnitOfWork.SaveChangesAsync(cancellationToken); + } } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs index 4c7884f5..0c9715b4 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs @@ -2,6 +2,7 @@ using Ums.Application.Identity.Auth; using Ums.Domain.Enums; using Ums.Domain.Identity.Auth; +using Ums.Infrastructure.Identity.Auth.Oidc; namespace Ums.Infrastructure.Identity.Auth; @@ -15,33 +16,43 @@ namespace Ums.Infrastructure.Identity.Auth; /// /// Pattern: identical to IdpResolutionStrategyFactorySetup. /// -/// To add a real adapter for a strategy (e.g. AzureAd): -/// 1. Implement AzureAdIdpAuthAdapter : IIdpAuthAdapter -/// 2. Add the .Create line below and register the class in DependencyInjection.cs +/// ADR-UMS-094 / G-049 (slice 1): el real (Authorization +/// Code + PKCE, validación estricta del id_token) atiende las estrategias OIDC — +/// Keycloak, GenericOidc y la familia OIDC (AzureAd/Okta/Zitadel/Auth0/Google). Nunca +/// se usa el stub en producción. Las estrategias no-OIDC (SAML2/LDAP) siguen sin +/// adaptador → AUTH_012 hasta implementarse sobre el mismo contrato. /// internal sealed class IdpAuthAdapterFactorySetup : AbstractFactorySetupSource { public IdpAuthAdapterFactorySetup() { - // Production adapters registered per strategy. - // Until a real adapter is implemented for a strategy, remove its comment - // and add the class. An unregistered strategy returns null → AUTH_012. + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Keycloak.Name); + + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.GenericOidc.Name); + + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.AzureAd.Name); - // For() - // .Create() - // .When(x => x.StrategyName == IdpStrategy.AzureAd.Name); + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Okta.Name); - // For() - // .Create() - // .When(x => x.StrategyName == IdpStrategy.Okta.Name); + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Zitadel.Name); - // For() - // .Create() - // .When(x => x.StrategyName == IdpStrategy.Zitadel.Name); + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Auth0.Name); - // For() - // .Create() - // .When(x => x.StrategyName == IdpStrategy.GenericOidc.Name); + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Google.Name); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpJwksProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpJwksProvider.cs new file mode 100644 index 00000000..c8103eed --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpJwksProvider.cs @@ -0,0 +1,83 @@ +using System.Net.Http; +using System.Text.Json; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Implementación HTTP real de : obtiene el documento +/// JWKS del issuer y extrae las llaves RSA de firma. En los unit tests se sustituye +/// por un fake con llaves controladas; el JWKS real se ejerce en el arnés Keycloak +/// (slice 2, ADR-UMS-094). +/// +public sealed class HttpJwksProvider : IJwksProvider +{ + private readonly HttpClient _httpClient; + + public HttpJwksProvider(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task>> GetSigningKeysAsync( + OidcEndpoints endpoints, + CancellationToken cancellationToken = default) + { + try + { + using var response = await _httpClient.GetAsync(endpoints.JwksUri, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return Result>.Failure( + OidcAuthErrors.JwksFetchFailed($"HTTP {(int)response.StatusCode}.")); + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var keys = ParseKeys(body); + return Result>.Success(keys); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + return Result>.Failure( + OidcAuthErrors.JwksFetchFailed("No se pudo contactar el endpoint JWKS.")); + } + } + + private static IReadOnlyList ParseKeys(string body) + { + var keys = new List(); + + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("keys", out var keysElement) || keysElement.ValueKind != JsonValueKind.Array) + { + return keys; + } + + foreach (var key in keysElement.EnumerateArray()) + { + var kty = ReadString(key, "kty"); + var n = ReadString(key, "n"); + var e = ReadString(key, "e"); + + if (!string.Equals(kty, "RSA", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrEmpty(n) || string.IsNullOrEmpty(e)) + { + continue; + } + + keys.Add(new OidcJsonWebKey( + Kid: ReadString(key, "kid") ?? string.Empty, + Kty: kty!, + Alg: ReadString(key, "alg"), + Use: ReadString(key, "use"), + N: n!, + E: e!)); + } + + return keys; + } + + private static string? ReadString(JsonElement element, string name) + => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpOidcTokenClient.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpOidcTokenClient.cs new file mode 100644 index 00000000..5d93ad42 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpOidcTokenClient.cs @@ -0,0 +1,112 @@ +using System.Net.Http; +using System.Text.Json; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Implementación HTTP real de : intercambia el +/// authorization code por tokens contra el token endpoint del IdP +/// (grant_type=authorization_code + PKCE code_verifier). +/// Usa client_secret_post cuando el cliente es confidencial. +/// +/// En los unit tests este cliente se sustituye por un fake; el IdP real (Keycloak) +/// se ejerce sin mocks en el arnés de integración (slice 2, ADR-UMS-094). +/// +public sealed class HttpOidcTokenClient : IOidcTokenClient +{ + private readonly HttpClient _httpClient; + + public HttpOidcTokenClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> ExchangeAuthorizationCodeAsync( + OidcEndpoints endpoints, + OidcClientSettings client, + string code, + string codeVerifier, + string redirectUri, + CancellationToken cancellationToken = default) + { + var form = new List> + { + new("grant_type", "authorization_code"), + new("code", code), + new("redirect_uri", redirectUri), + new("client_id", client.ClientId), + new("code_verifier", codeVerifier), + }; + + if (!string.IsNullOrEmpty(client.ClientSecret)) + { + form.Add(new KeyValuePair("client_secret", client.ClientSecret)); + } + + try + { + using var content = new FormUrlEncodedContent(form); + using var response = await _httpClient.PostAsync(endpoints.TokenEndpoint, content, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + // G-108 · ADR-UMS-097 §2.3 — separación ESTRUCTURAL por clase de status HTTP (NUNCA por texto): + // • 5xx → INDISPONIBILIDAD de infraestructura del IdP → AUTH_035 (TokenEndpointUnavailable). + // Entra en la lista blanca del clasificador y HABILITA el fallback encadenado. + // • 4xx → fallo de credencial/petición (p. ej. invalid_grant) → AUTH_021 (TokenExchangeFailed), + // TERMINAL. NUNCA debe entrar en la lista blanca de infra: encadenar ante un 4xx + // abriría credential spraying cross-IdP (invariante irrenunciable de ADR-UMS-097 §2.3). + // • Cualquier otro no-éxito (p. ej. 3xx inesperado) → TERMINAL (fail-closed: ante la duda, + // no se avanza la cadena; errar hacia terminal es lo correcto). + return (int)response.StatusCode >= 500 + ? Result.Failure( + OidcAuthErrors.TokenEndpointUnavailable($"HTTP {(int)response.StatusCode}.")) + : Result.Failure( + OidcAuthErrors.TokenExchangeFailed($"HTTP {(int)response.StatusCode}.")); + } + + var token = Parse(body); + return Result.Success(token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancelación pedida por el LLAMADOR (no es indisponibilidad del IdP): se propaga tal cual. + throw; + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) + { + // Fallo de TRANSPORTE (conexión/red/DNS) o TIMEOUT del token endpoint (el HttpClient aborta con + // TaskCanceledException —derivada de OperationCanceledException— sin que el token del llamador + // esté cancelado) → INDISPONIBILIDAD de infraestructura → AUTH_035 (habilita el fallback). + return Result.Failure( + OidcAuthErrors.TokenEndpointUnavailable("No se pudo contactar el token endpoint (transporte/timeout).")); + } + catch (JsonException) + { + // Respuesta 2xx con cuerpo NO-JSON: la credencial fue aceptada, pero el cuerpo no parsea. No es + // ni un rechazo de credencial ni una indisponibilidad inequívoca → TERMINAL (fail-closed): no se + // avanza la cadena por una anomalía de formato de una respuesta por lo demás exitosa. + return Result.Failure( + OidcAuthErrors.TokenExchangeFailed("La respuesta del token endpoint no es un JSON válido.")); + } + } + + private static OidcTokenResponse Parse(string body) + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + return new OidcTokenResponse( + IdToken: ReadString(root, "id_token"), + AccessToken: ReadString(root, "access_token"), + RefreshToken: ReadString(root, "refresh_token"), + TokenType: ReadString(root, "token_type"), + ExpiresIn: root.TryGetProperty("expires_in", out var e) && e.TryGetInt32(out var v) ? v : null); + } + + private static string? ReadString(JsonElement element, string name) + => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/IdpConfigurationOidcProviderConfigStore.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/IdpConfigurationOidcProviderConfigStore.cs new file mode 100644 index 00000000..5e6dda0f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/IdpConfigurationOidcProviderConfigStore.cs @@ -0,0 +1,66 @@ +using Ums.Domain.Configuration; +using Ums.Domain.Configuration.IdpConfiguration; +using Ums.Domain.Enums; +using Ums.Domain.Identity.Tenant.IdentityProvider; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Resuelve la del proveedor a partir del +/// IdpConfiguration activo del inquilino, parseando su ConfigPayload +/// (ADR-UMS-094: endpoints leídos de configuración, nunca hardcodeados). +/// +/// La resolución completa por prioridad/suite/dominio (FR-042) y la desencriptación +/// del secreto desde el SecretRef se integran en el slice 2; aquí se toma el +/// primer IdpConfiguration activo cuyo ProviderType corresponde a la +/// estrategia del proveedor, ordenado por prioridad. +/// +public sealed class IdpConfigurationOidcProviderConfigStore : IOidcProviderConfigStore +{ + private static readonly IReadOnlyDictionary StrategyToProviderType = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [IdpStrategy.Keycloak.Name] = ProviderType.Keycloak.Name, + [IdpStrategy.GenericOidc.Name] = ProviderType.GenericOidc.Name, + [IdpStrategy.AzureAd.Name] = ProviderType.AzureAd.Name, + [IdpStrategy.Okta.Name] = ProviderType.Okta.Name, + [IdpStrategy.Zitadel.Name] = ProviderType.Zitadel.Name, + [IdpStrategy.Auth0.Name] = ProviderType.Auth0.Name, + [IdpStrategy.Google.Name] = ProviderType.Google.Name, + }; + + private readonly IIdpConfigurationRepository _repository; + + public IdpConfigurationOidcProviderConfigStore(IIdpConfigurationRepository repository) + { + _repository = repository; + } + + public async Task> GetAsync( + IdentityProvider provider, + CancellationToken cancellationToken = default) + { + if (!StrategyToProviderType.TryGetValue(provider.Strategy.Name, out var providerTypeName)) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound($"La estrategia '{provider.Strategy.Name}' no es OIDC.")); + } + + var configurations = await _repository.GetByTenantIdAsync(provider.TenantId.GetValue(), cancellationToken); + + var selected = configurations + .Where(configuration => configuration.Status == IdpConfigStatus.Active) + .Where(configuration => string.Equals(configuration.ProviderType.Name, providerTypeName, StringComparison.OrdinalIgnoreCase)) + .OrderBy(configuration => configuration.ResolutionPriority) + .ThenByDescending(configuration => configuration.Version) + .FirstOrDefault(); + + if (selected is null) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound($"No hay IdpConfiguration activo para '{providerTypeName}' en el inquilino.")); + } + + return OidcProviderConfigParser.Parse(selected.Props.ConfigPayload); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthErrors.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthErrors.cs new file mode 100644 index 00000000..f1290e7d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthErrors.cs @@ -0,0 +1,82 @@ +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Códigos y mensajes de error del flujo OIDC (Result Pattern, textos en español). +/// Convergen con la normalización Result→HTTP (G-045): fallos de configuración / +/// validación → 401/409, nunca 503 ni excepción (ADR-UMS-094). +/// +public static class OidcAuthErrors +{ + public static string ConfigNotFound(string detalle) => + $"AUTH_020: No se encontró configuración OIDC válida para el proveedor. {detalle}"; + + public static string CallbackInvalid(string detalle) => + $"AUTH_033: Callback OIDC inválido. {detalle}"; + + public const string StateMismatch = + "AUTH_033: El parámetro 'state' del callback no coincide con el emitido en la autorización."; + + /// + /// AUTH_021 — fallo del intercambio de código atribuible al CLIENTE/CREDENCIAL: respuesta 4xx del + /// token endpoint (p. ej. invalid_grant) o cuerpo 2xx malformado. Es TERMINAL: nunca debe + /// entrar en la lista blanca de infra del clasificador, porque encadenar el fallback ante un fallo de + /// credencial abriría credential spraying cross-IdP (invariante irrenunciable de ADR-UMS-097 §2.3). + /// La rama de INFRAESTRUCTURA (5xx/timeout/transporte) usa un código estructuralmente distinto + /// ( = AUTH_035); ver G-108. + /// + public static string TokenExchangeFailed(string detalle) => + $"AUTH_021: Falló el intercambio del código por tokens contra el IdP. {detalle}"; + + /// + /// AUTH_035 — INDISPONIBILIDAD de infraestructura del token endpoint del IdP: respuesta 5xx, timeout o + /// fallo de transporte (conexión/red/DNS). Es estructuralmente distinto de + /// (AUTH_021 = 4xx/credencial → TERMINAL): esa separación por clase de + /// status HTTP / tipo de excepción —no por texto— es la que permite habilitar el fallback encadenado + /// SOLO por infra (ADR-UMS-097 §2.3, G-108) sin abrir credential spraying. Se añade a la lista + /// blanca IdpAuthOutcomeClassifier.InfraUnavailableCodes y mapea a HTTP 503. + /// + /// Se eligió un código NUEVO en la familia OIDC (AUTH_020..AUTH_034; local a este flujo, no vive + /// en el catálogo SDK) en lugar de reutilizar AUTH_013=IdpCallFailed: AUTH_013 está contaminado + /// por un rechazo de credencial del stub de dev (StubIdpAuthAdapter) y por errores de gestión, + /// de modo que reusarlo para infra arriesgaría clasificar un rechazo de credencial como infra + /// (regresión de seguridad). AUTH_035 no colisiona con ninguna semántica previa (G-108). + /// + public static string TokenEndpointUnavailable(string detalle) => + $"AUTH_035: El token endpoint del IdP no está disponible (5xx/timeout/transporte). {detalle}"; + + public const string MissingIdToken = + "AUTH_022: La respuesta del token endpoint no contiene 'id_token'."; + + public const string MalformedIdToken = + "AUTH_023: El 'id_token' no tiene el formato JWT esperado (cabecera.carga.firma)."; + + public static string UnsupportedAlgorithm(string? alg) => + $"AUTH_024: Algoritmo de firma no soportado ('{alg ?? "none"}'). Se exige RS256."; + + public static string SigningKeyNotFound(string? kid) => + $"AUTH_025: No se encontró la llave de firma (kid='{kid ?? "-"}') en el JWKS del issuer."; + + public const string InvalidSignature = + "AUTH_026: La firma del 'id_token' es inválida."; + + public const string InvalidIssuer = + "AUTH_027: El 'iss' del 'id_token' no coincide con el issuer configurado."; + + public const string InvalidAudience = + "AUTH_028: El 'aud' del 'id_token' no incluye el client_id configurado."; + + public const string Expired = + "AUTH_029: El 'id_token' está expirado ('exp')."; + + public const string NotYetValid = + "AUTH_030: El 'id_token' aún no es válido ('nbf')."; + + public const string NonceMismatch = + "AUTH_031: El 'nonce' del 'id_token' no coincide con el emitido en la autorización."; + + public const string MissingEmail = + "AUTH_032: El 'id_token' no contiene el claim 'email'; no se puede mapear la identidad UMS."; + + public static string JwksFetchFailed(string detalle) => + $"AUTH_034: No se pudo obtener el JWKS del issuer. {detalle}"; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthorizationRequestFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthorizationRequestFactory.cs new file mode 100644 index 00000000..66d75074 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthorizationRequestFactory.cs @@ -0,0 +1,47 @@ +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Construye la URL de autorización del flujo Authorization Code + PKCE (S256), +/// emitiendo los artefactos de sesión (state, nonce, code_verifier) que deben +/// persistirse para validarse en el callback (ADR-UMS-094). +/// +public sealed class OidcAuthorizationRequestFactory +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", + Justification = "Servicio registrado en DI (AddScoped) e invocado por instancia; la API de fábrica " + + "es de instancia por diseño (ADR-UMS-094). Volverla estática rompería los llamadores.")] + public OidcAuthorizationRequest Build(OidcProviderConfig config, string? loginHint = null) + { + var codeVerifier = OidcPkce.GenerateCodeVerifier(); + var codeChallenge = OidcPkce.ComputeS256Challenge(codeVerifier); + var state = OidcPkce.GenerateOpaqueValue(); + var nonce = OidcPkce.GenerateOpaqueValue(); + + var parameters = new List> + { + new("response_type", "code"), + new("client_id", config.Client.ClientId), + new("redirect_uri", config.Client.RedirectUri), + new("scope", config.Client.Scopes), + new("state", state), + new("nonce", nonce), + new("code_challenge", codeChallenge), + new("code_challenge_method", "S256"), + }; + + if (!string.IsNullOrWhiteSpace(loginHint)) + { + parameters.Add(new KeyValuePair("login_hint", loginHint)); + } + + var query = string.Join( + '&', + parameters.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + + var separator = config.Endpoints.AuthorizationEndpoint.Contains('?') ? '&' : '?'; + var url = $"{config.Endpoints.AuthorizationEndpoint}{separator}{query}"; + + return new OidcAuthorizationRequest(url, state, nonce, codeVerifier, codeChallenge); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcBase64Url.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcBase64Url.cs new file mode 100644 index 00000000..c3e1ed17 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcBase64Url.cs @@ -0,0 +1,26 @@ +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Codificación/decodificación Base64Url (RFC 7515) sin dependencias externas. +/// Usada por PKCE y por la verificación de firma del id_token. +/// +internal static class OidcBase64Url +{ + public static string Encode(ReadOnlySpan bytes) => + Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + public static byte[] Decode(string input) + { + var s = input.Replace('-', '+').Replace('_', '/'); + s += (s.Length % 4) switch + { + 2 => "==", + 3 => "=", + _ => string.Empty, + }; + return Convert.FromBase64String(s); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdTokenValidator.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdTokenValidator.cs new file mode 100644 index 00000000..9ee5577a --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdTokenValidator.cs @@ -0,0 +1,239 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ums.Domain.Identity.Auth; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Validación estricta del id_token OIDC y mapeo de claims → identidad UMS +/// (ADR-UMS-094). Verifica: +/// +/// formato JWT y algoritmo RS256 (rechaza none y confusión de algoritmo); +/// firma RSASSA-PKCS1-v1_5/SHA-256 contra la llave del JWKS del issuer (por kid); +/// claims iss, aud, exp, nbf y nonce. +/// +/// Un adaptador laxo es una vulnerabilidad: cualquier discrepancia devuelve +/// de fallo con código de dominio, nunca una excepción. +/// +public sealed class OidcIdTokenValidator +{ + private const string ExpectedAlgorithm = "RS256"; + + private readonly IJwksProvider _jwksProvider; + private readonly TimeProvider _timeProvider; + private readonly TimeSpan _clockSkew; + + public OidcIdTokenValidator( + IJwksProvider jwksProvider, + TimeProvider? timeProvider = null, + TimeSpan? clockSkew = null) + { + _jwksProvider = jwksProvider; + _timeProvider = timeProvider ?? TimeProvider.System; + _clockSkew = clockSkew ?? TimeSpan.FromMinutes(2); + } + + public async Task> ValidateAsync( + string idToken, + OidcEndpoints endpoints, + string expectedAudience, + string expectedNonce, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(idToken)) + { + return Result.Failure(OidcAuthErrors.MalformedIdToken); + } + + var parts = idToken.Split('.'); + if (parts.Length != 3) + { + return Result.Failure(OidcAuthErrors.MalformedIdToken); + } + + JsonElement header; + JsonElement payload; + try + { + header = ParseSegment(parts[0]); + payload = ParseSegment(parts[1]); + } + catch (Exception ex) when (ex is JsonException or FormatException) + { + return Result.Failure(OidcAuthErrors.MalformedIdToken); + } + + // 1) Algoritmo: sólo RS256 (rechaza 'none' y confusión de algoritmo HS/RS). + var alg = ReadString(header, "alg"); + if (!string.Equals(alg, ExpectedAlgorithm, StringComparison.Ordinal)) + { + return Result.Failure(OidcAuthErrors.UnsupportedAlgorithm(alg)); + } + + // 2) Firma: llave por 'kid' desde el JWKS del issuer. + var kid = ReadString(header, "kid"); + var keysResult = await _jwksProvider.GetSigningKeysAsync(endpoints, cancellationToken); + if (keysResult.IsFailure) + { + return Result.Failure(OidcAuthErrors.JwksFetchFailed(keysResult.Error)); + } + + var key = SelectKey(keysResult.Value, kid); + if (key is null) + { + return Result.Failure(OidcAuthErrors.SigningKeyNotFound(kid)); + } + + if (!VerifySignature(parts[0], parts[1], parts[2], key)) + { + return Result.Failure(OidcAuthErrors.InvalidSignature); + } + + // 3) iss + var iss = ReadString(payload, "iss"); + if (!string.Equals(iss, endpoints.Issuer, StringComparison.Ordinal)) + { + return Result.Failure(OidcAuthErrors.InvalidIssuer); + } + + // 4) aud (string o arreglo) + if (!AudienceMatches(payload, expectedAudience)) + { + return Result.Failure(OidcAuthErrors.InvalidAudience); + } + + // 5) exp / 6) nbf + var now = _timeProvider.GetUtcNow(); + if (TryReadUnixTime(payload, "exp", out var exp) && now > exp + _clockSkew) + { + return Result.Failure(OidcAuthErrors.Expired); + } + + if (TryReadUnixTime(payload, "nbf", out var nbf) && now < nbf - _clockSkew) + { + return Result.Failure(OidcAuthErrors.NotYetValid); + } + + // 7) nonce + var nonce = ReadString(payload, "nonce"); + if (!string.IsNullOrEmpty(expectedNonce) && !string.Equals(nonce, expectedNonce, StringComparison.Ordinal)) + { + return Result.Failure(OidcAuthErrors.NonceMismatch); + } + + // 8) Mapeo claims → identidad UMS. + return MapIdentity(payload); + } + + private static Result MapIdentity(JsonElement payload) + { + var email = ReadString(payload, "email"); + if (string.IsNullOrWhiteSpace(email)) + { + return Result.Failure(OidcAuthErrors.MissingEmail); + } + + var subject = ReadString(payload, "sub"); + var displayName = ReadString(payload, "name") + ?? ReadString(payload, "preferred_username") + ?? email.Split('@')[0]; + + var claims = new Dictionary(StringComparer.Ordinal); + foreach (var prop in payload.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.String) + { + claims[prop.Name] = prop.Value.GetString()!; + } + else if (prop.Value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False) + { + claims[prop.Name] = prop.Value.ToString(); + } + } + + var identity = new ExternalIdentity(email, subject, displayName, claims); + return Result.Success(identity); + } + + private static bool VerifySignature(string headerSegment, string payloadSegment, string signatureSegment, OidcJsonWebKey key) + { + try + { + var signingInput = Encoding.ASCII.GetBytes($"{headerSegment}.{payloadSegment}"); + var signature = OidcBase64Url.Decode(signatureSegment); + + using var rsa = RSA.Create(); + rsa.ImportParameters(new RSAParameters + { + Modulus = OidcBase64Url.Decode(key.N), + Exponent = OidcBase64Url.Decode(key.E), + }); + + return rsa.VerifyData(signingInput, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + catch (Exception ex) when (ex is FormatException or CryptographicException) + { + return false; + } + } + + private static OidcJsonWebKey? SelectKey(IReadOnlyList keys, string? kid) + { + var rsaKeys = keys.Where(k => string.Equals(k.Kty, "RSA", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (!string.IsNullOrEmpty(kid)) + { + return rsaKeys.FirstOrDefault(k => string.Equals(k.Kid, kid, StringComparison.Ordinal)); + } + + // Sin 'kid': sólo es determinista si hay exactamente una llave RSA. + return rsaKeys.Count == 1 ? rsaKeys[0] : null; + } + + private static bool AudienceMatches(JsonElement payload, string expectedAudience) + { + if (!payload.TryGetProperty("aud", out var aud)) + { + return false; + } + + return aud.ValueKind switch + { + JsonValueKind.String => string.Equals(aud.GetString(), expectedAudience, StringComparison.Ordinal), + JsonValueKind.Array => aud.EnumerateArray().Any(a => + a.ValueKind == JsonValueKind.String && + string.Equals(a.GetString(), expectedAudience, StringComparison.Ordinal)), + _ => false, + }; + } + + private static bool TryReadUnixTime(JsonElement payload, string name, out DateTimeOffset value) + { + value = default; + if (!payload.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.Number) + { + return false; + } + + if (!element.TryGetInt64(out var seconds)) + { + return false; + } + + value = DateTimeOffset.FromUnixTimeSeconds(seconds); + return true; + } + + private static JsonElement ParseSegment(string segment) + { + var json = Encoding.UTF8.GetString(OidcBase64Url.Decode(segment)); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + private static string? ReadString(JsonElement element, string name) + => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdpAuthAdapter.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdpAuthAdapter.cs new file mode 100644 index 00000000..fd5b5c8f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdpAuthAdapter.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Adaptador de autenticación OIDC real (Authorization Code + PKCE) sobre el +/// contrato (ADR-UMS-094 · G-049). Reemplaza al +/// StubIdpAuthAdapter para las estrategias OIDC/Keycloak/GenericOidc. +/// +/// El contrato recibe un credential +/// único; en la fase de callback el nivel de presentación empaqueta en él —como JSON— +/// el (código + artefactos de sesión state/nonce/code_verifier). +/// El adaptador entonces: +/// +/// resuelve los endpoints OIDC del inquilino (sin hardcodear); +/// valida que state coincida con el emitido en la autorización; +/// intercambia el código por tokens contra el token endpoint; +/// valida estrictamente el id_token (firma JWKS, iss/aud/exp/nbf/nonce); +/// mapea los claims a la identidad externa UMS. +/// +/// La construcción de la URL de autorización vive en +/// . +/// +public sealed class OidcIdpAuthAdapter : IIdpAuthAdapter +{ + private readonly IOidcProviderConfigStore _configStore; + private readonly IOidcTokenClient _tokenClient; + private readonly OidcIdTokenValidator _idTokenValidator; + + public OidcIdpAuthAdapter( + IOidcProviderConfigStore configStore, + IOidcTokenClient tokenClient, + OidcIdTokenValidator idTokenValidator) + { + _configStore = configStore; + _tokenClient = tokenClient; + _idTokenValidator = idTokenValidator; + } + + public async Task> ValidateAsync( + IdentityProvider provider, + string credential, + CancellationToken cancellationToken = default) + { + var callbackResult = ParseCallback(credential); + if (callbackResult.IsFailure) + { + return Result.Failure(callbackResult.Error); + } + + var callback = callbackResult.Value; + + var configResult = await _configStore.GetAsync(provider, cancellationToken); + if (configResult.IsFailure) + { + return Result.Failure(configResult.Error); + } + + var config = configResult.Value; + + // Defensa CSRF: el 'state' del callback debe coincidir con el emitido. + if (!FixedTimeEquals(callback.State, callback.ExpectedState)) + { + return Result.Failure(OidcAuthErrors.StateMismatch); + } + + var tokenResult = await _tokenClient.ExchangeAuthorizationCodeAsync( + config.Endpoints, + config.Client, + callback.Code, + callback.CodeVerifier, + callback.RedirectUri, + cancellationToken); + + if (tokenResult.IsFailure) + { + return Result.Failure(tokenResult.Error); + } + + var idToken = tokenResult.Value.IdToken; + if (string.IsNullOrWhiteSpace(idToken)) + { + return Result.Failure(OidcAuthErrors.MissingIdToken); + } + + return await _idTokenValidator.ValidateAsync( + idToken, + config.Endpoints, + config.Client.ClientId, + callback.ExpectedNonce, + cancellationToken); + } + + private static Result ParseCallback(string credential) + { + if (string.IsNullOrWhiteSpace(credential)) + { + return Result.Failure( + OidcAuthErrors.CallbackInvalid("El 'credential' del callback está vacío.")); + } + + try + { + var callback = JsonSerializer.Deserialize(credential, SerializerOptions); + if (callback is null || + string.IsNullOrWhiteSpace(callback.Code) || + string.IsNullOrWhiteSpace(callback.State) || + string.IsNullOrWhiteSpace(callback.CodeVerifier) || + string.IsNullOrWhiteSpace(callback.RedirectUri)) + { + return Result.Failure( + OidcAuthErrors.CallbackInvalid("Faltan campos obligatorios en el callback OIDC.")); + } + + return Result.Success(callback); + } + catch (JsonException) + { + return Result.Failure( + OidcAuthErrors.CallbackInvalid("El 'credential' no es un callback OIDC JSON válido.")); + } + } + + private static bool FixedTimeEquals(string a, string b) + { + var ba = System.Text.Encoding.UTF8.GetBytes(a); + var bb = System.Text.Encoding.UTF8.GetBytes(b); + return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ba, bb); + } + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcModels.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcModels.cs new file mode 100644 index 00000000..5fc7da82 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcModels.cs @@ -0,0 +1,78 @@ +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Endpoints OIDC del inquilino. Se leen de la configuración del proveedor +/// (IdpConfiguration.ConfigPayload), nunca se hardcodean (ADR-UMS-094). +/// +public sealed record OidcEndpoints( + string AuthorizationEndpoint, + string TokenEndpoint, + string JwksUri, + string Issuer); + +/// +/// Parámetros del cliente OIDC registrado ante el IdP del inquilino. +/// El ClientSecret proviene del secreto cifrado en reposo y nunca se +/// registra en logs, proyecciones ni grafo (ADR-UMS-094). +/// +public sealed record OidcClientSettings( + string ClientId, + string? ClientSecret, + string RedirectUri, + string Scopes = "openid email profile"); + +/// +/// Configuración OIDC completa resuelta para un proveedor del inquilino: +/// endpoints + parámetros de cliente. +/// +public sealed record OidcProviderConfig( + OidcEndpoints Endpoints, + OidcClientSettings Client); + +/// +/// Petición de autorización construida por el adaptador (Authorization Code + PKCE). +/// El State, Nonce y CodeVerifier deben persistirse en la sesión +/// del lado servidor para validarse en el callback. +/// +public sealed record OidcAuthorizationRequest( + string AuthorizationUrl, + string State, + string Nonce, + string CodeVerifier, + string CodeChallenge); + +/// +/// Datos del retorno (callback) OIDC más los artefactos de sesión emitidos en la +/// fase de autorización. El adaptador valida que State coincida con +/// ExpectedState y que el nonce del id_token coincida con +/// ExpectedNonce. +/// +public sealed record OidcCallback( + string Code, + string State, + string ExpectedState, + string ExpectedNonce, + string CodeVerifier, + string RedirectUri); + +/// +/// Respuesta del token endpoint del IdP tras el intercambio del código. +/// +public sealed record OidcTokenResponse( + string? IdToken, + string? AccessToken, + string? RefreshToken, + string? TokenType, + int? ExpiresIn); + +/// +/// Llave pública RSA publicada por el JWKS del issuer (subconjunto usado para +/// verificar la firma RS256 del id_token). +/// +public sealed record OidcJsonWebKey( + string Kid, + string Kty, + string? Alg, + string? Use, + string N, + string E); diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcPkce.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcPkce.cs new file mode 100644 index 00000000..424e7720 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcPkce.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Primitivas PKCE (RFC 7636) y valores anti-repetición (state/nonce) para el flujo +/// Authorization Code + PKCE. Todos los valores se generan con un RNG criptográfico. +/// +public static class OidcPkce +{ + /// Genera un code_verifier de alta entropía (43 chars, 32 bytes). + public static string GenerateCodeVerifier() + => OidcBase64Url.Encode(RandomNumberGenerator.GetBytes(32)); + + /// Calcula el code_challenge con el método S256: BASE64URL(SHA256(verifier)). + public static string ComputeS256Challenge(string codeVerifier) + { + var hash = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier)); + return OidcBase64Url.Encode(hash); + } + + /// Genera un valor opaco (state/nonce) de 32 bytes. + public static string GenerateOpaqueValue() + => OidcBase64Url.Encode(RandomNumberGenerator.GetBytes(32)); +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcProviderConfigParser.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcProviderConfigParser.cs new file mode 100644 index 00000000..225c39f3 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcProviderConfigParser.cs @@ -0,0 +1,94 @@ +using System.Text.Json; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Parsea la configuración OIDC (endpoints + cliente) desde el ConfigPayload +/// JSON del IdpConfiguration del inquilino. No hardcodea endpoints (ADR-UMS-094). +/// +/// Acepta claves tanto en estilo OIDC discovery (authorization_endpoint, +/// token_endpoint, jwks_uri, issuer) como abreviadas. Si el +/// payload sólo trae issuer/authority, deriva los endpoints estándar +/// de Keycloak/OIDC. +/// +public static class OidcProviderConfigParser +{ + public static Result Parse(string configPayload, string? clientSecret = null) + { + if (string.IsNullOrWhiteSpace(configPayload)) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound("El 'configPayload' del proveedor está vacío.")); + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(configPayload); + } + catch (JsonException) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound("El 'configPayload' del proveedor no es JSON válido.")); + } + + using (doc) + { + var root = doc.RootElement; + + var issuer = Read(root, "issuer", "authority"); + var authorization = Read(root, "authorization_endpoint", "authorizationEndpoint"); + var token = Read(root, "token_endpoint", "tokenEndpoint"); + var jwks = Read(root, "jwks_uri", "jwksUri", "jwks"); + + // Derivación estándar OIDC/Keycloak a partir del issuer si faltan endpoints. + if (!string.IsNullOrWhiteSpace(issuer)) + { + var baseUrl = issuer!.TrimEnd('/'); + authorization ??= $"{baseUrl}/protocol/openid-connect/auth"; + token ??= $"{baseUrl}/protocol/openid-connect/token"; + jwks ??= $"{baseUrl}/protocol/openid-connect/certs"; + } + + var clientId = Read(root, "client_id", "clientId", "applicationId"); + var redirectUri = Read(root, "redirect_uri", "redirectUri"); + var scopes = Read(root, "scope", "scopes") ?? "openid email profile"; + var secret = clientSecret ?? Read(root, "client_secret", "clientSecret"); + + var missing = new List(); + if (string.IsNullOrWhiteSpace(issuer)) missing.Add("issuer"); + if (string.IsNullOrWhiteSpace(authorization)) missing.Add("authorization_endpoint"); + if (string.IsNullOrWhiteSpace(token)) missing.Add("token_endpoint"); + if (string.IsNullOrWhiteSpace(jwks)) missing.Add("jwks_uri"); + if (string.IsNullOrWhiteSpace(clientId)) missing.Add("client_id"); + if (string.IsNullOrWhiteSpace(redirectUri)) missing.Add("redirect_uri"); + + if (missing.Count > 0) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound($"Faltan claves obligatorias: {string.Join(", ", missing)}.")); + } + + var config = new OidcProviderConfig( + new OidcEndpoints(authorization!, token!, jwks!, issuer!), + new OidcClientSettings(clientId!, secret, redirectUri!, scopes)); + + return Result.Success(config); + } + } + + private static string? Read(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out var value) && + value.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(value.GetString())) + { + return value.GetString(); + } + } + + return null; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/Ports.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/Ports.cs new file mode 100644 index 00000000..ac2ca165 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/Ports.cs @@ -0,0 +1,45 @@ +using Ums.Domain.Identity.Tenant.IdentityProvider; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Puerto de acceso HTTP al token endpoint del IdP. Se abstrae para poder +/// unit-testear la orquestación del adaptador sin un IdP vivo (ADR-UMS-094 · slice 1). +/// La implementación real (HTTP) es . +/// +public interface IOidcTokenClient +{ + Task> ExchangeAuthorizationCodeAsync( + OidcEndpoints endpoints, + OidcClientSettings client, + string code, + string codeVerifier, + string redirectUri, + CancellationToken cancellationToken = default); +} + +/// +/// Puerto de obtención del JWKS (llaves públicas de firma) del issuer. Se abstrae +/// para validar la firma del id_token con llaves controladas en los unit +/// tests, sin depender de un IdP real (ADR-UMS-094 · slice 1). +/// La implementación real (HTTP) es . +/// +public interface IJwksProvider +{ + Task>> GetSigningKeysAsync( + OidcEndpoints endpoints, + CancellationToken cancellationToken = default); +} + +/// +/// Puerto que resuelve los endpoints y parámetros OIDC del proveedor a partir de la +/// configuración del inquilino (IdpConfiguration), de modo que el adaptador +/// nunca hardcodee endpoints (ADR-UMS-094). La implementación real es +/// . +/// +public interface IOidcProviderConfigStore +{ + Task> GetAsync( + IdentityProvider provider, + CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Infrastructure/MasterData/Contracts/TenantEvent.cs b/src/apps/ums.api/Ums.Infrastructure/MasterData/Contracts/TenantEvent.cs index 81f19518..b2b412cb 100644 --- a/src/apps/ums.api/Ums.Infrastructure/MasterData/Contracts/TenantEvent.cs +++ b/src/apps/ums.api/Ums.Infrastructure/MasterData/Contracts/TenantEvent.cs @@ -1,10 +1,16 @@ namespace Evolith.Contracts.MasterData; /// -/// Wire contract for master-Tenant events published by MMS (ADR-0106 / ADR-0050). Duplicated -/// verbatim from the producer's canonical Evolith.Contracts.MasterData namespace so -/// MassTransit routes the message to this consumer (until a shared Evolith.Messaging.Contracts -/// package exists). Do not change field names/namespace independently of the producer. +/// Contrato de mensajería (wire) para eventos de Tenant maestro publicados por MMS. +/// El nombre canónico del contrato es el del productor, Evolith.Contracts.MasterData, +/// reproducido aquí verbatim (ADR-0106): UMS es CONSUMIDOR del Tenant Maestro que MMS posee +/// dentro de Evolith Core, y MassTransit enruta por el URN del tipo. Este namespace NO sigue +/// la convención Ums.* del satélite a propósito. +/// Decisión D-006 / G-011: hoy NO existe un productor MMS real (prototipo), por lo que +/// este consumidor está inerte por ausencia de mensajes — no hay riesgo latente. Cuando +/// exista integración real, el productor debe publicar con este mismo namespace+tipo +/// (MassTransit enruta por el URN del mensaje); no cambiar nombres de campo ni namespace +/// de forma unilateral respecto del productor. /// public sealed record TenantEvent { diff --git a/src/apps/ums.api/Ums.Infrastructure/MasterData/TenantProjectionDbContextFactory.cs b/src/apps/ums.api/Ums.Infrastructure/MasterData/TenantProjectionDbContextFactory.cs index 1ec6f88e..9db76d57 100644 --- a/src/apps/ums.api/Ums.Infrastructure/MasterData/TenantProjectionDbContextFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/MasterData/TenantProjectionDbContextFactory.cs @@ -9,7 +9,9 @@ public sealed class TenantProjectionDbContextFactory : IDesignTimeDbContextFacto public TenantProjectionDbContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder() +#pragma warning disable S2068 // Cadena de conexión de DISEÑO (EF Core tooling), solo local; el runtime resuelve por configuración. No es un secreto de producción. .UseNpgsql("Host=localhost;Port=5432;Database=ums;Username=postgres;Password=postgres") +#pragma warning restore S2068 .Options; return new TenantProjectionDbContext(options); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Messaging/MassTransitIntegrationEventPublisher.cs b/src/apps/ums.api/Ums.Infrastructure/Messaging/MassTransitIntegrationEventPublisher.cs new file mode 100644 index 00000000..9104bcf8 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Messaging/MassTransitIntegrationEventPublisher.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Ums.Application.Common.Interfaces; +using Ums.Domain.Events; + +namespace Ums.Infrastructure.Messaging; + +/// +/// ADR-0098 D7 / opción F: publica eventos de integración por el bus de MassTransit. Bajo kind/prod +/// el con ámbito de petición está respaldado por el bus-outbox de +/// UmsPlatformDbContext (UseBusOutbox()): el mensaje se estaciona con el cambio del +/// agregado y se entrega POST-commit. Es la frontera explícita entre el dominio (en proceso) y el +/// transporte inter-sistema; ningún evento de dominio crudo la cruza. +/// +public sealed class MassTransitIntegrationEventPublisher( + IPublishEndpoint publishEndpoint, + IFunctionalTransaction functionalTransaction) + : IIntegrationEventPublisher +{ + public Task PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + functionalTransaction.RecordEffect("message.publish", "outbox", integrationEvent.GetType().Name, EffectReversibility.PendingCompensation); + return publishEndpoint.Publish(integrationEvent, integrationEvent.GetType(), cancellationToken); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Observability/FunctionalTransaction.cs b/src/apps/ums.api/Ums.Infrastructure/Observability/FunctionalTransaction.cs new file mode 100644 index 00000000..5b2cadfc --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Observability/FunctionalTransaction.cs @@ -0,0 +1,321 @@ +namespace Ums.Infrastructure.Observability; + +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Ums.Application.Common.Interfaces; + +/// +/// Transacción funcional con alcance de petición (ADR-0096; decisión UMS en ADR-UMS-085). +/// Modela el proceso como un relato y emite la narrativa —apertura, etapas, decisiones, +/// efectos y desenlace— como logs estructurados enriquecidos con el vocabulario +/// beyondnet.transaction.* / beyondnet.stage / beyondnet.effect.*, además de +/// etiquetas sobre la en curso para correlación en Tempo. +/// +/// El desenlace lo garantiza el middleware por construcción +/// (FunctionalTransactionMiddleware): abre con y cierra siempre +/// con , incluso en la ruta de fallo. Una apertura sin desenlace +/// es un defecto alertable, detectable en el store de observabilidad. +/// +public sealed class FunctionalTransaction : IFunctionalTransaction +{ + private readonly ILogger _logger; + private readonly ITransactionLocatorFactory _locatorFactory; + private readonly SemaphoreSlim _mintGate = new(1, 1); + + private long _startTimestamp; + private int _stagesCompleted; + private int _stagesFailed; + private bool _opened; + private bool _completed; + + public FunctionalTransaction( + ILogger logger, + ITransactionLocatorFactory locatorFactory) + { + _logger = logger; + _locatorFactory = locatorFactory; + } + + public string? Locator { get; private set; } + + public string Name { get; private set; } = "(sin nombre)"; + + public string? Actor { get; private set; } + + public TransactionState State { get; private set; } = TransactionState.Started; + + // --- Superficie del middleware (apertura y desenlace) --- + + /// Abre la transacción: registra la apertura de la narrativa. + public void Open(string name, string? actor) + { + if (_opened) + { + return; + } + + _opened = true; + Name = string.IsNullOrWhiteSpace(name) ? Name : name; + Actor = actor; + State = TransactionState.Started; + _startTimestamp = Stopwatch.GetTimestamp(); + + var activity = Activity.Current; + activity?.SetTag(ObservabilityKeys.TransactionName, Name); + activity?.SetTag(ObservabilityKeys.TransactionActor, Actor); + activity?.SetTag(ObservabilityKeys.TransactionState, TransactionStateNames.Started); + + using (BeginNarrativeScope(TransactionStateNames.Started)) + { + _logger.LogInformation( + "Apertura de transacción funcional «{TransactionName}» por {Actor}.", + Name, + Actor ?? "(anónimo)"); + } + } + + /// Actualiza el actor una vez resuelta la autenticación. + public void SetActor(string? actor) + { + Actor = actor; + Activity.Current?.SetTag(ObservabilityKeys.TransactionActor, actor); + } + + /// + /// Cierra la transacción emitiendo el desenlace (siempre, incluso en fallo). + /// No lanza: el desenlace no debe convertirse él mismo en un fallo silencioso. + /// + public async Task CompleteAsync( + TransactionState finalState, + int statusCode, + CancellationToken cancellationToken = default) + { + if (_completed) + { + return; + } + + _completed = true; + + // Reconciliación con las etapas: un éxito nominal con etapas fallidas es parcial. + if (finalState == TransactionState.Completed && _stagesFailed > 0) + { + finalState = _stagesCompleted > 0 + ? TransactionState.PartiallyCompleted + : TransactionState.Failed; + } + + State = finalState; + + // El localizador legible se muestra al usuario ante un desenlace no exitoso. + if (IsUnsuccessful(finalState)) + { + try + { + await GetOrMintLocatorAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "No se pudo acuñar el localizador en el desenlace de la transacción."); + } + } + + var durationMs = Stopwatch.GetElapsedTime(_startTimestamp).TotalMilliseconds; + var stateName = ToStateName(finalState); + + var activity = Activity.Current; + activity?.SetTag(ObservabilityKeys.TransactionState, stateName); + activity?.SetTag(ObservabilityKeys.TransactionOutcome, true); + activity?.SetTag(ObservabilityKeys.TransactionDurationMs, durationMs); + + using (BeginDesenlaceScope(stateName, durationMs)) + { + var level = IsUnsuccessful(finalState) ? LogLevel.Warning : LogLevel.Information; + _logger.Log( + level, + "Desenlace de transacción funcional «{TransactionName}»: {TransactionOutcomeState} " + + "(HTTP {StatusCode}) en {DurationMs:F1} ms; etapas {StagesCompleted} completadas, " + + "{StagesFailed} fallidas; referencia {TransactionLocator}.", + Name, + stateName, + statusCode, + durationMs, + _stagesCompleted, + _stagesFailed, + Locator ?? "(sin localizador — identificada por traceId)"); + } + } + + // --- Puerto IFunctionalTransaction (uso desde la capa de aplicación) --- + + public async Task GetOrMintLocatorAsync(CancellationToken cancellationToken = default) + { + if (Locator is not null) + { + return Locator; + } + + await _mintGate.WaitAsync(cancellationToken); + try + { + if (Locator is null) + { + Locator = await _locatorFactory.NextAsync(cancellationToken); + Activity.Current?.SetTag(ObservabilityKeys.TransactionId, Locator); + } + } + finally + { + _mintGate.Release(); + } + + return Locator; + } + + public void RecordStage(string stage, string? detail = null, bool failed = false) + { + if (failed) + { + _stagesFailed++; + } + else + { + _stagesCompleted++; + } + + var status = failed ? "failed" : "completed"; + var activity = Activity.Current; + activity?.AddEvent(new ActivityEvent( + stage, + tags: new ActivityTagsCollection + { + [ObservabilityKeys.Stage] = stage, + [ObservabilityKeys.StageStatus] = status, + })); + + using (BeginScope(new Dictionary + { + [ObservabilityKeys.Stage] = stage, + [ObservabilityKeys.StageStatus] = status, + })) + { + _logger.Log( + failed ? LogLevel.Warning : LogLevel.Information, + "Etapa «{Stage}» {StageStatus}{StageDetail}.", + stage, + status, + string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {detail}"); + } + } + + public void RecordDecision(string decision, string reason) + { + using (BeginScope(new Dictionary + { + [ObservabilityKeys.Decision] = decision, + [ObservabilityKeys.DecisionReason] = reason, + })) + { + _logger.LogInformation( + "Decisión «{Decision}» porque {DecisionReason}.", + decision, + reason); + } + } + + public void RecordEffect( + string effectType, + string target, + string? reference, + EffectReversibility reversibility) + { + var reversibilityName = ToReversibilityName(reversibility); + + var activity = Activity.Current; + activity?.AddEvent(new ActivityEvent( + "effect", + tags: new ActivityTagsCollection + { + [ObservabilityKeys.EffectType] = effectType, + [ObservabilityKeys.EffectTarget] = target, + [ObservabilityKeys.EffectRef] = reference, + [ObservabilityKeys.EffectReversibility] = reversibilityName, + })); + + using (BeginScope(new Dictionary + { + [ObservabilityKeys.EffectType] = effectType, + [ObservabilityKeys.EffectTarget] = target, + [ObservabilityKeys.EffectRef] = reference ?? string.Empty, + [ObservabilityKeys.EffectReversibility] = reversibilityName, + })) + { + _logger.LogInformation( + "Efecto «{EffectType}» sobre {EffectTarget} (ref {EffectRef}, {EffectReversibility}).", + effectType, + target, + reference ?? "(sin ref)", + reversibilityName); + } + } + + public void MarkState(TransactionState state) + { + State = state; + Activity.Current?.SetTag(ObservabilityKeys.TransactionState, ToStateName(state)); + } + + // --- Auxiliares --- + + private IDisposable? BeginNarrativeScope(string stateName) + => BeginScope(new Dictionary + { + [ObservabilityKeys.TransactionName] = Name, + [ObservabilityKeys.TransactionState] = stateName, + [ObservabilityKeys.TransactionActor] = Actor ?? "(anónimo)", + }); + + private IDisposable? BeginDesenlaceScope(string stateName, double durationMs) + => BeginScope(new Dictionary + { + [ObservabilityKeys.TransactionName] = Name, + [ObservabilityKeys.TransactionState] = stateName, + [ObservabilityKeys.TransactionOutcome] = true, + [ObservabilityKeys.TransactionActor] = Actor ?? "(anónimo)", + [ObservabilityKeys.TransactionDurationMs] = durationMs, + [ObservabilityKeys.TransactionStagesCompleted] = _stagesCompleted, + [ObservabilityKeys.TransactionStagesFailed] = _stagesFailed, + [ObservabilityKeys.TransactionId] = Locator ?? string.Empty, + }); + + private IDisposable? BeginScope(Dictionary state) + => _logger.BeginScope(state); + + private static bool IsUnsuccessful(TransactionState state) + => state is TransactionState.Failed + or TransactionState.PartiallyCompleted + or TransactionState.TimedOut + or TransactionState.Cancelled; + + private static string ToStateName(TransactionState state) => state switch + { + TransactionState.Started => TransactionStateNames.Started, + TransactionState.InProgress => TransactionStateNames.InProgress, + TransactionState.Waiting => TransactionStateNames.Waiting, + TransactionState.Retrying => TransactionStateNames.Retrying, + TransactionState.Completed => TransactionStateNames.Completed, + TransactionState.PartiallyCompleted => TransactionStateNames.PartiallyCompleted, + TransactionState.Failed => TransactionStateNames.Failed, + TransactionState.Cancelled => TransactionStateNames.Cancelled, + TransactionState.TimedOut => TransactionStateNames.TimedOut, + _ => TransactionStateNames.Failed, + }; + + private static string ToReversibilityName(EffectReversibility reversibility) => reversibility switch + { + EffectReversibility.Compensated => "compensado", + EffectReversibility.PendingCompensation => "pendiente-de-compensar", + EffectReversibility.Irreversible => "irreversible", + _ => "pendiente-de-compensar", + }; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Observability/ObservabilityConstants.cs b/src/apps/ums.api/Ums.Infrastructure/Observability/ObservabilityConstants.cs new file mode 100644 index 00000000..6da2e582 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Observability/ObservabilityConstants.cs @@ -0,0 +1,100 @@ +namespace Ums.Infrastructure.Observability; + +/// +/// Constantes de observabilidad propias de UMS (ADR-0046). +/// +/// Sustituyen al andamiaje que se vendorizó bajo el namespace de los shells +/// (BeyondNetCode.Shell.Aop.Aspects.Logger.Serilog, gap G-015): ese shim se +/// retiró al unificar la correlación en W3C TraceContext. La correlación deja de +/// generarse a mano (ya no hay X-Correlation-Id): el trace_id/span_id +/// se derivan de / traceparent. +/// +/// Se conserva únicamente el SessionTrackingId, que es un identificador de +/// producto de UMS (rastreo de sesión de negocio) y no forma parte del estándar W3C. +/// +public static class ObservabilityHeaders +{ + /// Cabecera HTTP del identificador de rastreo de sesión de negocio de UMS. + public const string SessionTrackingId = "X-Session-Tracking-Id"; +} + +/// Claves de baggage/etiquetas de la propias de UMS. +public static class ObservabilityKeys +{ + /// Clave de baggage/etiqueta para el identificador de rastreo de sesión. + /// ADR-0096 §2.1 regla 1: los atributos propios viven bajo el prefijo evolith.. + public const string SessionTrackingId = "evolith.session.tracking_id"; + + // --- Vocabulario de trazabilidad funcional (ADR-0096 §2.2–§2.4) --- + // Todos bajo el prefijo canónico `evolith.`. Son a la vez etiquetas de span (Tempo) + // y propiedades de log estructurado (Loki), lo que habilita la búsqueda sin ID + // (ADR-UMS-085): consulta del store de observabilidad por estos atributos. + + /// Localizador legible de la transacción funcional (TX-AAAA-NNNNNN). + public const string TransactionId = "evolith.transaction.id"; + + /// Nombre funcional de la transacción (acción del actor). + public const string TransactionName = "evolith.transaction.name"; + + /// Localizador de la transacción padre, si esta es hija de otra. + public const string TransactionParentId = "evolith.transaction.parent_id"; + + /// Estado de la transacción (valores de ). + public const string TransactionState = "evolith.transaction.state"; + + /// Actor que originó la transacción. + public const string TransactionActor = "evolith.transaction.actor"; + + /// Duración total de la transacción en milisegundos (en el desenlace). + public const string TransactionDurationMs = "evolith.transaction.duration_ms"; + + /// Etapas completadas al momento del desenlace. + public const string TransactionStagesCompleted = "evolith.transaction.stages_completed"; + + /// Etapas fallidas al momento del desenlace. + public const string TransactionStagesFailed = "evolith.transaction.stages_failed"; + + /// Marca el evento de log/span como el desenlace de la transacción. + public const string TransactionOutcome = "evolith.transaction.outcome"; + + /// Nombre de la etapa de negocio en curso. + public const string Stage = "evolith.stage"; + + /// Estado de la etapa (completed | failed). + public const string StageStatus = "evolith.stage.status"; + + /// Decisión de negocio tomada. + public const string Decision = "evolith.decision"; + + /// El porqué de la decisión. + public const string DecisionReason = "evolith.decision.reason"; + + /// Tipo de efecto sobre el mundo externo (p. ej. message.publish). + public const string EffectType = "evolith.effect.type"; + + /// Objetivo del efecto (recurso afectado). + public const string EffectTarget = "evolith.effect.target"; + + /// Referencia del efecto (id, clave o identificador del resultado). + public const string EffectRef = "evolith.effect.ref"; + + /// Reversibilidad del efecto (compensado | pendiente-de-compensar | irreversible). + public const string EffectReversibility = "evolith.effect.reversibility"; +} + +/// +/// Nombres canónicos de los estados de transacción (ADR-0095, referidos por ADR-0096 §2.3). +/// Son los valores que viajan en la etiqueta . +/// +public static class TransactionStateNames +{ + public const string Started = "STARTED"; + public const string InProgress = "IN_PROGRESS"; + public const string Waiting = "WAITING"; + public const string Retrying = "RETRYING"; + public const string Completed = "COMPLETED"; + public const string PartiallyCompleted = "PARTIALLY_COMPLETED"; + public const string Failed = "FAILED"; + public const string Cancelled = "CANCELLED"; + public const string TimedOut = "TIMED_OUT"; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Observability/RequestContext.cs b/src/apps/ums.api/Ums.Infrastructure/Observability/RequestContext.cs new file mode 100644 index 00000000..b92d97e3 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Observability/RequestContext.cs @@ -0,0 +1,59 @@ +namespace Ums.Infrastructure.Observability; + +using System.Diagnostics; + +/// +/// Contexto de ejecución de la petición (scoped). Expone a la capa de aplicación los +/// datos de correlación W3C y el identificador de rastreo de sesión de UMS. +/// +/// Reemplaza a RequestContextAccessor + ExecutionContextSnapshot + +/// IExecutionContextAccessor, andamiaje que se vendorizó bajo el namespace de +/// los shells (gap G-015) y se retiró al unificar la correlación en W3C TraceContext +/// (ADR-0046): +/// +/// TraceId / SpanId — derivados de +/// (contexto de traza W3C propagado por traceparent). Ya no se calculan a mano. +/// CorrelationId — es el trace_id W3C: la correlación deja de +/// generarse como un GUID propio (X-Correlation-Id). +/// SessionTrackingId — identificador de rastreo de sesión de negocio +/// de UMS, ajeno al estándar W3C; lo fija SessionTrackingMiddleware. +/// ClientTimezone — zona horaria IANA del cliente (ADR-0076 D2); +/// la fija CultureMiddleware. +/// +/// +public sealed class RequestContext : IRequestContext +{ + private string? _sessionTrackingId; + private string? _clientTimezone; + + public string? SessionTrackingId => _sessionTrackingId; + + /// Unificación W3C (ADR-0046): la correlación es el trace_id de traceparent. + public string? CorrelationId => TraceId; + + public string? TraceId + { + get + { + var activity = Activity.Current; + return activity is { IdFormat: ActivityIdFormat.W3C } ? activity.TraceId.ToString() : null; + } + } + + public string? SpanId + { + get + { + var activity = Activity.Current; + return activity is { IdFormat: ActivityIdFormat.W3C } ? activity.SpanId.ToString() : null; + } + } + + public string? ClientTimezone => _clientTimezone; + + public void SetSessionTrackingId(string? sessionTrackingId) + => _sessionTrackingId = string.IsNullOrWhiteSpace(sessionTrackingId) ? null : sessionTrackingId; + + public void SetClientTimezone(string? timezone) + => _clientTimezone = string.IsNullOrWhiteSpace(timezone) ? null : timezone; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Observability/TransactionLocatorFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Observability/TransactionLocatorFactory.cs new file mode 100644 index 00000000..f3721aac --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Observability/TransactionLocatorFactory.cs @@ -0,0 +1,87 @@ +namespace Ums.Infrastructure.Observability; + +using System.Collections.Concurrent; +using System.Data; +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Ums.Application.Common.Interfaces; +using Ums.Infrastructure.Persistence; + +/// +/// Acuña el localizador legible TX-AAAA-NNNNNN con una secuencia PostgreSQL por +/// año (ADR-UMS-084). La secuencia se crea de forma perezosa e idempotente +/// (CREATE SEQUENCE IF NOT EXISTS) y reinicia al cambiar de año, porque cada año +/// tiene su propia secuencia (tx_locator_AAAA). Las secuencias de PostgreSQL son +/// no transaccionales: nextval persiste aunque la transacción de la petición +/// se revierta, lo que da un localizador monotónico sin huecos reutilizables. +/// +/// Fuera de un proveedor relacional (p. ej. el proveedor InMemory de las pruebas) se cae a +/// un contador en memoria del proceso, de modo que el formato del localizador se mantiene +/// verificable sin una base de datos real. +/// +public sealed class TransactionLocatorFactory : ITransactionLocatorFactory +{ + private static readonly ConcurrentDictionary InMemoryCounters = new(); + + private readonly UmsPlatformDbContext _dbContext; + + public TransactionLocatorFactory(UmsPlatformDbContext dbContext) + => _dbContext = dbContext; + + public async Task NextAsync(CancellationToken cancellationToken = default) + { + var year = DateTimeOffset.UtcNow.Year; + var next = _dbContext.Database.IsRelational() + ? await NextFromSequenceAsync(year, cancellationToken) + : InMemoryCounters.AddOrUpdate(year, 1, static (_, current) => current + 1); + + return Format(year, next); + } + + internal static string Format(int year, long sequence) + => string.Create(CultureInfo.InvariantCulture, $"TX-{year:D4}-{sequence:D6}"); + + private async Task NextFromSequenceAsync(int year, CancellationToken cancellationToken) + { + // El nombre de la secuencia se compone de un entero validado (el año), no de + // entrada del usuario: no hay superficie de inyección. + var sequenceName = $"\"{UmsPlatformDbContext.DefaultSchema}\".\"tx_locator_{year}\""; + + var connection = _dbContext.Database.GetDbConnection(); + var mustClose = connection.State != ConnectionState.Open; + if (mustClose) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + await using var command = connection.CreateCommand(); + + var currentTransaction = _dbContext.Database.CurrentTransaction; + if (currentTransaction is not null) + { + command.Transaction = currentTransaction.GetDbTransaction(); + } + + // S2077: el identificador de secuencia NO es parametrizable en SQL (los nombres de objeto no + // admiten binding). sequenceName se compone de un entero validado (el año) y un esquema constante, + // no de entrada del usuario (ver comentario superior): no hay superficie de inyección. +#pragma warning disable S2077 + command.CommandText = + $"CREATE SEQUENCE IF NOT EXISTS {sequenceName}; SELECT nextval('{sequenceName}');"; +#pragma warning restore S2077 + + var scalar = await command.ExecuteScalarAsync(cancellationToken); + return Convert.ToInt64(scalar, CultureInfo.InvariantCulture); + } + finally + { + if (mustClose) + { + await connection.CloseAsync(); + } + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/Entities/AccessEnforcementPolicyRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/Entities/AccessEnforcementPolicyRecord.cs index b4bda599..e1c34575 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/Entities/AccessEnforcementPolicyRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/Entities/AccessEnforcementPolicyRecord.cs @@ -11,6 +11,7 @@ public sealed class AccessEnforcementPolicyRecord : IAuditableRecord public Guid? RoleId { get; set; } public int EnforcementActionId { get; set; } public bool IsActive { get; set; } + public int GracePeriodDays { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public string? UpdatedBy { get; set; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/PostgreSqlAccessEnforcementPolicyRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/PostgreSqlAccessEnforcementPolicyRepository.cs index 6963d148..46bce43d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/PostgreSqlAccessEnforcementPolicyRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/PostgreSqlAccessEnforcementPolicyRepository.cs @@ -117,6 +117,7 @@ private static AccessEnforcementPolicyRecord ToRecord(AccessEnforcementPolicyAgg RoleId = aggregate.RoleId?.GetValue(), EnforcementActionId = aggregate.EnforcementAction.Id, IsActive = aggregate.IsActive, + GracePeriodDays = aggregate.GracePeriodDays, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -134,6 +135,7 @@ private static void Apply(AccessEnforcementPolicyRecord target, AccessEnforcemen target.RoleId = replacement.RoleId; target.EnforcementActionId = replacement.EnforcementActionId; target.IsActive = replacement.IsActive; + target.GracePeriodDays = replacement.GracePeriodDays; target.CreatedBy = replacement.CreatedBy; target.CreatedAtUtc = replacement.CreatedAtUtc; target.UpdatedBy = replacement.UpdatedBy; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/PostgreSqlAuditRecordRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/PostgreSqlAuditRecordRepository.cs index 7a7b4336..61049374 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/PostgreSqlAuditRecordRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/PostgreSqlAuditRecordRepository.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Ums.Application.Common.Interfaces; using Ums.Domain.Audit.AuditRecord; using Ums.Infrastructure.Persistence.Audit.Entities; using Ums.Infrastructure.Persistence.Reflection; @@ -16,19 +17,40 @@ namespace Ums.Infrastructure.Persistence.Audit; public sealed class PostgreSqlAuditRecordRepository : IAuditRecordRepository, BeyondNetCode.Shell.Ddd.Interfaces.IUnitOfWork { private readonly UmsPlatformDbContext _dbContext; + private readonly ITenantContext _tenantContext; private readonly HashSet _trackedAggregates = []; - public PostgreSqlAuditRecordRepository(UmsPlatformDbContext dbContext) + public PostgreSqlAuditRecordRepository(UmsPlatformDbContext dbContext, ITenantContext tenantContext) { _dbContext = dbContext; + _tenantContext = tenantContext; } BeyondNetCode.Shell.Ddd.Interfaces.IUnitOfWork IRepository.UnitOfWork => this; public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var record = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + // G-103 — Aislamiento por inquilino en la lectura por Id. Sin este filtro, un actor podía + // leer la traza de OTRO inquilino conociendo su Id (fuga cross-tenant). La tabla de + // auditoría NO lleva query filter global de EF a propósito: rompería la lectura cross-tenant + // legítima del admin de plataforma (GetAllAuditRecordsQueryHandler fija + // effectiveTenantId = IsInternalAdmin ? request.TenantId : OrganizationId, que para el admin + // difiere de OrganizationId) y las QueryBy* que reciben el inquilino de forma explícita. Por + // eso el aislamiento se aplica aquí, en la capa de aplicación, igual que hacen las QueryBy*. + // + // Bypass legítimo (misma semántica que effectiveTenantId del handler y el convenio FIX-05): + // - IsInternalAdmin → admin de plataforma con vista cross-tenant. + // - OrganizationId nulo → contexto de sistema/segundo plano (sembrado, outbox). + // En cualquier otro caso se restringe a la traza del inquilino del contexto. + var query = _dbContext.Set().Where(x => x.Id == id); + + var organizationId = _tenantContext.OrganizationId; + if (!_tenantContext.IsInternalAdmin && organizationId.HasValue) + { + query = query.Where(x => x.RootTenantId == organizationId.Value); + } + + var record = await query.FirstOrDefaultAsync(cancellationToken); return record is null ? null : Rehydrate(record); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/PermissionTemplateItemRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/PermissionTemplateItemRecordConfiguration.cs index 83591b0c..2419643c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/PermissionTemplateItemRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/PermissionTemplateItemRecordConfiguration.cs @@ -16,5 +16,11 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.HasIndex(x => new { x.TemplateId, x.TargetTypeId, x.TargetId, x.ActionId }).IsUnique(); + + // El índice único empieza por TemplateId, así que no sirve para preguntar «qué plantillas + // tocan este destino», que es lo que hacen las guardas de dependencia antes de borrar una + // opción o un recurso. Sin él, esa comprobación recorre la tabla entera. + builder.HasIndex(x => new { x.TargetId, x.IsActive }) + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfilePermissionRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfilePermissionRecordConfiguration.cs index c5a1c042..bee8e531 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfilePermissionRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfilePermissionRecordConfiguration.cs @@ -17,5 +17,10 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.ProfileId); builder.HasIndex(x => new { x.ProfileId, x.TemplateId, x.ActionId, x.TargetId }); + + // El compuesto anterior lidera por ProfileId: no cubre «cuántos perfiles usan esta + // plantilla», la guarda que corre antes de despublicar o borrar una plantilla. + builder.HasIndex(x => x.TemplateId) + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfileRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfileRecordConfiguration.cs index dd73429d..2953997d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfileRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfileRecordConfiguration.cs @@ -17,7 +17,13 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.RowVersion).IsRowVersion(); // FIX-03: optimistic concurrency builder.HasIndex(x => x.TenantId); - builder.HasIndex(x => x.UserId); + builder.HasIndex(x => x.UserId, "IX_Profiles_UserId"); + + // Índice PARCIAL: el login pregunta siempre por los perfiles ACTIVOS de un usuario. Al + // filtrar el índice por esa condición, las filas inactivas —que solo crecen— no ocupan + // espacio en él ni ensucian el plan. + builder.HasIndex(x => x.UserId, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId, x.BranchId }); builder.HasMany(x => x.Permissions) diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteAppSettingRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteAppSettingRecordConfiguration.cs index be0bbf0d..dd368649 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteAppSettingRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteAppSettingRecordConfiguration.cs @@ -14,6 +14,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ConfigKey).HasMaxLength(100).IsRequired(); builder.Property(x => x.ConfigValue).HasMaxLength(4000).IsRequired(); + // G-178: fail-closed también en la base. Las filas que ya existan quedan en `false`, que + // es lo correcto: nadie decidió publicarlas. + builder.Property(x => x.IsClientVisible).IsRequired().HasDefaultValue(false); + builder.HasIndex(x => new { x.SystemSuiteId, x.ConfigKey, x.ScopeId }).IsUnique(); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteDomainResourceRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteDomainResourceRecordConfiguration.cs index 96a0fb73..826c2d1c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteDomainResourceRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteDomainResourceRecordConfiguration.cs @@ -20,6 +20,11 @@ public void Configure(EntityTypeBuilder builder builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + builder.HasIndex(x => x.ModuleId) + + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + builder.HasIndex(x => new { x.SystemSuiteId, x.Code }).IsUnique(); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteMenuRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteMenuRecordConfiguration.cs deleted file mode 100644 index 52b8fce9..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteMenuRecordConfiguration.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Ums.Infrastructure.Persistence.Authorization.Entities; - -namespace Ums.Infrastructure.Persistence.Authorization.Configurations; - -public sealed class SystemSuiteMenuRecordConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("SystemSuiteMenus", AuthorizationPersistenceConstants.Schema); - builder.HasKey(x => x.Id); - - builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); - builder.Property(x => x.Label).HasMaxLength(200).IsRequired(); - builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); - builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); - builder.Property(x => x.UpdatedBy).HasMaxLength(100); - builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - - builder.HasIndex(x => new { x.ModuleId, x.Code }).IsUnique(); - - builder.HasMany(x => x.SubMenus) - .WithOne(x => x.Menu) - .HasForeignKey(x => x.MenuId) - .OnDelete(DeleteBehavior.Cascade); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteModuleRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteModuleRecordConfiguration.cs index aae40a6d..d4d957a1 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteModuleRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteModuleRecordConfiguration.cs @@ -14,15 +14,11 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); builder.Property(x => x.Name).HasMaxLength(200).IsRequired(); builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); + builder.Property(x => x.Icon).HasMaxLength(64); builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.HasIndex(x => new { x.SystemSuiteId, x.Code }).IsUnique(); - - builder.HasMany(x => x.Menus) - .WithOne(x => x.Module) - .HasForeignKey(x => x.ModuleId) - .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeActionRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeActionRecordConfiguration.cs new file mode 100644 index 00000000..1bf5da15 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeActionRecordConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Authorization.Entities; + +namespace Ums.Infrastructure.Persistence.Authorization.Configurations; + +public sealed class SystemSuiteNodeActionRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SystemSuiteNodeActions", AuthorizationPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.ActionCode).HasMaxLength(100).IsRequired(); + + // Una funcionalidad no se vincula dos veces al mismo nodo. + builder.HasIndex(x => new { x.NodeId, x.ActionCode }).IsUnique(); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeRecordConfiguration.cs new file mode 100644 index 00000000..372d24fe --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeRecordConfiguration.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Authorization.Entities; + +namespace Ums.Infrastructure.Persistence.Authorization.Configurations; + +public sealed class SystemSuiteNodeRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SystemSuiteNodes", AuthorizationPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); + builder.Property(x => x.Label).HasMaxLength(200).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); + builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); + builder.Property(x => x.UpdatedBy).HasMaxLength(100); + builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + + // Metadatos SDLC opcionales + builder.Property(x => x.Responsable).HasMaxLength(200); + builder.Property(x => x.Criticidad).HasMaxLength(50); + builder.Property(x => x.ProductoImpactado).HasMaxLength(200); + builder.Property(x => x.ComponenteTecnico).HasMaxLength(200); + builder.Property(x => x.Dependencias).HasMaxLength(2000); + builder.Property(x => x.Evidencias).HasMaxLength(2000); + builder.Property(x => x.TrazabilidadSdlc).HasMaxLength(2000); + + // Código único entre hermanos (por módulo + padre). En Postgres los NULL + // de ParentNodeId son distintos, así que la unicidad de raíces la garantiza + // adicionalmente el dominio; el índice cubre consultas y ramas no-raíz. + builder.Property(x => x.Icon).HasMaxLength(64); + builder.Property(x => x.Route).HasMaxLength(400); + + builder.HasIndex(x => new { x.ModuleId, x.ParentNodeId, x.Code }).IsUnique(); + + builder.HasOne(x => x.Module) + .WithMany(m => m.Nodes) + .HasForeignKey(x => x.ModuleId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(x => x.Parent) + .WithMany(p => p.Children) + .HasForeignKey(x => x.ParentNodeId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(x => x.Actions) + .WithOne(a => a.Node) + .HasForeignKey(a => a.NodeId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteOptionRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteOptionRecordConfiguration.cs deleted file mode 100644 index 6f39071c..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteOptionRecordConfiguration.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Ums.Infrastructure.Persistence.Authorization.Entities; - -namespace Ums.Infrastructure.Persistence.Authorization.Configurations; - -public sealed class SystemSuiteOptionRecordConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("SystemSuiteOptions", AuthorizationPersistenceConstants.Schema); - builder.HasKey(x => x.Id); - - builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); - builder.Property(x => x.Label).HasMaxLength(200).IsRequired(); - builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); - builder.Property(x => x.ActionCode).HasMaxLength(100).IsRequired(); - builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); - builder.Property(x => x.UpdatedBy).HasMaxLength(100); - builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - - builder.HasIndex(x => new { x.SubMenuId, x.Code }).IsUnique(); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteSubMenuRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteSubMenuRecordConfiguration.cs deleted file mode 100644 index cfbb35c8..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteSubMenuRecordConfiguration.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Ums.Infrastructure.Persistence.Authorization.Entities; - -namespace Ums.Infrastructure.Persistence.Authorization.Configurations; - -public sealed class SystemSuiteSubMenuRecordConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("SystemSuiteSubMenus", AuthorizationPersistenceConstants.Schema); - builder.HasKey(x => x.Id); - - builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); - builder.Property(x => x.Label).HasMaxLength(200).IsRequired(); - builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); - builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); - builder.Property(x => x.UpdatedBy).HasMaxLength(100); - builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - - builder.HasIndex(x => new { x.MenuId, x.Code }).IsUnique(); - - builder.HasMany(x => x.Options) - .WithOne(x => x.SubMenu) - .HasForeignKey(x => x.SubMenuId) - .OnDelete(DeleteBehavior.Cascade); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteAppSettingRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteAppSettingRecord.cs index 071adc06..1fa67f07 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteAppSettingRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteAppSettingRecord.cs @@ -8,5 +8,8 @@ public sealed class SystemSuiteAppSettingRecord public string ConfigValue { get; set; } = string.Empty; public int ScopeId { get; set; } + /// Marca de exposición al cliente (G-178). Falso por defecto: fail-closed. + public bool IsClientVisible { get; set; } + public SystemSuiteRecord SystemSuite { get; set; } = null!; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteMenuRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteMenuRecord.cs deleted file mode 100644 index a1495a82..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteMenuRecord.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ums.Infrastructure.Persistence.Authorization.Entities; - -public sealed class SystemSuiteMenuRecord : IAuditableRecord -{ - public Guid Id { get; set; } - public Guid ModuleId { get; set; } - public string Code { get; set; } = string.Empty; - public string Label { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public int SortOrder { get; set; } - public string CreatedBy { get; set; } = string.Empty; - public DateTime CreatedAtUtc { get; set; } - public string? UpdatedBy { get; set; } - public DateTime? UpdatedAtUtc { get; set; } - public string AuditTimeSpan { get; set; } = string.Empty; - - public SystemSuiteModuleRecord Module { get; set; } = null!; - public List SubMenus { get; set; } = []; -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteModuleRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteModuleRecord.cs index f6d24e2d..775850d2 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteModuleRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteModuleRecord.cs @@ -9,6 +9,7 @@ public sealed class SystemSuiteModuleRecord : IAuditableRecord public string Description { get; set; } = string.Empty; public int StatusId { get; set; } public int SortOrder { get; set; } + public string? Icon { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public string? UpdatedBy { get; set; } @@ -16,5 +17,5 @@ public sealed class SystemSuiteModuleRecord : IAuditableRecord public string AuditTimeSpan { get; set; } = string.Empty; public SystemSuiteRecord SystemSuite { get; set; } = null!; - public List Menus { get; set; } = []; + public List Nodes { get; set; } = []; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeActionRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeActionRecord.cs new file mode 100644 index 00000000..5f979337 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeActionRecord.cs @@ -0,0 +1,15 @@ +namespace Ums.Infrastructure.Persistence.Authorization.Entities; + +/// +/// Puente N:M entre un nodo hoja (Opción) y una funcionalidad del catálogo +/// (`SystemSuiteActions.Code`) — ADR-0090. Una opción puede vincular varias +/// funcionalidades y una funcionalidad puede estar en varias opciones. +/// +public sealed class SystemSuiteNodeActionRecord +{ + public Guid Id { get; set; } + public Guid NodeId { get; set; } + public string ActionCode { get; set; } = string.Empty; + + public SystemSuiteNodeRecord Node { get; set; } = null!; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeRecord.cs new file mode 100644 index 00000000..364ea698 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeRecord.cs @@ -0,0 +1,45 @@ +namespace Ums.Infrastructure.Persistence.Authorization.Entities; + +/// +/// Nodo del árbol de navegación recursivo (ADR-0090). Adjacency list: +/// nulo ⇒ hijo directo del módulo. Reemplazará a +/// SystemSuiteMenus/SubMenus/Options (convivencia aditiva durante la migración). +/// +public sealed class SystemSuiteNodeRecord : IAuditableRecord +{ + public Guid Id { get; set; } + public Guid ModuleId { get; set; } + public Guid? ParentNodeId { get; set; } + public int NodeKindId { get; set; } + public string Code { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int StatusId { get; set; } + public int SortOrder { get; set; } + + /// Presentación (G-178): identificador de icono y ruta destino. Opcionales. + public string? Icon { get; set; } + public string? Route { get; set; } + + // ── Metadatos de gobernanza SDLC (opcionales) ── + public string? Responsable { get; set; } + public string? Criticidad { get; set; } + public string? ProductoImpactado { get; set; } + public string? ComponenteTecnico { get; set; } + public string? Dependencias { get; set; } + public string? Evidencias { get; set; } + public string? TrazabilidadSdlc { get; set; } + + // ── Auditoría ── + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public string? UpdatedBy { get; set; } + public DateTime? UpdatedAtUtc { get; set; } + public string AuditTimeSpan { get; set; } = string.Empty; + + // ── Navegaciones ── + public SystemSuiteModuleRecord Module { get; set; } = null!; + public SystemSuiteNodeRecord? Parent { get; set; } + public List Children { get; set; } = []; + public List Actions { get; set; } = []; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteOptionRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteOptionRecord.cs deleted file mode 100644 index 773767d9..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteOptionRecord.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ums.Infrastructure.Persistence.Authorization.Entities; - -public sealed class SystemSuiteOptionRecord : IAuditableRecord -{ - public Guid Id { get; set; } - public Guid SubMenuId { get; set; } - public string Code { get; set; } = string.Empty; - public string Label { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string ActionCode { get; set; } = string.Empty; - public int SortOrder { get; set; } - public string CreatedBy { get; set; } = string.Empty; - public DateTime CreatedAtUtc { get; set; } - public string? UpdatedBy { get; set; } - public DateTime? UpdatedAtUtc { get; set; } - public string AuditTimeSpan { get; set; } = string.Empty; - - public SystemSuiteSubMenuRecord SubMenu { get; set; } = null!; -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteSubMenuRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteSubMenuRecord.cs deleted file mode 100644 index 0e8b007d..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteSubMenuRecord.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ums.Infrastructure.Persistence.Authorization.Entities; - -public sealed class SystemSuiteSubMenuRecord : IAuditableRecord -{ - public Guid Id { get; set; } - public Guid MenuId { get; set; } - public string Code { get; set; } = string.Empty; - public string Label { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public int SortOrder { get; set; } - public string CreatedBy { get; set; } = string.Empty; - public DateTime CreatedAtUtc { get; set; } - public string? UpdatedBy { get; set; } - public DateTime? UpdatedAtUtc { get; set; } - public string AuditTimeSpan { get; set; } = string.Empty; - - public SystemSuiteMenuRecord Menu { get; set; } = null!; - public List Options { get; set; } = []; -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileCsvExporter.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileCsvExporter.cs index 12445edf..46c2f874 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileCsvExporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileCsvExporter.cs @@ -29,7 +29,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu { var targetNameEscaped = p.TargetName.Replace("\"", "\"\""); var actionNameEscaped = p.ActionName.Replace("\"", "\"\""); - var effect = p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"); + var effect = ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied); sb.AppendLine($"{p.TargetType},\"{targetNameEscaped}\",\"{actionNameEscaped}\",{effect},{p.IsActive},{p.IsOverride}"); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileExporterBase.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileExporterBase.cs index 616612cd..8892868d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileExporterBase.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileExporterBase.cs @@ -5,26 +5,44 @@ namespace Ums.Infrastructure.Persistence.Authorization.Exporters; public abstract class ProfileExporterBase { + public static string GetEffect(bool isAllowed, bool isDenied) + { + if (isAllowed) return "Allow"; + if (isDenied) return "Deny"; + return "Neutral"; + } + + public static string FormatId(Guid id, ExportConfiguration config) + { + return config.MaskGuids ? MaskGuid(id) : id.ToString(); + } + + public static string? FormatId(Guid? id, ExportConfiguration config) + { + if (!id.HasValue) return null; + return config.MaskGuids ? MaskGuid(id.Value) : id.Value.ToString(); + } + protected static object MapPermission(ProfilePermissionDto p, ExportConfiguration config) { return new { - id = config.MaskGuids ? MaskGuid(p.PermissionId) : p.PermissionId.ToString(), - targetId = config.MaskGuids ? MaskGuid(p.TargetId) : p.TargetId.ToString(), + id = FormatId(p.PermissionId, config), + targetId = FormatId(p.TargetId, config), targetType = p.TargetType, targetName = p.TargetName, - actionId = config.MaskGuids ? MaskGuid(p.ActionId) : p.ActionId.ToString(), + actionId = FormatId(p.ActionId, config), actionName = p.ActionName, - effect = p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"), + effect = GetEffect(p.IsAllowed, p.IsDenied), isActive = p.IsActive, isOverride = p.IsOverride, originalFromTemplate = p.OriginalFromTemplate != null ? new { - id = config.MaskGuids ? MaskGuid(p.OriginalFromTemplate.ItemId) : p.OriginalFromTemplate.ItemId.ToString(), - targetId = config.MaskGuids ? MaskGuid(p.OriginalFromTemplate.TargetId) : p.OriginalFromTemplate.TargetId.ToString(), + id = FormatId(p.OriginalFromTemplate.ItemId, config), + targetId = FormatId(p.OriginalFromTemplate.TargetId, config), targetType = p.OriginalFromTemplate.TargetType, targetName = p.OriginalFromTemplate.TargetName, - effect = p.OriginalFromTemplate.IsAllowed ? "Allow" : (p.OriginalFromTemplate.IsDenied ? "Deny" : "Neutral"), + effect = GetEffect(p.OriginalFromTemplate.IsAllowed, p.OriginalFromTemplate.IsDenied), isActive = p.OriginalFromTemplate.IsActive } : null }; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileJsonExporter.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileJsonExporter.cs index f4f2b86a..2b38aabb 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileJsonExporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileJsonExporter.cs @@ -28,7 +28,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu .Select(p => MapPermission(p, config)) .ToList(); - var effectivePermissionsSummary = BuildEffectivePermissionsSummary(profile, config); + var effectivePermissionsSummary = BuildEffectivePermissionsSummary(profile); object result; @@ -63,9 +63,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu name = profile.RoleName, status = profile.IsActive ? "Active" : "Inactive", roleLevel = profile.Scope, - permissionTemplateId = profile.Permissions.FirstOrDefault()?.TemplateId != null - ? (config.MaskGuids ? MaskGuid(profile.Permissions.First().TemplateId) : profile.Permissions.First().TemplateId.ToString()) - : null, + permissionTemplateId = ProfileExporterBase.FormatId(profile.Permissions.FirstOrDefault()?.TemplateId, config), permissionCount = profile.PermissionCount }, authorizationGraph = new @@ -125,22 +123,22 @@ private static object MapPermission(ProfilePermissionDto p, ExportConfiguration targetName = p.TargetName, actionId = config.MaskGuids ? MaskGuid(p.ActionId) : p.ActionId.ToString(), actionName = p.ActionName, - effect = p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"), + effect = ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied), isActive = p.IsActive, isOverride = p.IsOverride, originalFromTemplate = p.OriginalFromTemplate != null ? new { - id = config.MaskGuids ? MaskGuid(p.OriginalFromTemplate.ItemId) : p.OriginalFromTemplate.ItemId.ToString(), - targetId = config.MaskGuids ? MaskGuid(p.OriginalFromTemplate.TargetId) : p.OriginalFromTemplate.TargetId.ToString(), + id = ProfileExporterBase.FormatId(p.OriginalFromTemplate.ItemId, config), + targetId = ProfileExporterBase.FormatId(p.OriginalFromTemplate.TargetId, config), targetType = p.OriginalFromTemplate.TargetType, targetName = p.OriginalFromTemplate.TargetName, - effect = p.OriginalFromTemplate.IsAllowed ? "Allow" : (p.OriginalFromTemplate.IsDenied ? "Deny" : "Neutral"), + effect = ProfileExporterBase.GetEffect(p.OriginalFromTemplate.IsAllowed, p.OriginalFromTemplate.IsDenied), isActive = p.OriginalFromTemplate.IsActive } : null }; } - private static object BuildEffectivePermissionsSummary(ProfileDto profile, ExportConfiguration config) + private static object BuildEffectivePermissionsSummary(ProfileDto profile) { var totalPermissions = profile.Permissions.Count; var allowedPermissions = profile.Permissions.Count(p => p.IsAllowed); @@ -159,5 +157,9 @@ private static object BuildEffectivePermissionsSummary(ProfileDto profile, Expor }; } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "Enmascaramiento PII: descarta el valor a propósito y devuelve una máscara fija. " + + "El parámetro conserva la firma de una función de enmascarado.")] private static string MaskGuid(Guid guid) => "****-****-****-****"; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileXmlExporter.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileXmlExporter.cs index 6d1c48f3..f7301f52 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileXmlExporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileXmlExporter.cs @@ -21,7 +21,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu new XElement("TargetType", p.TargetType), new XElement("TargetName", p.TargetName), new XElement("ActionName", p.ActionName), - new XElement("Effect", p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral")), + new XElement("Effect", ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied)), new XElement("IsActive", p.IsActive), new XElement("IsOverride", p.IsOverride) ); @@ -61,7 +61,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu modulesElement.Add(new XElement("Module", new XElement("TargetName", p.TargetName), new XElement("ActionName", p.ActionName), - new XElement("Effect", p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral")))); + new XElement("Effect", ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied)))); } graphElement.Add(modulesElement); @@ -71,7 +71,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu domainElement.Add(new XElement("Resource", new XElement("TargetName", p.TargetName), new XElement("ActionName", p.ActionName), - new XElement("Effect", p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral")))); + new XElement("Effect", ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied)))); } graphElement.Add(domainElement); @@ -81,7 +81,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu actionsElement.Add(new XElement("Action", new XElement("TargetName", p.TargetName), new XElement("ActionName", p.ActionName), - new XElement("Effect", p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral")))); + new XElement("Effect", ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied)))); } graphElement.Add(actionsElement); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileYamlExporter.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileYamlExporter.cs index d2684500..7ab631f4 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileYamlExporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileYamlExporter.cs @@ -64,7 +64,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu sb.AppendLine($" targetType: {p.TargetType}"); sb.AppendLine($" targetName: {p.TargetName}"); sb.AppendLine($" actionName: {p.ActionName}"); - sb.AppendLine($" effect: {(p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"))}"); + sb.AppendLine($" effect: {(ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied))}"); sb.AppendLine($" isActive: {p.IsActive}"); sb.AppendLine($" isOverride: {p.IsOverride}"); } @@ -83,7 +83,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu sb.AppendLine($" targetType: {p.TargetType}"); sb.AppendLine($" targetName: {p.TargetName}"); sb.AppendLine($" actionName: {p.ActionName}"); - sb.AppendLine($" effect: {(p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"))}"); + sb.AppendLine($" effect: {(ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied))}"); sb.AppendLine($" isActive: {p.IsActive}"); sb.AppendLine($" isOverride: {p.IsOverride}"); } @@ -102,7 +102,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu sb.AppendLine($" targetType: {p.TargetType}"); sb.AppendLine($" targetName: {p.TargetName}"); sb.AppendLine($" actionName: {p.ActionName}"); - sb.AppendLine($" effect: {(p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"))}"); + sb.AppendLine($" effect: {(ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied))}"); sb.AppendLine($" isActive: {p.IsActive}"); sb.AppendLine($" isOverride: {p.IsOverride}"); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlPermissionTemplateRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlPermissionTemplateRepository.cs index 515320e4..4535f471 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlPermissionTemplateRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlPermissionTemplateRepository.cs @@ -13,6 +13,13 @@ public sealed class PostgreSqlPermissionTemplateRepository(UmsPlatformDbContext { private readonly HashSet _trackedAggregates = []; + /// + /// Id del estado terminal de borrado lógico (TemplateStatus.Deleted). Se materializa como + /// constante entera porque el filtro tiene que traducirse a SQL: EF no sabe evaluar la comparación + /// de un DomainEnumeration del dominio dentro de una expresión LINQ. + /// + private const int DeletedStatusId = 4; + public IUnitOfWork UnitOfWork => this; public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) @@ -20,7 +27,7 @@ public sealed class PostgreSqlPermissionTemplateRepository(UmsPlatformDbContext var record = await dbContext.PermissionTemplates .AsSplitQuery() .Include(x => x.Items) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != DeletedStatusId, cancellationToken); return record is null ? null : Rehydrate(record); } @@ -30,14 +37,17 @@ public sealed class PostgreSqlPermissionTemplateRepository(UmsPlatformDbContext var record = await dbContext.PermissionTemplates .AsSplitQuery() .Include(x => x.Items) - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id && x.StatusId != DeletedStatusId, cancellationToken); return record is null ? null : Rehydrate(record); } public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { - IQueryable query = dbContext.PermissionTemplates.AsSplitQuery().Include(x => x.Items); + IQueryable query = dbContext.PermissionTemplates + .AsSplitQuery() + .Include(x => x.Items) + .Where(x => x.StatusId != DeletedStatusId); if (tenantId.HasValue) { @@ -54,7 +64,7 @@ public async Task> GetByTenantIdAsync var records = await dbContext.PermissionTemplates .AsSplitQuery() .Include(x => x.Items) - .Where(x => x.TenantId == tenantId) + .Where(x => x.TenantId == tenantId && x.StatusId != DeletedStatusId) .OrderBy(x => x.RoleId) .ThenBy(x => x.Version) .ToListAsync(cancellationToken); @@ -62,6 +72,25 @@ public async Task> GetByTenantIdAsync return records.Select(Rehydrate).ToList(); } + /// + /// ÚNICA lectura que SÍ ve las plantillas lógicamente eliminadas, y es deliberado: su único + /// consumidor es CreatePermissionTemplateCommandHandler para calcular la versión siguiente + /// (CreateNextVersion, G-140). Una fila eliminada lógicamente SIGUE OCUPANDO su versión en + /// IX_PermissionTemplates_TenantId_RoleId_SystemSuiteId_Version, que es un índice único sobre toda + /// la tabla. Si la filtrásemos, el alta siguiente reutilizaría la versión de una eliminada y + /// chocaría con 23505 → 409 opaco. + /// + public async Task> GetByTenantRoleSuiteAsync(Guid tenantId, Guid roleId, Guid systemSuiteId, CancellationToken cancellationToken = default) + { + var records = await dbContext.PermissionTemplates + .AsSplitQuery() + .Include(x => x.Items) + .Where(x => x.TenantId == tenantId && x.RoleId == roleId && x.SystemSuiteId == systemSuiteId) + .ToListAsync(cancellationToken); + + return records.Select(Rehydrate).ToList(); + } + public Task AddAsync(PermissionTemplateAggregate aggregate, CancellationToken cancellationToken = default) { dbContext.PermissionTemplates.Add(ToRecord(aggregate)); @@ -100,6 +129,17 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); } + catch (DbUpdateException ex) + when (ex.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation }) + { + // G-140: red de seguridad. El alta ya resuelve la versión siguiente en el handler, pero + // ante una carrera dos altas concurrentes sobre la misma terna pueden calcular la misma + // versión y violar IX_PermissionTemplates_TenantId_RoleId_SystemSuiteId_Version (23505). + // Se traduce a 409 Conflict en vez de dejar que caiga a 500 opaco (cf. PostgreSqlTenantRepository). + var entry = ex.Entries.FirstOrDefault(); + var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); + throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); + } foreach (var aggregate in _trackedAggregates) { @@ -110,14 +150,24 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = return true; } + /// + /// Borrado LÓGICO. Marca la fila con el estado terminal Deleted; NUNCA la quita de la tabla. + /// + /// Antes hacía dbContext.PermissionTemplates.Remove(record), que además arrastraba por + /// cascada todos los PermissionTemplateItems: se perdía sin remedio el rastro de qué + /// concesiones había otorgado la plantilla, justo lo que el negocio consulta hacia atrás. + /// + /// Devuelve false si la fila no existe o si YA estaba eliminada, para que el handler + /// distinga la carrera de dos borrados concurrentes (mismo contrato booleano que antes). + /// public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { var record = await dbContext.PermissionTemplates - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != DeletedStatusId, cancellationToken); if (record is null) return false; - dbContext.PermissionTemplates.Remove(record); + record.StatusId = DeletedStatusId; return true; } @@ -128,9 +178,16 @@ public Task CountPublishedByRoleAsync(Guid roleId, CancellationToken cancel t => t.RoleId == roleId && t.StatusId == 2 /* Published */, cancellationToken); + /// + /// Los ítems de una plantilla lógicamente eliminada NO bloquean: su plantilla contenedora ya está + /// eliminada, así que la referencia al recurso de dominio también lo está. Sin este cruce, borrar + /// una plantilla dejaría bloqueado para siempre el recurso al que apuntaban sus ítems. + /// public Task CountItemsByTargetAsync(Guid targetId, CancellationToken cancellationToken = default) => dbContext.PermissionTemplateItems.CountAsync( - i => i.TargetId == targetId && i.IsActive, + i => i.TargetId == targetId + && i.IsActive + && dbContext.PermissionTemplates.Any(t => t.Id == i.TemplateId && t.StatusId != DeletedStatusId), cancellationToken); public void Dispose() => dbContext.Dispose(); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlProfileRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlProfileRepository.cs index 3e4df3bf..09540e8b 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlProfileRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlProfileRepository.cs @@ -73,6 +73,21 @@ public async Task> GetByUserIdAsync(Guid userId, return records.Select(Rehydrate).ToList(); } + public async Task> GetActiveByUserAndTenantAsync( + Guid userId, Guid tenantId, CancellationToken cancellationToken = default) + { + var records = await dbContext.Profiles + .AsNoTracking() + .AsSplitQuery() + .Include(x => x.Permissions) + // El `IsActive == true` va explícito para que el planificador pueda usar el índice + // parcial `IX_Profiles_UserId_Active`. + .Where(x => x.UserId == userId && x.TenantId == tenantId && x.IsActive) + .ToListAsync(cancellationToken); + + return records.Select(Rehydrate).ToList(); + } + public Task AddAsync(ProfileAggregate aggregate, CancellationToken cancellationToken = default) { dbContext.Profiles.Add(ToRecord(aggregate)); @@ -142,6 +157,11 @@ public Task CountActiveByTemplateAsync(Guid templateId, CancellationToken c public Task CountActiveByUserAsync(Guid userId, CancellationToken cancellationToken = default) => dbContext.Profiles.CountAsync(p => p.UserId == userId && p.IsActive, cancellationToken); + // ADR-0164 §2.2: guarda de cascada del cierre de sucursal. Solo los ACTIVOS bloquean; un perfil + // ya desactivado es una referencia muerta y no impide nada. + public Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default) + => dbContext.Profiles.CountAsync(p => p.BranchId == branchId && p.IsActive, cancellationToken); + private static ProfileAggregate Rehydrate(ProfileRecord record) => AuthorizationAggregateFactory.RehydrateProfile(record, record.Permissions); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlRoleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlRoleRepository.cs index fe80efb2..811811ca 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlRoleRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlRoleRepository.cs @@ -56,6 +56,18 @@ public async Task> GetByTenantIdAsync(Guid tenantId return records.Select(AuthorizationAggregateFactory.RehydrateRole).ToList(); } + public async Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) return []; + + var records = await dbContext.Roles + .AsNoTracking() + .Where(x => ids.Contains(x.Id)) + .ToListAsync(cancellationToken); + + return records.Select(AuthorizationAggregateFactory.RehydrateRole).ToList(); + } + public Task AddAsync(RoleAggregate aggregate, CancellationToken cancellationToken = default) { dbContext.Roles.Add(ToRecord(aggregate)); @@ -107,7 +119,6 @@ private static RoleRecord ToRecord(RoleAggregate aggregate) if (props == null) throw new InvalidOperationException("Role aggregate has null Props"); var audit = props.Audit?.GetValue(); - var roleId = props.Id?.GetValue() ?? Guid.Empty; var now = DateTime.UtcNow; return new RoleRecord diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlSystemSuiteRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlSystemSuiteRepository.cs index 9d3e0f64..2a92e312 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlSystemSuiteRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlSystemSuiteRepository.cs @@ -7,6 +7,7 @@ namespace Ums.Infrastructure.Persistence.Authorization; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbContext) : ISystemSuiteRepository, IUnitOfWork { @@ -18,7 +19,7 @@ public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbConte { var record = await dbContext.SystemSuites .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -31,7 +32,7 @@ public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbConte { var record = await dbContext.SystemSuites .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -40,11 +41,23 @@ public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbConte return record is null ? null : Rehydrate(record); } + /// + /// Sonda de unicidad del código, no una lectura del catálogo: la usa el alta para no chocar con + /// el índice único (TenantId, Code). + /// + /// Es la ÚNICA consulta que ve también los sistemas eliminados lógicamente, y tiene que + /// verlos: la lápida conserva su código y el índice único de PostgreSQL la sigue contando. Si + /// esta sonda la ignorara, reutilizar el código de un sistema eliminado pasaría la validación y + /// reventaría después con una violación de integridad —un 500 en vez de un 409 con sentido—. + /// Se apaga solo el filtro de borrado; el de inquilino sigue puesto, porque la unicidad es por + /// inquilino y no debe filtrarse la existencia de códigos ajenos (G-246). + /// public async Task GetByCodeAsync(Code code, CancellationToken cancellationToken = default) { var record = await dbContext.SystemSuites + .IgnoreQueryFilters([UmsPlatformDbContext.SystemSuiteSoftDeleteFilter]) .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -56,7 +69,7 @@ public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbConte public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { IQueryable query = dbContext.SystemSuites.AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources); @@ -75,7 +88,7 @@ public async Task> GetByTenantIdAsync(Guid t { var records = await dbContext.SystemSuites .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -86,6 +99,143 @@ public async Task> GetByTenantIdAsync(Guid t return records.Select(Rehydrate).ToList(); } + public async Task> GetSummariesByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) return []; + + // Proyección directa a columnas: ni `Include`, ni rehidratación, ni seguimiento de cambios. + return await dbContext.SystemSuites + .AsNoTracking() + .Where(x => ids.Contains(x.Id)) + .Select(x => new Ums.Domain.Authorization.SystemSuite.SystemSuiteSummary(x.Id, x.Code, x.Name)) + .ToListAsync(cancellationToken); + } + + public async Task GetPageAsync( + Ums.Domain.Authorization.SystemSuite.SystemSuitePageQuery query, + CancellationToken cancellationToken = default) + { + // Sin Include: aquí solo se decide QUÉ suites entran en la página. El árbol de cada una + // lo carga después `GetByIdsAsync`, y solo el de las que sobreviven al filtro. + IQueryable consulta = dbContext.SystemSuites.AsNoTracking(); + + if (query.TenantId.HasValue) + consulta = consulta.Where(x => x.TenantId == query.TenantId.Value); + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + // El estado se persiste como identificador, no como nombre: se traduce aquí para + // filtrar por columna indexable en vez de por una expresión. + var estadoId = DomainEnumerationMapper.FromName(query.Status).Id; + consulta = consulta.Where(x => x.StatusId == estadoId); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var patron = $"%{query.Search}%"; + consulta = query.SearchField switch + { + // ILIKE de PostgreSQL: la comparación insensible a mayúsculas se resuelve en la + // base, no trayendo filas para compararlas en memoria. + "code" => consulta.Where(x => EF.Functions.ILike(x.Code, patron)), + "id" => consulta.Where(x => EF.Functions.ILike(x.Id.ToString(), patron)), + _ => consulta.Where(x => EF.Functions.ILike(x.Name, patron)), + }; + } + + var total = await consulta.CountAsync(cancellationToken); + + consulta = (query.SortBy, query.Descending) switch + { + ("code", true) => consulta.OrderByDescending(x => x.Code), + ("code", false) => consulta.OrderBy(x => x.Code), + // Se ordena por el NOMBRE del estado, no por su identificador, para conservar el + // orden que el listado ya mostraba (Active, Deprecated, Maintenance). EF lo traduce + // a un CASE, sin traer filas. + // La expresión va INLINE, no en un método: EF traduce un condicional a CASE, pero + // no sabe traducir la llamada a un método propio — lo haría en memoria o reventaría. + ("status", true) => consulta.OrderByDescending(x => x.StatusId == 1 ? "Active" : x.StatusId == 2 ? "Maintenance" : "Deprecated"), + ("status", false) => consulta.OrderBy(x => x.StatusId == 1 ? "Active" : x.StatusId == 2 ? "Maintenance" : "Deprecated"), + (_, true) => consulta.OrderByDescending(x => x.Name), + _ => consulta.OrderBy(x => x.Name), + }; + + var ids = await consulta + .Skip((query.Page - 1) * query.PageSize) + .Take(query.PageSize) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + return new Ums.Domain.Authorization.SystemSuite.SystemSuitePage(ids, total); + } + + public async Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) return []; + + var records = await dbContext.SystemSuites + .AsSplitQuery() + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) + .Include(x => x.AppSettings) + .Include(x => x.Actions) + .Include(x => x.DomainResources) + .Where(x => ids.Contains(x.Id)) + .ToListAsync(cancellationToken); + + // Se respeta el orden que decidió la página: `IN (...)` no lo garantiza. + var porId = records.ToDictionary(r => r.Id); + return ids.Where(porId.ContainsKey).Select(id => Rehydrate(porId[id])).ToList(); + } + + public async Task GetDependentsAsync( + Guid id, + CancellationToken cancellationToken = default) + { + // Conteos, no cargas: la guarda solo necesita saber CUÁNTAS referencias vivas apuntan al + // sistema. Traer los agregados para contarlos sería el patrón que ADR-0041 proscribe, y aquí + // ni siquiera se mira un solo campo de ellos. Todas las columnas implicadas están indexadas. + // + // Sobre qué se considera «vivo» (regla de cascada, G-246): de estas siete tablas, solo + // `Tenants` tiene eliminación lógica —su filtro global `!IsDeleted` deja fuera a los + // inquilinos ya eliminados, de modo que un inquilino difunto NO bloquea—. Las otras seis no + // tienen marca de borrado lógico: `Roles.IsActive`, `PermissionTemplates.StatusId`, + // `FeatureFlags/IdpConfigurations/AppConfigurations` con su `Archived` y `ApprovalWorkflows` + // sin estado alguno describen ciclo de vida, no borrado. Se cuenta TODA fila existente: + // fail-closed. Cuando alguna de ellas gane su estado terminal, aquí se añade el predicado y + // en ningún sitio más. + var roles = await dbContext.Roles + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var templates = await dbContext.PermissionTemplates + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var featureFlags = await dbContext.FeatureFlags + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var idpConfigurations = await dbContext.IdpConfigurations + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var appConfigurations = await dbContext.AppConfigurations + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var approvalWorkflows = await dbContext.ApprovalWorkflows + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + // Es la referencia más peligrosa: si el sistema por defecto de un inquilino deja de leerse, + // su resolución de método de autenticación se queda sin destino. El filtro global de + // `Tenants` (`!IsDeleted`) es el que materializa aquí la regla del propietario: un inquilino + // ya eliminado lógicamente es una lápida y no cuenta. + var tenantsUsingAsDefault = await dbContext.Tenants + .CountAsync(x => x.DefaultSystemSuiteId == id, cancellationToken).ConfigureAwait(false); + + return new Ums.Domain.Authorization.SystemSuite.SystemSuiteDependents( + roles, + templates, + featureFlags, + idpConfigurations, + appConfigurations, + approvalWorkflows, + tenantsUsingAsDefault); + } + + // No hay DeleteAsync, y su ausencia es deliberada: la eliminación de un sistema es un cambio de + // estado a `SystemStatus.Deleted` que viaja por `UpdateAsync`. Sobre el catálogo se consultan + // datos antiguos, y una fila borrada de verdad no se recupera. + public Task AddAsync(SystemSuiteAggregate aggregate, CancellationToken cancellationToken = default) { dbContext.SystemSuites.Add(ToRecord(aggregate)); @@ -107,7 +257,7 @@ public async Task UpdateAsync(SystemSuiteAggregate aggregate, CancellationToken .FirstOrDefault(e => e.Entity.Id == id) ?.Entity ?? await dbContext.SystemSuites - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -175,6 +325,7 @@ private static SystemSuiteRecord ToRecord(SystemSuiteAggregate aggregate) Id = Guid.NewGuid(), SystemSuiteId = aggregate.Props.Id.GetValue(), ConfigKey = x.Key.GetValue(), + IsClientVisible = x.IsClientVisible, ConfigValue = x.Value.GetValue(), ScopeId = x.Scope.Id, }).ToList(), @@ -231,73 +382,75 @@ private static SystemSuiteModuleRecord ToRecord(Ums.Domain.Authorization.SystemS Description = module.Props.Description.GetValue(), StatusId = module.Props.Status.Id, SortOrder = module.Props.SortOrder, + Icon = module.Props.Icon, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, UpdatedAtUtc = audit.UpdatedAt, AuditTimeSpan = audit.TimeSpan, - Menus = module.Menus.Select(ToRecord).ToList(), + Nodes = FlattenNodes(module.Nodes), }; } - private static SystemSuiteMenuRecord ToRecord(Ums.Domain.Authorization.SystemSuite.Menu.Menu menu) + private static SystemSuiteNodeRecord ToRecord(MenuNodeEntity node) { - var audit = menu.Props.Audit.GetValue(); - return new SystemSuiteMenuRecord + var audit = node.Props.Audit.GetValue(); + var meta = node.Metadata; + var nodeId = node.GetId().GetValue(); + return new SystemSuiteNodeRecord { - Id = menu.Props.Id.GetValue(), - ModuleId = menu.Props.ModuleId.GetValue(), - Code = menu.Props.Code.GetValue(), - Label = menu.Props.Label.GetValue(), - Description = menu.Props.Description.GetValue(), - SortOrder = menu.Props.SortOrder, + Id = nodeId, + ModuleId = node.ModuleId.GetValue(), + ParentNodeId = node.ParentNodeId?.GetValue(), + NodeKindId = (int)node.Kind, + Code = node.Code.GetValue(), + Label = node.Label.GetValue(), + Description = node.Description.GetValue(), + StatusId = node.Status.Id, + SortOrder = node.SortOrder, + Icon = node.Props.Presentation.Icon, + Route = node.Props.Presentation.Route, + Responsable = meta.Responsable, + Criticidad = meta.Criticidad, + ProductoImpactado = meta.ProductoImpactado, + ComponenteTecnico = meta.ComponenteTecnico, + Dependencias = meta.Dependencias, + Evidencias = meta.Evidencias, + TrazabilidadSdlc = meta.TrazabilidadSdlc, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, UpdatedAtUtc = audit.UpdatedAt, AuditTimeSpan = audit.TimeSpan, - SubMenus = menu.SubMenus.Select(ToRecord).ToList(), + Actions = node.ActionCodes.Select(ac => new SystemSuiteNodeActionRecord + { + Id = Guid.NewGuid(), + NodeId = nodeId, + ActionCode = ac.GetValue(), + }).ToList(), }; } - private static SystemSuiteSubMenuRecord ToRecord(Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu subMenu) + /// Aplana el árbol de MenuNode a filas planas (ParentNodeId enlaza el árbol). + private static List FlattenNodes(IEnumerable roots) { - var audit = subMenu.Props.Audit.GetValue(); - return new SystemSuiteSubMenuRecord + var flat = new List(); + + void Walk(MenuNodeEntity node) { - Id = subMenu.Props.Id.GetValue(), - MenuId = subMenu.Props.MenuId.GetValue(), - Code = subMenu.Props.Code.GetValue(), - Label = subMenu.Props.Label.GetValue(), - Description = subMenu.Props.Description.GetValue(), - SortOrder = subMenu.Props.SortOrder, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Options = subMenu.Options.Select(ToRecord).ToList(), - }; - } + flat.Add(ToRecord(node)); + foreach (var child in node.Children) + { + Walk(child); + } + } - private static SystemSuiteOptionRecord ToRecord(Ums.Domain.Authorization.SystemSuite.Option.Option option) - { - var audit = option.Props.Audit.GetValue(); - return new SystemSuiteOptionRecord + foreach (var root in roots) { - Id = option.Props.Id.GetValue(), - SubMenuId = option.Props.SubMenuId.GetValue(), - Code = option.Props.Code.GetValue(), - Label = option.Props.Label.GetValue(), - Description = option.Props.Description.GetValue(), - ActionCode = option.Props.ActionCode.GetValue(), - SortOrder = option.Props.SortOrder, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; + Walk(root); + } + + return flat; } private void Apply(SystemSuiteRecord target, SystemSuiteAggregate source) @@ -441,72 +594,56 @@ private void ReconcileModules( existing.UpdatedAtUtc = rep.UpdatedAtUtc; existing.AuditTimeSpan = rep.AuditTimeSpan; - ReconcileMenus(existing.Menus, rep.Menus); + ReconcileNodes(existing.Nodes, rep.Nodes); }); } - private void ReconcileMenus( - IList tracked, - IList replacement) + /// + /// Reconcilia el árbol de nodos en PLANO por Id (el árbol se enlaza por + /// ParentNodeId). Cubre inserción, actualización y borrado de subárboles. + /// + private void ReconcileNodes( + IList tracked, + IList replacement) { ReconcileByKey( tracked, replacement, - m => m.Id, + n => n.Id, (existing, rep) => { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - - ReconcileSubMenus(existing.SubMenus, rep.SubMenus); + existing.ParentNodeId = rep.ParentNodeId; + existing.NodeKindId = rep.NodeKindId; + existing.Code = rep.Code; + existing.Label = rep.Label; + existing.Description = rep.Description; + existing.StatusId = rep.StatusId; + existing.SortOrder = rep.SortOrder; + existing.Responsable = rep.Responsable; + existing.Criticidad = rep.Criticidad; + existing.ProductoImpactado = rep.ProductoImpactado; + existing.ComponenteTecnico = rep.ComponenteTecnico; + existing.Dependencias = rep.Dependencias; + existing.Evidencias = rep.Evidencias; + existing.TrazabilidadSdlc = rep.TrazabilidadSdlc; + existing.UpdatedBy = rep.UpdatedBy; + existing.UpdatedAtUtc = rep.UpdatedAtUtc; + existing.AuditTimeSpan = rep.AuditTimeSpan; + + ReconcileNodeActions(existing.Actions, rep.Actions); }); } - private void ReconcileSubMenus( - IList tracked, - IList replacement) + private void ReconcileNodeActions( + IList tracked, + IList replacement) { + // Id se regenera en cada ToRecord → reconciliar por ActionCode (la clave real). ReconcileByKey( tracked, replacement, - sm => sm.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - - ReconcileOptions(existing.Options, rep.Options); - }); + a => a.ActionCode, + (_, _) => { /* solo la clave es relevante; nada mutable que actualizar */ }); } - private void ReconcileOptions( - IList tracked, - IList replacement) - { - ReconcileByKey( - tracked, - replacement, - o => o.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.ActionCode = rep.ActionCode; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - }); - } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/ConfigurationPersistenceConstants.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/ConfigurationPersistenceConstants.cs index 8cfa36f9..5d783f1a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/ConfigurationPersistenceConstants.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/ConfigurationPersistenceConstants.cs @@ -3,4 +3,22 @@ namespace Ums.Infrastructure.Persistence.Configuration; internal static class ConfigurationPersistenceConstants { public const string Schema = "ums_configuration"; + + /// + /// Predicado SQL de «fila viva» para los índices únicos PARCIALES de este esquema. + /// + /// ADR-0164 §2.3 dejó los índices únicos SIN filtrar por borrado, de modo que la clave natural + /// quedaba ocupada para siempre. El propietario del producto acotó esa regla el 2026-08-04: vale + /// para lo que identifica algo del mundo real —una sucursal, un sistema— pero NO para una RANURA + /// de configuración. El código de una configuración sale de un catálogo cerrado + /// (`MFA_REQUIRED_FOR_ADMIN` es *el* nombre del parámetro, no uno que se elija), así que dejar la + /// ranura ocupada equivale a impedir para siempre volver a configurar ese parámetro. + /// + /// El filtro se escribe en SQL y no como expresión C# porque un índice parcial vive en la base; + /// el literal 4 es . Se interpola desde el enum, no se teclea: + /// si ese id cambiara, la foto del modelo dejaría de cuadrar y la migración de verificación + /// saldría NO vacía, que es exactamente la señal que se quiere. + /// + public static readonly string LiveRowIndexFilter = + $"\"StatusId\" != {ConfigStatus.Deleted.Id}"; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/AppConfigurationRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/AppConfigurationRecordConfiguration.cs index aeca0de7..06752667 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/AppConfigurationRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/AppConfigurationRecordConfiguration.cs @@ -20,7 +20,19 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.Property(x => x.RowVersion).IsRowVersion(); // FIX-03: optimistic concurrency - builder.HasIndex(x => new { x.TenantId, x.SystemSuiteId, x.ModuleId, x.Code }).IsUnique(); + // Índice único PARCIAL: la ranura (ámbito, código) la ocupa solo una configuración VIVA. + // Una configuración eliminada lógicamente sale del índice —su fila permanece en la tabla, + // que es lo que ADR-0164 §2.3 protege— y su código vuelve a poder configurarse. Sin este + // filtro, borrar la configuración global de `MFA_REQUIRED_FOR_ADMIN` impedía volver a + // configurar ese parámetro NUNCA, porque su código no lo inventa quien opera: viene de un + // catálogo cerrado. Lo midió `app-configuration-state.spec.ts` (409 donde esperaba 201). + // + // Sucursales y SystemSuite NO cambian: ahí el código sí identifica algo del mundo real y la + // regla original —clave ocupada para siempre— se mantiene por decisión explícita. + builder.HasIndex(x => new { x.TenantId, x.SystemSuiteId, x.ModuleId, x.Code }) + .IsUnique() + .HasFilter(ConfigurationPersistenceConstants.LiveRowIndexFilter); + builder.HasIndex(x => x.ScopeId); builder.HasIndex(x => x.StatusId); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/FeatureFlagRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/FeatureFlagRecordConfiguration.cs index 93732d98..c0bef74b 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/FeatureFlagRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/FeatureFlagRecordConfiguration.cs @@ -21,7 +21,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.SystemSuiteId).IsRequired(); builder.Property(x => x.TenantId); - builder.HasIndex(x => new { x.SystemSuiteId, x.FlagCode }).IsUnique(); + builder.HasIndex(x => new { x.SystemSuiteId, x.FlagCode }) + .IsUnique() + .HasFilter("\"StatusId\" != 3"); builder.HasIndex(x => x.SystemSuiteId); builder.HasIndex(x => x.StatusId); builder.HasIndex(x => x.FlagTypeId); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterDefinitionRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterDefinitionRecordConfiguration.cs index d743ad24..fca7e76e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterDefinitionRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterDefinitionRecordConfiguration.cs @@ -12,7 +12,14 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(x => x.Id); builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); - builder.HasIndex(x => x.Code).IsUnique(); + + // Índice único PARCIAL sobre las definiciones VIVAS. El código de un parámetro es una ranura + // del catálogo, no la identidad de una cosa del mundo real: si la lápida siguiera ocupando el + // índice, retirar una definición prohibiría volver a declarar ese mismo parámetro para + // siempre. La fila eliminada se conserva (ADR-0164 §2.1); lo que se libera es la ranura. + builder.HasIndex(x => x.Code) + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); builder.Property(x => x.Name).HasMaxLength(200).IsRequired(); builder.Property(x => x.Description).HasMaxLength(1000); @@ -20,9 +27,13 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Version).HasMaxLength(50).IsRequired(); builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); builder.Property(x => x.UpdatedBy).HasMaxLength(100); + builder.Property(x => x.DeletedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.HasIndex(x => x.ScopeId); builder.HasIndex(x => x.IsActive); + // Toda lectura del catálogo filtra por IsDeleted (filtro global de consulta): sin índice, + // ese predicado se paga con un recorrido completo en cada listado. + builder.HasIndex(x => x.IsDeleted); } } \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterGlobalValueRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterGlobalValueRecordConfiguration.cs index 44ff8060..2e4084f7 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterGlobalValueRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterGlobalValueRecordConfiguration.cs @@ -17,7 +17,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - builder.HasIndex(x => x.ParameterDefinitionId).IsUnique(); + // Índice único PARCIAL: una definición tiene como mucho UN valor global vivo. El valor + // eliminado se queda en la tabla como historia —explica por qué el sistema se comportó como + // se comportó— pero deja de reservar la ranura, así que el parámetro puede volver a recibir + // un valor global. Sin el filtro, borrar el valor global era irreversible. + builder.HasIndex(x => x.ParameterDefinitionId) + .IsUnique() + .HasFilter(ConfigurationPersistenceConstants.LiveRowIndexFilter); + builder.HasIndex(x => x.StatusId); } } \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterTenantValueRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterTenantValueRecordConfiguration.cs index afee5e1f..dda05597 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterTenantValueRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterTenantValueRecordConfiguration.cs @@ -17,7 +17,13 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - builder.HasIndex(x => new { x.TenantId, x.ParameterDefinitionId }).IsUnique(); + // Índice único PARCIAL: un inquilino tiene como mucho UN override vivo por definición. Igual + // que en el valor global, la fila eliminada permanece pero libera la ranura; de lo contrario + // un inquilino que retira su override no podría volver a fijarlo jamás. + builder.HasIndex(x => new { x.TenantId, x.ParameterDefinitionId }) + .IsUnique() + .HasFilter(ConfigurationPersistenceConstants.LiveRowIndexFilter); + builder.HasIndex(x => x.TenantId); builder.HasIndex(x => x.StatusId); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Entities/ParameterDefinitionRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Entities/ParameterDefinitionRecord.cs index 2e6a5169..8aff8c3f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Entities/ParameterDefinitionRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Entities/ParameterDefinitionRecord.cs @@ -14,6 +14,13 @@ public sealed class ParameterDefinitionRecord : IAuditableRecord public bool IsActive { get; set; } public bool IsMandatory { get; set; } public int DisplayOrder { get; set; } + + // Soft-delete (política del propietario: nunca se borra la fila). Mismos tres campos que + // UserAccountRecord (REC-16) para que el patrón sea uno solo en todo el repositorio. + public bool IsDeleted { get; set; } + public DateTime? DeletedAtUtc { get; set; } + public string? DeletedBy { get; set; } + public string Version { get; set; } = string.Empty; public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlAppConfigurationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlAppConfigurationRepository.cs index 15f2f4ea..4f80028a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlAppConfigurationRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlAppConfigurationRepository.cs @@ -14,10 +14,12 @@ public sealed class PostgreSqlAppConfigurationRepository(UmsPlatformDbContext db public IUnitOfWork UnitOfWork => this; + // Las lecturas ocultan lo eliminado lógicamente: la fila permanece en la tabla —la consulta + // histórica la necesita— pero un GET sobre ella responde 404 y no aparece en ningún listado. public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { var record = await dbContext.AppConfigurations - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != ConfigStatus.Deleted.Id, cancellationToken); return record is null ? null : Rehydrate(record); } @@ -25,6 +27,21 @@ public sealed class PostgreSqlAppConfigurationRepository(UmsPlatformDbContext db public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) => GetByIdAsync(id, cancellationToken); + /// + /// Lookup por ámbito+código: devuelve quién OCUPA esa ranura ahora mismo, nunca una lápida. + /// + /// Antes no filtraba lo eliminado, porque el índice único tampoco lo hacía y ocultarlo habría + /// convertido un conflicto legible en un 23505 → 500. Ahora el índice es parcial (solo filas + /// vivas), así que la comprobación de unicidad tiene que mirar exactamente lo mismo que mira el + /// índice: si siguiera viendo la lápida, el alta devolvería 409 sobre una ranura que la base + /// considera libre. Es el 409-donde-se-esperaba-201 que midió la certificación E2E. + /// + /// El orden hace la respuesta DETERMINISTA cuando conviven varias filas con el mismo código: + /// una viva y N eliminadas es el caso normal tras liberar la ranura, y las eliminadas ya quedan + /// fuera; el desempate por estado y fecha cubre además el caso —permitido por el handler desde + /// CFG-06— de una archivada conviviendo con la nueva: gana la que no está archivada, y entre + /// iguales la más reciente. Sin ese orden, `FirstOrDefault` devolvería una fila arbitraria. + /// public async Task GetByScopeAndCodeAsync(Guid? tenantId, Guid? systemSuiteId, Guid? moduleId, string code, CancellationToken cancellationToken = default) { IQueryable query = dbContext.AppConfigurations; @@ -34,12 +51,19 @@ public sealed class PostgreSqlAppConfigurationRepository(UmsPlatformDbContext db query = query.IgnoreQueryFilters(); } + var deletedStatusId = ConfigStatus.Deleted.Id; + var archivedStatusId = ConfigStatus.Archived.Id; + var record = await query - .FirstOrDefaultAsync(x => + .Where(x => x.TenantId == tenantId && x.SystemSuiteId == systemSuiteId && x.ModuleId == moduleId - && x.Code == code, cancellationToken); + && x.Code == code + && x.StatusId != deletedStatusId) + .OrderBy(x => x.StatusId == archivedStatusId ? 1 : 0) + .ThenByDescending(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); return record is null ? null : Rehydrate(record); } @@ -53,7 +77,11 @@ public async Task> GetAllAsync(Guid? te query = query.IgnoreQueryFilters().Where(x => x.TenantId == tenantId.Value); } - var records = await query.OrderBy(x => x.Code).ToListAsync(cancellationToken); + // Lo eliminado lógicamente no se lista (ni en la API ni en la carga de la caché). + var records = await query + .Where(x => x.StatusId != ConfigStatus.Deleted.Id) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); return records.Select(Rehydrate).ToList(); } @@ -91,6 +119,9 @@ public async Task UpdateAsync( _trackedAggregates.Add(aggregate); } + // Aquí NO hay DeleteAsync: el borrado es lógico y viaja por UpdateAsync como cualquier otro + // cambio de estado (StatusId → Deleted). Ninguna ruta de este repositorio retira filas. + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlFeatureFlagRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlFeatureFlagRepository.cs index 01eff4fc..602b7118 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlFeatureFlagRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlFeatureFlagRepository.cs @@ -67,6 +67,19 @@ public async Task> GetAllAsync(Guid? tenantI return records.Select(Rehydrate).ToList(); } + public async Task> GetBySystemSuiteIdForEvaluationAsync(Guid systemSuiteId, CancellationToken cancellationToken = default) + { + var records = await dbContext.FeatureFlags + .AsNoTracking() + .AsSplitQuery() + .Include(x => x.Criteria) + .Where(x => x.SystemSuiteId == systemSuiteId) + .OrderBy(x => x.FlagCode) + .ToListAsync(cancellationToken); + + return records.Select(Rehydrate).ToList(); + } + public async Task> GetBySystemSuiteIdAsync(Guid systemSuiteId, CancellationToken cancellationToken = default) { var records = await dbContext.FeatureFlags diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlParameterRepositories.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlParameterRepositories.cs index 1f340445..dd194421 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlParameterRepositories.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlParameterRepositories.cs @@ -11,67 +11,85 @@ namespace Ums.Infrastructure.Persistence.Configuration; public sealed class PostgreSqlParameterDefinitionRepository(UmsPlatformDbContext db) : IParameterDefinitionRepository { - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Id == id, ct); + var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterDefinition(r); } - public async Task GetByCodeAsync(string code, CancellationToken ct = default) + public async Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) { var upper = code.ToUpperInvariant(); - var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Code == upper, ct); + var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Code == upper, cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterDefinition(r); } - public async Task> GetAllAsync(CancellationToken ct = default) + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - var records = await db.ParameterDefinitions.OrderBy(x => x.DisplayOrder).ToListAsync(ct); + var records = await db.ParameterDefinitions.OrderBy(x => x.DisplayOrder).ToListAsync(cancellationToken); return records.Select(ConfigurationAggregateFactory.RehydrateParameterDefinition).ToList(); } - public async Task AddAsync(ParameterDefinition d, CancellationToken ct = default) - => await db.ParameterDefinitions.AddAsync(ToRecord(d), ct); + public async Task AddAsync(ParameterDefinition definition, CancellationToken cancellationToken = default) + => await db.ParameterDefinitions.AddAsync(ToRecord(definition), cancellationToken); - public async Task UpdateAsync(ParameterDefinition d, CancellationToken ct = default) + public async Task UpdateAsync(ParameterDefinition definition, CancellationToken cancellationToken = default) { var existing = await db.ParameterDefinitions - .FirstOrDefaultAsync(x => x.Id == d.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterDefinition {d.Props.Id.GetValue()} not found."); - Apply(existing, d); + .FirstOrDefaultAsync(x => x.Id == definition.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"ParameterDefinition {definition.Props.Id.GetValue()} not found."); + Apply(existing, definition); } - public Task CountByCodeAsync(string code, CancellationToken ct = default) - => db.ParameterDefinitions.CountAsync(x => x.Code == code.ToUpperInvariant(), ct); + /// + public Task CountByCodeAsync(string code, CancellationToken cancellationToken = default) + // Cuenta solo las VIVAS: el filtro global `!IsDeleted` basta y ya no se apaga. El índice + // único de `Code` pasó a ser parcial, así que una lápida no reserva el código; contarla + // devolvería un 409 sobre una ranura que la base considera libre. + => db.ParameterDefinitions + .CountAsync(x => x.Code == code.ToUpperInvariant(), cancellationToken); - public Task CountGlobalValuesAsync(Guid definitionId, CancellationToken ct = default) - => db.ParameterGlobalValues.CountAsync(x => x.ParameterDefinitionId == definitionId, ct); + /// + public Task CountLiveGlobalValuesAsync(Guid definitionId, CancellationToken cancellationToken = default) + => db.ParameterGlobalValues.CountAsync( + x => x.ParameterDefinitionId == definitionId && x.StatusId != ConfigStatus.Deleted.Id, + cancellationToken); - public Task CountTenantValuesAsync(Guid definitionId, CancellationToken ct = default) - => db.ParameterTenantValues.CountAsync(x => x.ParameterDefinitionId == definitionId, ct); + /// + public Task CountLiveTenantValuesAsync(Guid definitionId, CancellationToken cancellationToken = default) + => db.ParameterTenantValues + // IgnoreQueryFilters a propósito: la integridad referencial es una pregunta del SISTEMA, + // no del inquilino en curso. Con el filtro de inquilino activo, un administrador interno + // operando con X-Tenant-Id no vería los overrides de los demás inquilinos y borraría la + // definición dejándolos huérfanos. + .IgnoreQueryFilters() + .CountAsync( + x => x.ParameterDefinitionId == definitionId && x.StatusId != ConfigStatus.Deleted.Id, + cancellationToken); - public async Task SaveChangesAsync(CancellationToken ct = default) + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await db.SaveChangesAsync(ct); + await db.SaveChangesAsync(cancellationToken); return true; } - private static ParameterDefinitionRecord ToRecord(ParameterDefinition d) + private static ParameterDefinitionRecord ToRecord(ParameterDefinition definition) { - var audit = d.Props.Audit.GetValue(); + var audit = definition.Props.Audit.GetValue(); return new ParameterDefinitionRecord { - Id = d.Props.Id.GetValue(), - Code = d.Props.Code.GetValue(), - Name = d.Props.Name.Value, - Description = d.Props.Description.GetValue(), - DataTypeId = d.Props.DataType.Id, - DefaultValue = d.Props.DefaultValue.Value, - ScopeId = d.Props.Scope.Id, - IsActive = d.Props.IsActive, - IsMandatory = d.Props.IsMandatory, - DisplayOrder = d.Props.DisplayOrder, - Version = d.Props.Version, + Id = definition.Props.Id.GetValue(), + Code = definition.Props.Code.GetValue(), + Name = definition.Props.Name.Value, + Description = definition.Props.Description.GetValue(), + DataTypeId = definition.Props.DataType.Id, + DefaultValue = definition.Props.DefaultValue.Value, + ScopeId = definition.Props.Scope.Id, + IsActive = definition.Props.IsActive, + IsMandatory = definition.Props.IsMandatory, + DisplayOrder = definition.Props.DisplayOrder, + IsDeleted = definition.Props.IsDeleted, + Version = definition.Props.Version, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -80,66 +98,83 @@ private static ParameterDefinitionRecord ToRecord(ParameterDefinition d) }; } - private static void Apply(ParameterDefinitionRecord t, ParameterDefinition d) + private static void Apply(ParameterDefinitionRecord t, ParameterDefinition definition) { - var audit = d.Props.Audit.GetValue(); - t.Name = d.Props.Name.Value; - t.Description = d.Props.Description.GetValue(); - t.DefaultValue = d.Props.DefaultValue.Value; - t.ScopeId = d.Props.Scope.Id; - t.IsActive = d.Props.IsActive; - t.IsMandatory = d.Props.IsMandatory; - t.DisplayOrder = d.Props.DisplayOrder; - t.Version = d.Props.Version; + var audit = definition.Props.Audit.GetValue(); + t.Name = definition.Props.Name.Value; + t.Description = definition.Props.Description.GetValue(); + t.DefaultValue = definition.Props.DefaultValue.Value; + t.ScopeId = definition.Props.Scope.Id; + t.IsActive = definition.Props.IsActive; + t.IsMandatory = definition.Props.IsMandatory; + t.DisplayOrder = definition.Props.DisplayOrder; + t.Version = definition.Props.Version; t.UpdatedBy = audit.UpdatedBy; t.UpdatedAtUtc = audit.UpdatedAt; t.AuditTimeSpan = audit.TimeSpan; + + // Sello de borrado lógico: se estampa una sola vez, en la transición. Si ya estaba puesto no + // se reescribe, para que la fecha y el actor del borrado original no se pierdan. + if (definition.Props.IsDeleted && !t.IsDeleted) + { + t.IsDeleted = true; + t.DeletedAtUtc = audit.UpdatedAt ?? DateTime.UtcNow; + t.DeletedBy = audit.UpdatedBy; + } } } public sealed class PostgreSqlParameterGlobalValueRepository(UmsPlatformDbContext db) : IParameterGlobalValueRepository { - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var r = await db.ParameterGlobalValues.FirstOrDefaultAsync(x => x.Id == id, ct); + var r = await db.ParameterGlobalValues + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != ConfigStatus.Deleted.Id, cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterGlobalValue(r); } - public async Task GetByDefinitionIdAsync(Guid definitionId, CancellationToken ct = default) + /// + public async Task GetByDefinitionIdAsync(Guid definitionId, CancellationToken cancellationToken = default) { + // Solo la fila VIVA: el índice único es ahora parcial, así que las lápidas no reservan la + // ranura y no deben devolverse. Devolver una haría dos daños a la vez: bloquear un alta que + // la base permite, y dar por «el valor global» uno que se retiró hace meses. var r = await db.ParameterGlobalValues - .FirstOrDefaultAsync(x => x.ParameterDefinitionId == definitionId, ct); + .Where(x => x.ParameterDefinitionId == definitionId && x.StatusId != ConfigStatus.Deleted.Id) + .OrderByDescending(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterGlobalValue(r); } - public async Task AddAsync(ParameterGlobalValue v, CancellationToken ct = default) - => await db.ParameterGlobalValues.AddAsync(ToRecord(v), ct); + public async Task AddAsync(ParameterGlobalValue value, CancellationToken cancellationToken = default) + => await db.ParameterGlobalValues.AddAsync(ToRecord(value), cancellationToken); - public async Task UpdateAsync(ParameterGlobalValue v, CancellationToken ct = default) + public async Task UpdateAsync(ParameterGlobalValue value, CancellationToken cancellationToken = default) { var existing = await db.ParameterGlobalValues - .FirstOrDefaultAsync(x => x.Id == v.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterGlobalValue {v.Props.Id.GetValue()} not found."); - Apply(existing, v); + .FirstOrDefaultAsync(x => x.Id == value.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"ParameterGlobalValue {value.Props.Id.GetValue()} not found."); + Apply(existing, value); } - public async Task SaveChangesAsync(CancellationToken ct = default) + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await db.SaveChangesAsync(ct); + await db.SaveChangesAsync(cancellationToken); return true; } - private static ParameterGlobalValueRecord ToRecord(ParameterGlobalValue v) + private static ParameterGlobalValueRecord ToRecord(ParameterGlobalValue value) { - var audit = v.Props.Audit.GetValue(); + var audit = value.Props.Audit.GetValue(); return new ParameterGlobalValueRecord { - Id = v.Props.Id.GetValue(), - ParameterDefinitionId = v.Props.ParameterDefinitionId.GetValue(), - EffectiveValue = v.Props.Value.Value, - StatusId = v.Props.Status.Id, - Version = v.Props.Version, + Id = value.Props.Id.GetValue(), + ParameterDefinitionId = value.Props.ParameterDefinitionId.GetValue(), + EffectiveValue = value.Props.Value.Value, + StatusId = value.Props.Status.Id, + Version = value.Props.Version, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -148,12 +183,12 @@ private static ParameterGlobalValueRecord ToRecord(ParameterGlobalValue v) }; } - private static void Apply(ParameterGlobalValueRecord t, ParameterGlobalValue v) + private static void Apply(ParameterGlobalValueRecord t, ParameterGlobalValue value) { - var audit = v.Props.Audit.GetValue(); - t.EffectiveValue = v.Props.Value.Value; - t.StatusId = v.Props.Status.Id; - t.Version = v.Props.Version; + var audit = value.Props.Audit.GetValue(); + t.EffectiveValue = value.Props.Value.Value; + t.StatusId = value.Props.Status.Id; + t.Version = value.Props.Version; t.UpdatedBy = audit.UpdatedBy; t.UpdatedAtUtc = audit.UpdatedAt; t.AuditTimeSpan = audit.TimeSpan; @@ -163,48 +198,57 @@ private static void Apply(ParameterGlobalValueRecord t, ParameterGlobalValue v) public sealed class PostgreSqlParameterTenantValueRepository(UmsPlatformDbContext db) : IParameterTenantValueRepository { - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var r = await db.ParameterTenantValues.FirstOrDefaultAsync(x => x.Id == id, ct); + var r = await db.ParameterTenantValues + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != ConfigStatus.Deleted.Id, cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterTenantValue(r); } + /// public async Task GetByTenantAndDefinitionAsync( - Guid tenantId, Guid definitionId, CancellationToken ct = default) + Guid tenantId, Guid definitionId, CancellationToken cancellationToken = default) { + // Solo la fila VIVA, por la misma razón que en el valor global: el índice único parcial + // (TenantId, ParameterDefinitionId) ya no cuenta las lápidas. var r = await db.ParameterTenantValues - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.ParameterDefinitionId == definitionId, ct); + .Where(x => x.TenantId == tenantId + && x.ParameterDefinitionId == definitionId + && x.StatusId != ConfigStatus.Deleted.Id) + .OrderByDescending(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterTenantValue(r); } - public async Task AddAsync(ParameterTenantValue v, CancellationToken ct = default) - => await db.ParameterTenantValues.AddAsync(ToRecord(v), ct); + public async Task AddAsync(ParameterTenantValue value, CancellationToken cancellationToken = default) + => await db.ParameterTenantValues.AddAsync(ToRecord(value), cancellationToken); - public async Task UpdateAsync(ParameterTenantValue v, CancellationToken ct = default) + public async Task UpdateAsync(ParameterTenantValue value, CancellationToken cancellationToken = default) { var existing = await db.ParameterTenantValues - .FirstOrDefaultAsync(x => x.Id == v.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterTenantValue {v.Props.Id.GetValue()} not found."); - Apply(existing, v); + .FirstOrDefaultAsync(x => x.Id == value.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"ParameterTenantValue {value.Props.Id.GetValue()} not found."); + Apply(existing, value); } - public async Task SaveChangesAsync(CancellationToken ct = default) + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await db.SaveChangesAsync(ct); + await db.SaveChangesAsync(cancellationToken); return true; } - private static ParameterTenantValueRecord ToRecord(ParameterTenantValue v) + private static ParameterTenantValueRecord ToRecord(ParameterTenantValue value) { - var audit = v.Props.Audit.GetValue(); + var audit = value.Props.Audit.GetValue(); return new ParameterTenantValueRecord { - Id = v.Props.Id.GetValue(), - TenantId = v.Props.TenantId.GetValue(), - ParameterDefinitionId = v.Props.ParameterDefinitionId.GetValue(), - OverrideValue = v.Props.Value.Value, - StatusId = v.Props.Status.Id, - Version = v.Props.Version, + Id = value.Props.Id.GetValue(), + TenantId = value.Props.TenantId.GetValue(), + ParameterDefinitionId = value.Props.ParameterDefinitionId.GetValue(), + OverrideValue = value.Props.Value.Value, + StatusId = value.Props.Status.Id, + Version = value.Props.Version, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -213,12 +257,12 @@ private static ParameterTenantValueRecord ToRecord(ParameterTenantValue v) }; } - private static void Apply(ParameterTenantValueRecord t, ParameterTenantValue v) + private static void Apply(ParameterTenantValueRecord t, ParameterTenantValue value) { - var audit = v.Props.Audit.GetValue(); - t.OverrideValue = v.Props.Value.Value; - t.StatusId = v.Props.Status.Id; - t.Version = v.Props.Version; + var audit = value.Props.Audit.GetValue(); + t.OverrideValue = value.Props.Value.Value; + t.StatusId = value.Props.Status.Id; + t.Version = value.Props.Version; t.UpdatedBy = audit.UpdatedBy; t.UpdatedAtUtc = audit.UpdatedAt; t.AuditTimeSpan = audit.TimeSpan; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/IDistributedLockProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/IDistributedLockProvider.cs index 149e7b1d..3ef2ea75 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/IDistributedLockProvider.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/IDistributedLockProvider.cs @@ -4,7 +4,7 @@ namespace Ums.Infrastructure.Persistence; /// /// Abstraction for database-level distributed locks, enabling the architecture -/// to support multiple providers (e.g. SQL Server's sp_getapplock vs PostgreSQL's pg_advisory_lock) +/// to support the PostgreSQL advisory-lock implementation (pg_advisory_lock). /// without coupling the bootstrapper or outbox dispatcher to a specific database engine. /// public interface IDistributedLockProvider diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/PasswordResetTokenRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/PasswordResetTokenRecordConfiguration.cs new file mode 100644 index 00000000..87c2755d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/PasswordResetTokenRecordConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Identity.Entities; + +namespace Ums.Infrastructure.Persistence.Identity.Configurations; + +public sealed class PasswordResetTokenRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PasswordResetTokens", IdentityPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + // Único: el canje localiza el token por hash, y dos filas con el mismo hash harían + // ambigua la resolución de cuál gastar. + builder.Property(x => x.TokenHash).HasMaxLength(128).IsRequired(); + builder.HasIndex(x => x.TokenHash).IsUnique(); + + builder.Property(x => x.Status).HasMaxLength(20).IsRequired(); + builder.Property(x => x.InvalidatedReason).HasMaxLength(60); + + builder.HasIndex(x => new { x.TenantId, x.UserId }); // invalidación de los vivos del usuario + builder.HasIndex(x => x.ExpiresAtUtc); // purga de vencidos + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/RefreshTokenRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/RefreshTokenRecordConfiguration.cs new file mode 100644 index 00000000..e8cada7b --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/RefreshTokenRecordConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Identity.Entities; + +namespace Ums.Infrastructure.Persistence.Identity.Configurations; + +public sealed class RefreshTokenRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RefreshTokens", IdentityPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + // Solo el hash del token; único para localizarlo en renovación/revocación. + builder.Property(x => x.TokenHash).HasMaxLength(128).IsRequired(); + builder.HasIndex(x => x.TokenHash).IsUnique(); + + builder.Property(x => x.Status).HasMaxLength(20).IsRequired(); + builder.Property(x => x.RevokedReason).HasMaxLength(60); + + builder.HasIndex(x => x.FamilyId); // invalidación de familia (reuso/revocación) + builder.HasIndex(x => new { x.TenantId, x.UserId }); + builder.HasIndex(x => x.ExpiresAtUtc); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchLifecycleEntryRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchLifecycleEntryRecordConfiguration.cs new file mode 100644 index 00000000..25d6690d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchLifecycleEntryRecordConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Identity.Entities; + +namespace Ums.Infrastructure.Persistence.Identity.Configurations; + +public sealed class TenantBranchLifecycleEntryRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TenantBranchLifecycleEntries", IdentityPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.ActorId).HasMaxLength(100).IsRequired(); + builder.Property(x => x.NameSnapshot).HasMaxLength(200).IsRequired(); + builder.Property(x => x.GeofencingSnapshot).HasMaxLength(4000); + builder.Property(x => x.Reason).HasMaxLength(500); + builder.Property(x => x.EpisodeId).IsRequired(); + builder.Property(x => x.OccurredAtUtc).IsRequired(); + + // La consulta de la bitácora es siempre «los episodios de ESTA sucursal en orden»; el índice + // compuesto la resuelve sin ordenar en memoria. + builder.HasIndex(x => new { x.BranchId, x.OccurredAtUtc }); + + // Aislamiento por inquilino: el filtro global y la política RLS filtran por TenantId. + builder.HasIndex(x => x.TenantId); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchRecordConfiguration.cs index a0182507..5ad43e44 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchRecordConfiguration.cs @@ -18,6 +18,25 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + // ADR-0164 §2.1: cierre definitivo. + builder.Property(x => x.IsClosed).HasDefaultValue(false).IsRequired(); + builder.Property(x => x.ClosedBy).HasMaxLength(100); + + // ADR-0164 §2.3: el índice único NO se filtra por el estado de cierre. Es la mitad de base de + // datos de la misma decisión que toma `Tenant.AddBranch`: el código de una sucursal cerrada + // queda ocupado para siempre, así que un alta con ese código choca aquí igual que arriba y + // ninguna consulta histórica queda ambigua. builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + + // Índice parcial que cubre el predicado de las LECTURAS de listado (`IsClosed = false`), + // el mismo patrón que ya usan Tenants y UserAccounts para su borrado lógico. + builder.HasIndex(x => x.IsClosed).HasFilter("\"IsClosed\" = false"); + + // La bitácora cuelga de la sucursal. El borrado en cascada es teórico —la sucursal ya no se + // borra nunca— pero se declara para que el modelo no dependa de esa promesa. + builder.HasMany(x => x.LifecycleEntries) + .WithOne(x => x.Branch) + .HasForeignKey(x => x.BranchId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantParameterRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantParameterRecordConfiguration.cs index 144ebcd2..491a4a67 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantParameterRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantParameterRecordConfiguration.cs @@ -17,6 +17,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ValueTypeId).IsRequired(); builder.Property(x => x.CategoryId).IsRequired(); builder.Property(x => x.IsActive).IsRequired(); + // REC-16 (mismo patrón que TenantRecord/UserAccountRecord): la marca de borrado lógico nace en + // false para toda fila existente, así que la migración no reescribe datos. + builder.Property(x => x.IsDeleted).HasDefaultValue(false).IsRequired(); builder.Property(x => x.IsSensitive).IsRequired(); builder.Property(x => x.DefaultValue).HasMaxLength(4000); builder.Property(x => x.AllowedValues).HasMaxLength(2000); @@ -24,9 +27,16 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + // El índice único parcial sigue cubriendo solo las filas ACTIVAS. Un parámetro eliminado + // lógicamente está siempre inactivo (Delete exige desactivación previa), así que queda fuera + // del índice y no impide dar de alta de nuevo el mismo código. builder.HasIndex(x => new { x.TenantId, x.Code, x.IsActive }) .HasFilter("\"IsActive\" = true") .IsUnique() .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive"); + + // Índice parcial sobre las filas vivas: es el predicado que el filtro global añade a TODA + // consulta de parámetros, igual que en Tenants y UserAccounts. + builder.HasIndex(x => x.IsDeleted).HasFilter("\"IsDeleted\" = false"); } } \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantRecordConfiguration.cs index 67d64ba9..9e1cd769 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantRecordConfiguration.cs @@ -28,6 +28,13 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.Code).IsUnique(); builder.HasIndex(x => x.ParentTenantId); + // ADR-0071 / FS-26 (G-025): invariante de un único propietario de gestión (Admin Root) + // por ecosistema. Índice único filtrado: a lo sumo una fila con IsManagementOwner = true. + builder.HasIndex(x => x.IsManagementOwner) + .IsUnique() + .HasFilter("\"IsManagementOwner\" = true") + .HasDatabaseName("IX_Tenants_SingleManagementOwner"); + builder.HasMany(x => x.Branches) .WithOne(x => x.Tenant) .HasForeignKey(x => x.TenantId) @@ -37,10 +44,5 @@ public void Configure(EntityTypeBuilder builder) .WithOne(x => x.Tenant) .HasForeignKey(x => x.TenantId) .OnDelete(DeleteBehavior.Cascade); - - builder.HasOne(x => x.Branding) - .WithOne(x => x.Tenant) - .HasForeignKey(x => x.TenantId) - .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/UserAccountRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/UserAccountRecordConfiguration.cs index abe2491c..3168aa64 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/UserAccountRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/UserAccountRecordConfiguration.cs @@ -19,6 +19,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.Property(x => x.RowVersion).IsRowVersion(); // FIX-03: optimistic concurrency + // ADR-UMS-095: bloqueo temporal de cuenta por intentos fallidos. + builder.Property(x => x.FailedLoginAttempts).HasDefaultValue(0).IsRequired(); + // REC-16: Soft-delete + GDPR builder.Property(x => x.IsDeleted).HasDefaultValue(false).IsRequired(); builder.Property(x => x.DeletedBy).HasMaxLength(100); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/PasswordResetTokenRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/PasswordResetTokenRecord.cs new file mode 100644 index 00000000..93e29e1f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/PasswordResetTokenRecord.cs @@ -0,0 +1,35 @@ +namespace Ums.Infrastructure.Persistence.Identity.Entities; + +using Ums.Application.Identity.Auth; + +/// +/// Registro de persistencia de un token de restablecimiento de contraseña (G-188). +/// +/// Seguridad: se guarda ÚNICAMENTE el . El plaintext sale una +/// sola vez hacia el buzón del titular y no se persiste, ni se registra en logs, ni viaja en +/// ninguna respuesta HTTP. +/// +public class PasswordResetTokenRecord +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + public Guid UserId { get; set; } + + /// SHA-256 (hex) del token en claro. + public string TokenHash { get; set; } = string.Empty; + + /// Active | Used | Invalidated. + public string Status { get; set; } = PasswordResetTokenStatuses.Active; + + public DateTime IssuedAtUtc { get; set; } + + public DateTime ExpiresAtUtc { get; set; } + + /// Momento en que el token dejó de ser canjeable, sea por canje o por invalidación. + public DateTime? ConsumedAtUtc { get; set; } + + /// Motivo de la invalidación (reissue, password-reset, account-change). + public string? InvalidatedReason { get; set; } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/RefreshTokenRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/RefreshTokenRecord.cs new file mode 100644 index 00000000..090c1c74 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/RefreshTokenRecord.cs @@ -0,0 +1,47 @@ +namespace Ums.Infrastructure.Persistence.Identity.Entities; + +using Ums.Application.Identity.Auth; + +/// +/// Registro de persistencia de un refresh token (ADR-UMS-091 / FR-015/016). +/// +/// Seguridad: se guarda ÚNICAMENTE el (SHA-256 del +/// plaintext); el token en claro se devuelve una sola vez al cliente y nunca se persiste, +/// registra ni aparece en el grafo. La búsqueda en renovación/revocación se hace por hash. +/// +/// agrupa la cadena de rotaciones de una misma sesión: la +/// detección de reuso invalida la familia entera. es +/// Active | Rotated | Revoked | Used. +/// +public class RefreshTokenRecord +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + public Guid UserId { get; set; } + + /// Familia de la cadena de rotación (misma sesión). + public Guid FamilyId { get; set; } + + /// SHA-256 (hex) del token en claro. Nunca se guarda el plaintext. + public string TokenHash { get; set; } = string.Empty; + + /// Active | Rotated | Revoked | Used. + public string Status { get; set; } = RefreshTokenStatuses.Active; + + public DateTime IssuedAtUtc { get; set; } + + public DateTime ExpiresAtUtc { get; set; } + + /// Nº de renovaciones ya realizadas en esta familia (tope opcional). + public int RenewalCount { get; set; } + + /// Token que reemplazó a este al rotar (cadena de rotación). + public Guid? ReplacedByTokenId { get; set; } + + public DateTime? RevokedAtUtc { get; set; } + + /// Motivo de revocación (logout, block, permission-change, reuse). + public string? RevokedReason { get; set; } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchLifecycleEntryRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchLifecycleEntryRecord.cs new file mode 100644 index 00000000..5781059d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchLifecycleEntryRecord.cs @@ -0,0 +1,33 @@ +namespace Ums.Infrastructure.Persistence.Identity.Entities; + +/// +/// Fila de la BITÁCORA de una sucursal (ADR-0164). Append-only por diseño: no lleva columnas de +/// auditoría de modificación porque un asiento no se modifica —el instante y el autor del episodio +/// SON el dato—, y una bitácora que se puede reescribir no prueba nada. +/// +public sealed class TenantBranchLifecycleEntryRecord +{ + public Guid Id { get; set; } + + /// Inquilino dueño de la sucursal. Sostiene el filtro global y la política RLS. + public Guid TenantId { get; set; } + + public Guid BranchId { get; set; } + + /// Identificador de BranchLifecycleEpisode. Estable: se persiste. + public int EpisodeId { get; set; } + + public DateTime OccurredAtUtc { get; set; } + + public string ActorId { get; set; } = string.Empty; + + /// Nombre de la sucursal en el instante del episodio (foto de la época). + public string NameSnapshot { get; set; } = string.Empty; + + /// Geocerca en el instante del episodio; nula si la sucursal no tenía. + public string? GeofencingSnapshot { get; set; } + + public string? Reason { get; set; } + + public TenantBranchRecord Branch { get; set; } = default!; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchRecord.cs index 6a4ae27c..6c402cae 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchRecord.cs @@ -10,6 +10,13 @@ public sealed class TenantBranchRecord : IAuditableRecord public string Name { get; set; } = string.Empty; public string? GeofencingMetadata { get; set; } public bool IsActive { get; set; } + + // ADR-0164 §2.1: cierre definitivo. La fila permanece; lo que cambia es que deja de listarse y + // deja de admitir transiciones. `IsActive` sigue siendo el eje reversible y no se toca. + public bool IsClosed { get; set; } + public DateTime? ClosedAtUtc { get; set; } + public string? ClosedBy { get; set; } + public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public string? UpdatedBy { get; set; } @@ -17,4 +24,7 @@ public sealed class TenantBranchRecord : IAuditableRecord public string AuditTimeSpan { get; set; } = string.Empty; public TenantRecord Tenant { get; set; } = default!; + + /// Bitácora de episodios de la sucursal. Solo crece: nunca se actualiza ni se borra. + public ICollection LifecycleEntries { get; set; } = new List(); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantParameterRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantParameterRecord.cs index 3a0b635b..155cc730 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantParameterRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantParameterRecord.cs @@ -12,6 +12,11 @@ public sealed class TenantParameterRecord : IAuditableRecord public int ValueTypeId { get; set; } public int CategoryId { get; set; } public bool IsActive { get; set; } + /// + /// Borrado LÓGICO (política del propietario: el borrado físico no se permite). Independiente de + /// : inactivo es reversible, eliminado es terminal. + /// + public bool IsDeleted { get; set; } public bool IsSensitive { get; set; } public string? DefaultValue { get; set; } public string? AllowedValues { get; set; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantRecord.cs index 767271fb..a38d7857 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantRecord.cs @@ -12,6 +12,10 @@ public sealed class TenantRecord : IAuditableRecord public string? CompanyReference { get; set; } public Guid? ParentTenantId { get; set; } public bool IsManagementOwner { get; set; } + + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino. Columna nullable/retrocompatible: + // los inquilinos existentes quedan NULL y la resolución de IdP omite el filtro por suite. + public Guid? DefaultSystemSuiteId { get; set; } public int StatusId { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } @@ -27,5 +31,7 @@ public sealed class TenantRecord : IAuditableRecord public List Branches { get; set; } = []; public List IdentityProviders { get; set; } = []; + + // Branding por inquilino: activo propio del satélite (no existe en la plataforma de origen). public TenantBrandingRecord? Branding { get; set; } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/UserAccountRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/UserAccountRecord.cs index e7746c34..d03c2c29 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/UserAccountRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/UserAccountRecord.cs @@ -23,6 +23,10 @@ public sealed class UserAccountRecord : IAuditableRecord // FS-19: Validity period management public DateTime? ExpiresAtUtc { get; set; } + // ADR-UMS-095: bloqueo temporal de cuenta por intentos fallidos de autenticación. + public int FailedLoginAttempts { get; set; } + public DateTime? LockedUntilUtc { get; set; } + // REC-16: Soft-delete + GDPR anonymization public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/InMemoryTenantSignupRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/InMemoryTenantSignupRequestRepository.cs index af636ae9..be5c2c70 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/InMemoryTenantSignupRequestRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/InMemoryTenantSignupRequestRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory using System.Collections.Concurrent; using Ums.Domain.Identity; using Ums.Domain.Identity.TenantSignupRequest; @@ -50,3 +51,5 @@ public void Seed(TenantSignupRequestAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PasswordResetTokenStore.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PasswordResetTokenStore.cs new file mode 100644 index 00000000..9b80329b --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PasswordResetTokenStore.cs @@ -0,0 +1,126 @@ +namespace Ums.Infrastructure.Persistence.Identity; + +using Microsoft.EntityFrameworkCore; +using Ums.Application.Identity.Auth; +using Ums.Infrastructure.Persistence.Identity.Entities; + +/// +/// Implementación de sobre . +/// Persiste solo el hash del token (G-188). Funciona con Npgsql y con el EF InMemory de los tests. +/// +public sealed class PasswordResetTokenStore : IPasswordResetTokenStore +{ + private readonly UmsPlatformDbContext _db; + + public PasswordResetTokenStore(UmsPlatformDbContext db) + { + _db = db; + } + + public async Task IssueAsync( + Guid tenantId, + Guid userId, + string tokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default) + { + // Un solo secreto pendiente por usuario: pedir el restablecimiento de nuevo anula el + // enlace anterior. Sin esto, cada solicitud dejaría un token vivo más y la ventana de + // exposición crecería con el número de solicitudes, no con la vida del token. + MarkAsInvalidated( + await ActiveTokensOf(tenantId, userId).ToListAsync(cancellationToken).ConfigureAwait(false), + "reissue", + issuedAtUtc); + + _db.PasswordResetTokens.Add(new PasswordResetTokenRecord + { + Id = Guid.NewGuid(), + TenantId = tenantId, + UserId = userId, + TokenHash = tokenHash, + Status = PasswordResetTokenStatuses.Active, + IssuedAtUtc = issuedAtUtc, + ExpiresAtUtc = expiresAtUtc, + }); + + // Un único SaveChanges ⇒ la invalidación de los anteriores y la emisión del nuevo + // entran juntas o no entra ninguna. + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task FindByHashAsync( + string tokenHash, + CancellationToken cancellationToken = default) + { + var record = await _db.PasswordResetTokens + .AsNoTracking() + .FirstOrDefaultAsync(r => r.TokenHash == tokenHash, cancellationToken) + .ConfigureAwait(false); + + return record is null + ? null + : new PasswordResetTokenSnapshot( + record.Id, + record.TenantId, + record.UserId, + record.Status, + record.IssuedAtUtc, + record.ExpiresAtUtc); + } + + public async Task ConsumeAsync( + Guid tokenId, + DateTime consumedAtUtc, + CancellationToken cancellationToken = default) + { + var record = await _db.PasswordResetTokens + .FirstOrDefaultAsync(r => r.Id == tokenId && r.Status == PasswordResetTokenStatuses.Active, cancellationToken) + .ConfigureAwait(false); + + if (record is null) + { + return; + } + + record.Status = PasswordResetTokenStatuses.Used; + record.ConsumedAtUtc = consumedAtUtc; + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task InvalidateActiveForUserAsync( + Guid tenantId, + Guid userId, + string reason, + DateTime invalidatedAtUtc, + CancellationToken cancellationToken = default) + { + var records = await ActiveTokensOf(tenantId, userId).ToListAsync(cancellationToken).ConfigureAwait(false); + if (records.Count == 0) + { + return; + } + + MarkAsInvalidated(records, reason, invalidatedAtUtc); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private IQueryable ActiveTokensOf(Guid tenantId, Guid userId) => + _db.PasswordResetTokens.Where(r => + r.TenantId == tenantId + && r.UserId == userId + && r.Status == PasswordResetTokenStatuses.Active); + + private static void MarkAsInvalidated( + List records, + string reason, + DateTime invalidatedAtUtc) + { + foreach (var record in records) + { + record.Status = PasswordResetTokenStatuses.Invalidated; + record.InvalidatedReason = reason; + record.ConsumedAtUtc = invalidatedAtUtc; + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantRepository.cs index 1a4bb081..846ddc5f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantRepository.cs @@ -1,6 +1,8 @@ using System.Data; using Microsoft.EntityFrameworkCore; +using Ums.Domain.Enums; using Ums.Domain.Identity; +using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Kernel; using Ums.Infrastructure.Persistence; using Ums.Infrastructure.Persistence.Identity.Entities; @@ -22,14 +24,8 @@ public sealed class PostgreSqlTenantRepository(UmsPlatformDbContext dbContext) : .AsSingleQuery() .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - if (record is not null) - { - await LoadSqliteTenantChildrenAsync(record, cancellationToken); - } - return record is null ? null : Rehydrate(record); } @@ -41,24 +37,46 @@ public sealed class PostgreSqlTenantRepository(UmsPlatformDbContext dbContext) : // del CONTEXTO; un admin interno que provisiona sobre otro inquilino cargaría una colección vacía y // la guarda en memoria no vería el duplicado. La lectura sigue aislada (esta consulta no expone // datos: solo devuelve un booleano y se usa dentro de un handler ya acotado a management-owner). + // + // ADR-0164 §2.3: tampoco filtra por `IsClosed`. El código de una sucursal cerrada sigue ocupado, + // y esta consulta es la que hace que el alta lo diga con un conflicto legible en vez de dejar + // que reviente el índice único con un 23505. public Task BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) => dbContext.TenantBranches .IgnoreQueryFilters() .AnyAsync(b => b.TenantId == tenantId && b.Code == code, cancellationToken); + /// + public async Task> GetBranchLifecycleAsync( + Guid tenantId, Guid branchId, CancellationToken cancellationToken = default) + { + var filas = await dbContext.TenantBranchLifecycleEntries + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.BranchId == branchId) + .OrderBy(x => x.OccurredAtUtc) + .ThenBy(x => x.EpisodeId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return filas.Select(f => new BranchLifecycleEntry( + f.Id, + f.TenantId, + f.BranchId, + DomainEnumerationMapper.FromValue(f.EpisodeId), + f.OccurredAtUtc, + f.ActorId, + f.NameSnapshot, + f.GeofencingSnapshot, + f.Reason)).ToList(); + } + public async Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) { var record = await dbContext.Tenants .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .FirstOrDefaultAsync(x => x.Code == code, cancellationToken); - if (record is not null) - { - await LoadSqliteTenantChildrenAsync(record, cancellationToken); - } - return record is null ? null : Rehydrate(record); } @@ -76,7 +94,6 @@ public async Task> GetAllAsync(Guid? tenantId = n .AsSplitQuery() .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .OrderBy(x => x.Name) .ToListAsync(cancellationToken); @@ -86,7 +103,7 @@ public async Task> GetAllAsync(Guid? tenantId = n /// public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( int page, int pageSize, string? search, string? status, string sortBy, string sortOrder, - Guid? tenantId = null, CancellationToken cancellationToken = default) + Guid? tenantId = null, CancellationToken cancellationToken = default, string? searchField = null) { // REC-12: Apply all filtering at the DB level before Skip/Take to avoid loading full tables. var query = dbContext.Tenants.AsQueryable(); @@ -101,7 +118,10 @@ public async Task> GetAllAsync(Guid? tenantId = n if (!string.IsNullOrWhiteSpace(search)) { var lower = search.ToLower(); - query = (sortBy.ToLower()) switch + // El campo de búsqueda lo determina `searchField` (parámetro `criteria` del API), no el + // orden: buscar por código no debe exigir ordenar por código. Fallback a sortBy si viene vacío. + var field = string.IsNullOrWhiteSpace(searchField) ? sortBy : searchField; + query = (field.ToLower()) switch { "code" => query.Where(t => t.Code.ToLower().Contains(lower)), _ => query.Where(t => t.Name.ToLower().Contains(lower)), @@ -134,7 +154,6 @@ public async Task> GetAllAsync(Guid? tenantId = n .AsSplitQuery() .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); @@ -176,7 +195,6 @@ public async Task UpdateAsync(TenantAggregate aggregate, CancellationToken cance var existing = await dbContext.Tenants .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) ?? throw new InvalidOperationException($"Tenant {aggregate.Props.Id.GetValue()} does not exist."); @@ -191,6 +209,8 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = { // Capture outbox messages BEFORE committing so events are not lost on save failure. // MarkChangesAsCommitted() is called AFTER SaveChangesAsync succeeds (FIX-01). + // Publicación pre-commit = bug (mensaje fantasma); se stagea vía outbox y se entrega + // post-commit. Patrón e interpretación: KB-TXN-001 (evolith-core); ADR-0098 D4.1 / G-066. foreach (var aggregate in _trackedAggregates) { await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); @@ -207,10 +227,23 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); } + catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) + when (ex.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation }) + { + // G-045/G-037: red de seguridad. Si una violación de índice único (23505) escapa a las + // guardas de aplicación (p.ej. IX_Tenants_SingleManagementOwner ante una carrera), + // la traducimos a 409 Conflict en vez de dejar que caiga a 500. + var entry = ex.Entries.FirstOrDefault(); + var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); + throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); + } foreach (var aggregate in _trackedAggregates) { aggregate.DomainEvents.MarkChangesAsCommitted(); + // Los asientos de bitácora ya están en la base: vaciar el búfer evita que un segundo + // guardado del mismo agregado vuelva a intentar insertarlos (ADR-0164). + aggregate.MarkBranchLifecycleAsCommitted(); } _trackedAggregates.Clear(); @@ -220,74 +253,7 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = public void Dispose() => dbContext.Dispose(); private static TenantAggregate Rehydrate(TenantRecord record) - => IdentityAggregateFactory.RehydrateTenant(record, record.Branches, record.IdentityProviders, record.Branding); - - private async Task LoadSqliteTenantChildrenAsync(TenantRecord record, CancellationToken cancellationToken) - { - if (!dbContext.Database.IsSqlite()) - { - return; - } - - var tenantId = record.Id.ToString(); - - record.Branches = await LoadSqliteTenantBranchesAsync(tenantId, cancellationToken); - - record.IdentityProviders = await dbContext.TenantIdentityProviders - .FromSqlInterpolated($"SELECT * FROM TenantIdentityProviders WHERE lower(TenantId) = lower({tenantId})") - .ToListAsync(cancellationToken); - - record.Branding = await dbContext.TenantBrandings - .FromSqlInterpolated($"SELECT * FROM TenantBrandings WHERE lower(TenantId) = lower({tenantId})") - .FirstOrDefaultAsync(cancellationToken); - } - - private async Task> LoadSqliteTenantBranchesAsync( - string tenantId, - CancellationToken cancellationToken) - { - var connection = dbContext.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - { - await connection.OpenAsync(cancellationToken); - } - - await using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT Id, TenantId, Code, Name, GeofencingMetadata, IsActive, CreatedBy, CreatedAtUtc, UpdatedBy, UpdatedAtUtc, AuditTimeSpan - FROM TenantBranches - WHERE lower(TenantId) = lower($tenantId) - ORDER BY Code - """; - - var parameter = command.CreateParameter(); - parameter.ParameterName = "$tenantId"; - parameter.Value = tenantId; - command.Parameters.Add(parameter); - - var branches = new List(); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - - while (await reader.ReadAsync(cancellationToken)) - { - branches.Add(new TenantBranchRecord - { - Id = Guid.Parse(reader.GetString(0)), - TenantId = Guid.Parse(reader.GetString(1)), - Code = reader.GetString(2), - Name = reader.GetString(3), - GeofencingMetadata = reader.IsDBNull(4) ? null : reader.GetString(4), - IsActive = reader.GetBoolean(5), - CreatedBy = reader.GetString(6), - CreatedAtUtc = reader.GetDateTime(7), - UpdatedBy = reader.IsDBNull(8) ? null : reader.GetString(8), - UpdatedAtUtc = reader.IsDBNull(9) ? null : reader.GetDateTime(9), - AuditTimeSpan = reader.GetString(10), - }); - } - - return branches; - } + => IdentityAggregateFactory.RehydrateTenant(record, record.Branches, record.IdentityProviders); private static TenantRecord ToRecord(TenantAggregate aggregate) { @@ -303,6 +269,8 @@ private static TenantRecord ToRecord(TenantAggregate aggregate) CompanyReference = aggregate.CompanyReference?.GetValue(), ParentTenantId = aggregate.ParentTenantId?.GetValue(), IsManagementOwner = aggregate.IsManagementOwner, + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino (nullable/retrocompatible). + DefaultSystemSuiteId = aggregate.DefaultSystemSuiteId?.GetValue(), StatusId = aggregate.Status.Id, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, @@ -320,11 +288,17 @@ private static TenantRecord ToRecord(TenantAggregate aggregate) Name = branch.Name.GetValue(), GeofencingMetadata = branch.GeofencingMetadata?.GetValue(), IsActive = branch.IsActive, + IsClosed = branch.IsClosed, + ClosedAtUtc = branch.ClosedAtUtc, + ClosedBy = branch.ClosedBy, CreatedBy = a.CreatedBy, CreatedAtUtc = a.CreatedAt, UpdatedBy = a.UpdatedBy, UpdatedAtUtc = a.UpdatedAt, AuditTimeSpan = a.TimeSpan, + // Solo los asientos de ESTA unidad de trabajo: la bitácora histórica no se carga + // con el agregado, así que aquí no hay nada que reescribir, solo que añadir. + LifecycleEntries = branch.PendingLifecycleEntries.Select(ToLifecycleRecord).ToList(), }; }).ToList(), IdentityProviders = aggregate.IdentityProviders.Select(provider => @@ -346,34 +320,6 @@ private static TenantRecord ToRecord(TenantAggregate aggregate) AuditTimeSpan = a.TimeSpan, }; }).ToList(), - Branding = aggregate.Branding is null ? null : ToBrandingRecord(aggregate.Branding), - }; - } - - private static TenantBrandingRecord ToBrandingRecord(Ums.Domain.Identity.Tenant.Branding.Branding branding) - { - var audit = branding.Props.Audit.GetValue(); - return new TenantBrandingRecord - { - Id = branding.Props.Id.GetValue(), - TenantId = branding.Props.TenantId.GetValue(), - Logo = branding.Logo.GetValue(), - LogoFormatId = branding.LogoFormat.Id, - PrimaryColor = branding.PrimaryColor.GetValue(), - BackgroundStyleId = branding.BackgroundStyle.Id, - HeadlineText = branding.HeadlineText.GetValue(), - SecondaryText = branding.SecondaryText.GetValue(), - PrimaryButtonLabel = branding.PrimaryButtonLabel.GetValue(), - FooterText = branding.FooterText.GetValue(), - CustomDomain = branding.CustomDomain?.GetValue(), - DnsVerificationStatusId = branding.DnsVerificationStatus.Id, - DnsCnameTarget = branding.DnsCnameTarget.GetValue(), - MagicLinkFallbackEnabled = branding.MagicLinkFallbackEnabled, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, }; } @@ -388,6 +334,7 @@ private void Apply(TenantRecord target, TenantAggregate source) target.CompanyReference = replacement.CompanyReference; target.ParentTenantId = replacement.ParentTenantId; target.IsManagementOwner = replacement.IsManagementOwner; + target.DefaultSystemSuiteId = replacement.DefaultSystemSuiteId; target.StatusId = replacement.StatusId; target.CreatedBy = replacement.CreatedBy; target.CreatedAtUtc = replacement.CreatedAtUtc; @@ -398,19 +345,81 @@ private void Apply(TenantRecord target, TenantAggregate source) UpsertBranches(target.Branches, replacement.Branches); UpsertIdentityProviders(target.IdentityProviders, replacement.IdentityProviders); - target.Branding = replacement.Branding; } private void UpsertBranches(ICollection target, IEnumerable source) { + var reemplazo = source.ToList(); + EfChildCollectionReconciler.ReconcileById( dbContext, target, - source, + reemplazo, branch => branch.Id, UpdateBranch); + + AppendLifecycleEntries(target, reemplazo); + } + + /// + /// Vuelca los asientos de bitácora del agregado en la MISMA transacción que el cambio de estado. + /// + /// Va aquí y no en un manejador de eventos post-commit porque ese despacho es, por + /// contrato (ADR-0098 D4), best-effort: un fallo se registra como advertencia y no revierte + /// nada. Una bitácora que puede perder episodios en silencio no sirve como prueba de auditoría. + /// + /// Solo AÑADE. No actualiza ni borra, y por eso no usa el reconciliador: un asiento es + /// inmutable, y la colección rastreada no está cargada —la bitácora no viaja con el agregado—, + /// así que reconciliar por ausencia habría intentado borrar toda la historia. + /// + private void AppendLifecycleEntries(ICollection target, IEnumerable source) + { + var porId = target.ToDictionary(b => b.Id); + + foreach (var origen in source) + { + if (origen.LifecycleEntries.Count == 0) continue; + if (!porId.TryGetValue(origen.Id, out var destino)) continue; + + // Cuando la sucursal es NUEVA, el reconciliador ya insertó en `target` el mismo objeto + // que trae los asientos: no hay nada que trasvasar, solo que marcarlos como altas. + var esLaMismaInstancia = ReferenceEquals(destino, origen); + var yaPresentes = esLaMismaInstancia + ? [] + : destino.LifecycleEntries.Select(e => e.Id).ToHashSet(); + + foreach (var asiento in origen.LifecycleEntries.ToList()) + { + if (!esLaMismaInstancia) + { + if (!yaPresentes.Add(asiento.Id)) continue; + + asiento.BranchId = destino.Id; + destino.LifecycleEntries.Add(asiento); + } + + // Se marca explícitamente en vez de confiar en que la detección de cambios descubra + // los hijos nuevos a través de la navegación: el asiento es la evidencia de + // auditoría, y no puede depender de una inferencia. + dbContext.Entry(asiento).State = EntityState.Added; + } + } } + private static TenantBranchLifecycleEntryRecord ToLifecycleRecord(BranchLifecycleEntry entry) + => new() + { + Id = entry.Id, + TenantId = entry.TenantId, + BranchId = entry.BranchId, + EpisodeId = entry.Episode.Id, + OccurredAtUtc = entry.OccurredAtUtc, + ActorId = entry.ActorId, + NameSnapshot = entry.NameSnapshot, + GeofencingSnapshot = entry.GeofencingSnapshot, + Reason = entry.Reason, + }; + private void UpsertIdentityProviders(ICollection target, IEnumerable source) { EfChildCollectionReconciler.ReconcileById( @@ -428,6 +437,9 @@ private static void UpdateBranch(TenantBranchRecord target, TenantBranchRecord s target.Name = source.Name; target.GeofencingMetadata = source.GeofencingMetadata; target.IsActive = source.IsActive; + target.IsClosed = source.IsClosed; + target.ClosedAtUtc = source.ClosedAtUtc; + target.ClosedBy = source.ClosedBy; target.CreatedBy = source.CreatedBy; target.CreatedAtUtc = source.CreatedAtUtc; target.UpdatedBy = source.UpdatedBy; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantSignupRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantSignupRequestRepository.cs index 25e5e580..7a3b6d6a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantSignupRequestRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantSignupRequestRepository.cs @@ -137,7 +137,7 @@ private static TenantSignupRequestRecord ToRecord(TenantSignupRequestAggregate a }; } - private void Apply(TenantSignupRequestRecord target, TenantSignupRequestAggregate source) + private static void Apply(TenantSignupRequestRecord target, TenantSignupRequestAggregate source) { var replacement = ToRecord(source); target.CompanyName = replacement.CompanyName; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlUserAccountRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlUserAccountRepository.cs index 4b364ad4..6d5eb2ff 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlUserAccountRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlUserAccountRepository.cs @@ -155,6 +155,7 @@ public async Task SoftDeleteAsync(Guid id, string deletedBy, CancellationT // so modifying `record` here is additive to the changes Apply() already staged. var record = await dbContext.UserAccounts .IgnoreQueryFilters() + .Include(x => x.PasswordCredentials) .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted, cancellationToken); if (record is null) return false; @@ -166,6 +167,14 @@ public async Task SoftDeleteAsync(Guid id, string deletedBy, CancellationT // GDPR: replace PII with a deterministic, irreversible token (SHA-256 of the GUID). record.Email = BuildAnonymizedEmail(id); record.IdentityReference = null; + // GDPR (user-account.md:464): DisplayName is PII and the BCrypt hash must be nulled on + // terminal deletion — the presence of a credential record would otherwise imply a local + // account. Both are cleared here so no reversible secret or PII survives the soft-delete. + record.DisplayName = null; + foreach (var credential in record.PasswordCredentials) + { + credential.PasswordHash = string.Empty; + } record.AnonymizedAtUtc = now; // EF change tracker now has IsDeleted=true + anonymized email pending; SaveChangesAsync // (called from SaveEntitiesAsync by the handler) will commit everything atomically. @@ -238,6 +247,11 @@ public Task CountActiveByTenantAsync(Guid tenantId, CancellationToken cance u => u.TenantId == tenantId && u.StatusId == 2 /* Active */, cancellationToken); + public Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default) + => dbContext.UserAccounts.CountAsync( + u => u.BranchId == branchId && u.StatusId == 2 /* Active */, + cancellationToken); + private static UserAccountAggregate Rehydrate(UserAccountRecord record) => IdentityAggregateFactory.RehydrateUserAccount(record, record.MfaEnrollments, record.PasswordCredentials); @@ -256,6 +270,9 @@ private static UserAccountRecord ToRecord(UserAccountAggregate aggregate) IdentityReference = aggregate.IdentityReference?.GetValue(), IdentityReferenceTypeId = aggregate.IdentityReferenceType?.Id, ExpiresAtUtc = aggregate.ExpiresAt.HasValue ? aggregate.ExpiresAt.Value.UtcDateTime : null, + // ADR-UMS-095: bloqueo temporal por intentos fallidos. + FailedLoginAttempts = aggregate.FailedLoginAttempts, + LockedUntilUtc = aggregate.LockedUntil.HasValue ? aggregate.LockedUntil.Value.UtcDateTime : null, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -309,6 +326,9 @@ private void Apply(UserAccountRecord target, UserAccountAggregate source) target.IdentityReference = replacement.IdentityReference; target.IdentityReferenceTypeId = replacement.IdentityReferenceTypeId; target.ExpiresAtUtc = replacement.ExpiresAtUtc; + // ADR-UMS-095: bloqueo temporal por intentos fallidos. + target.FailedLoginAttempts = replacement.FailedLoginAttempts; + target.LockedUntilUtc = replacement.LockedUntilUtc; target.CreatedBy = replacement.CreatedBy; target.CreatedAtUtc = replacement.CreatedAtUtc; target.UpdatedBy = replacement.UpdatedBy; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/RefreshTokenStore.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/RefreshTokenStore.cs new file mode 100644 index 00000000..141b62f0 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/RefreshTokenStore.cs @@ -0,0 +1,161 @@ +namespace Ums.Infrastructure.Persistence.Identity; + +using Microsoft.EntityFrameworkCore; +using Ums.Application.Identity.Auth; +using Ums.Infrastructure.Persistence.Identity.Entities; + +/// +/// Implementación de sobre . +/// Persiste solo el hash del token (ADR-UMS-091). Funciona con el proveedor Npgsql y con +/// el EF InMemory de los tests; bajo la postura fail-closed solo se invoca cuando un +/// inquilino activa la capacidad, así que en tests (deshabilitado por defecto) no se toca. +/// +public sealed class RefreshTokenStore : IRefreshTokenStore +{ + private readonly UmsPlatformDbContext _db; + + public RefreshTokenStore(UmsPlatformDbContext db) + { + _db = db; + } + + public async Task IssueAsync( + Guid tenantId, + Guid userId, + Guid familyId, + string tokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default) + { + var record = new RefreshTokenRecord + { + Id = Guid.NewGuid(), + TenantId = tenantId, + UserId = userId, + FamilyId = familyId, + TokenHash = tokenHash, + Status = RefreshTokenStatuses.Active, + IssuedAtUtc = issuedAtUtc, + ExpiresAtUtc = expiresAtUtc, + RenewalCount = 0, + }; + + _db.RefreshTokens.Add(record); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task FindByHashAsync( + string tokenHash, + CancellationToken cancellationToken = default) + { + var record = await _db.RefreshTokens + .AsNoTracking() + .FirstOrDefaultAsync(r => r.TokenHash == tokenHash, cancellationToken) + .ConfigureAwait(false); + + if (record is null) + { + return null; + } + + return new RefreshTokenSnapshot( + record.Id, + record.TenantId, + record.UserId, + record.FamilyId, + record.Status, + record.IssuedAtUtc, + record.ExpiresAtUtc, + record.RenewalCount); + } + + public async Task RotateAsync( + RefreshTokenSnapshot current, + Guid newTokenId, + string newTokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default) + { + // Rotación: el token vigente queda Rotated apuntando al nuevo, y el nuevo entra + // Active en la misma familia. Un único SaveChanges ⇒ atómico. + var old = await _db.RefreshTokens + .FirstOrDefaultAsync(r => r.Id == current.Id, cancellationToken) + .ConfigureAwait(false); + + if (old is not null) + { + old.Status = RefreshTokenStatuses.Rotated; + old.ReplacedByTokenId = newTokenId; + } + + _db.RefreshTokens.Add(new RefreshTokenRecord + { + Id = newTokenId, + TenantId = current.TenantId, + UserId = current.UserId, + FamilyId = current.FamilyId, + TokenHash = newTokenHash, + Status = RefreshTokenStatuses.Active, + IssuedAtUtc = issuedAtUtc, + ExpiresAtUtc = expiresAtUtc, + RenewalCount = current.RenewalCount + 1, + }); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task RevokeFamilyAsync( + Guid familyId, + string reason, + DateTime revokedAtUtc, + CancellationToken cancellationToken = default) + { + // Invalida la familia entera (reuso o revocación explícita). Idempotente: + // solo toca los que aún no están revocados. + var members = await _db.RefreshTokens + .Where(r => r.FamilyId == familyId && r.Status != RefreshTokenStatuses.Revoked) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var member in members) + { + member.Status = RefreshTokenStatuses.Revoked; + member.RevokedAtUtc = revokedAtUtc; + member.RevokedReason = reason; + } + + if (members.Count > 0) + { + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async Task RevokeAllForUserAsync( + Guid tenantId, + Guid userId, + string reason, + DateTime revokedAtUtc, + CancellationToken cancellationToken = default) + { + // Revocación explícita del logout: cierra todas las familias vivas del usuario + // en el inquilino. Idempotente: solo toca los que aún no están revocados. + var members = await _db.RefreshTokens + .Where(r => r.TenantId == tenantId && r.UserId == userId && r.Status != RefreshTokenStatuses.Revoked) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var member in members) + { + member.Status = RefreshTokenStatuses.Revoked; + member.RevokedAtUtc = revokedAtUtc; + member.RevokedReason = reason; + } + + if (members.Count > 0) + { + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/InMemoryTenantParameterRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/InMemoryTenantParameterRepository.cs index 371a8d0e..c11de915 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/InMemoryTenantParameterRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/InMemoryTenantParameterRepository.cs @@ -45,22 +45,34 @@ public Task UpdateAsync(TenantParameterAggregate aggregate, CancellationToken ca return Task.CompletedTask; } + /// + /// Borrado LÓGICO: la entrada NO se saca de la lista; antes hacía RemoveAll y perdía la + /// fila. El agregado ya trae la marca puesta por TenantParameter.Delete (aquí la lista + /// guarda la MISMA instancia) y las lecturas de abajo lo ocultan. Igual que el store PostgreSQL, + /// se corta si nadie pasó por el dominio. + /// public Task DeleteAsync(TenantParameterAggregate aggregate, CancellationToken cancellationToken = default) { - _parameters.RemoveAll(p => p.GetId().GetValue() == aggregate.GetId().GetValue()); - _committedIds.Remove(aggregate.GetId().GetValue()); + if (!aggregate.IsDeleted) + { + throw new InvalidOperationException( + $"Tenant parameter {aggregate.GetId().GetValue()} must be logically deleted via TenantParameter.Delete before persisting."); + } + + var index = _parameters.FindIndex(p => p.GetId().GetValue() == aggregate.GetId().GetValue()); + if (index >= 0) _parameters[index] = aggregate; return Task.CompletedTask; } public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - return await Task.FromResult(_parameters.FirstOrDefault(p => p.GetId().GetValue() == id)); + return await Task.FromResult(_parameters.FirstOrDefault(p => !p.IsDeleted && p.GetId().GetValue() == id)); } public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) { return await Task.FromResult(_parameters.FirstOrDefault(p => - p.TenantId.GetValue() == tenantId && p.GetId().GetValue() == id)); + !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.GetId().GetValue() == id)); } Task IAggregateRepository.GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken) @@ -71,29 +83,29 @@ public Task DeleteAsync(TenantParameterAggregate aggregate, CancellationToken ca public async Task GetByCodeAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) { return await Task.FromResult(_parameters.FirstOrDefault(p => - p.TenantId.GetValue() == tenantId && p.Code.GetValue() == code)); + !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.Code.GetValue() == code)); } public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { - return await Task.FromResult(_parameters.Where(p => p.TenantId.GetValue() == tenantId).ToList()); + return await Task.FromResult(_parameters.Where(p => !p.IsDeleted && p.TenantId.GetValue() == tenantId).ToList()); } public async Task> GetActiveByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { - return await Task.FromResult(_parameters.Where(p => p.TenantId.GetValue() == tenantId && p.IsActive).ToList()); + return await Task.FromResult(_parameters.Where(p => !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.IsActive).ToList()); } public async Task> GetByCategoryAsync(Guid tenantId, string category, CancellationToken cancellationToken = default) { return await Task.FromResult(_parameters.Where(p => - p.TenantId.GetValue() == tenantId && p.IsActive && p.Category.Name.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList()); + !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.IsActive && p.Category.Name.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList()); } public async Task ExistsActiveCodeAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) { return await Task.FromResult(_parameters.Any(p => - p.TenantId.GetValue() == tenantId && p.Code.GetValue() == code && p.IsActive)); + !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.Code.GetValue() == code && p.IsActive)); } public void Seed(TenantParameterAggregate aggregate) diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/PostgreSqlTenantParameterRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/PostgreSqlTenantParameterRepository.cs index b491d104..944f7150 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/PostgreSqlTenantParameterRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/PostgreSqlTenantParameterRepository.cs @@ -66,11 +66,6 @@ public void Dispose() return record is null ? null : Rehydrate(record); } - Task IAggregateRepository.GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken) - { - return GetByIdAsync(tenantId, id, cancellationToken); - } - public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) { var record = await dbContext.TenantParameters @@ -79,6 +74,11 @@ public void Dispose() return record is null ? null : Rehydrate(record); } + Task IAggregateRepository.GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken) + { + return GetByIdAsync(tenantId, id, cancellationToken); + } + public async Task GetByCodeAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) { var record = await dbContext.TenantParameters @@ -145,10 +145,28 @@ public Task UpdateAsync(TenantParameterAggregate aggregate, CancellationToken ca return Task.CompletedTask; } + /// + /// Borrado LÓGICO. Persiste la marca IsDeleted del agregado; NUNCA quita la fila. + /// + /// Antes hacía dbContext.TenantParameters.Remove(...): la configuración histórica del + /// inquilino —qué valor regía y quién lo puso— se perdía sin remedio, y el negocio consulta hacia + /// atrás. La política del propietario es explícita: solo existe borrado lógico. + /// + /// La DECISIÓN de eliminar es del dominio (TenantParameter.Delete, que aplica la guardia de + /// cascada sobre el vínculo activo); el repositorio solo la persiste. Si el agregado no viene ya + /// en estado eliminado, es un error de programación —alguien se saltó el dominio— y se corta aquí + /// en vez de escribir en la base un borrado que nadie validó. + /// public Task DeleteAsync(TenantParameterAggregate aggregate, CancellationToken cancellationToken = default) { - dbContext.TenantParameters.Remove(ToRecord(aggregate)); - _trackedAggregates.Remove(aggregate); + if (!aggregate.IsDeleted) + { + throw new InvalidOperationException( + $"Tenant parameter {aggregate.GetId().GetValue()} must be logically deleted via TenantParameter.Delete before persisting."); + } + + dbContext.TenantParameters.Update(ToRecord(aggregate)); + _trackedAggregates.Add(aggregate); return Task.CompletedTask; } @@ -167,6 +185,7 @@ private static TenantParameterRecord ToRecord(TenantParameterAggregate aggregate ValueTypeId = aggregate.ValueType.Id, CategoryId = aggregate.Category.Id, IsActive = aggregate.IsActive, + IsDeleted = aggregate.IsDeleted, IsSensitive = aggregate.IsSensitive, DefaultValue = aggregate.DefaultValue, AllowedValues = aggregate.AllowedValues, diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RoleMaturityStatusRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RoleMaturityStatusRecordConfiguration.cs new file mode 100644 index 00000000..46496fea --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RoleMaturityStatusRecordConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Iga.Entities; + +namespace Ums.Infrastructure.Persistence.Iga.Configurations; + +public sealed class RoleMaturityStatusRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RoleMaturityStatuses", IgaPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.PerformanceScore).HasColumnType("numeric(4,2)"); + builder.Property(x => x.BlockingFactor).HasMaxLength(500); + builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); + builder.Property(x => x.UpdatedBy).HasMaxLength(100); + builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.UserId }); + // Un estado de madurez por usuario y rol dentro del inquilino (fuente de elegibilidad). + builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique(); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RolePromotionRequestRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RolePromotionRequestRecordConfiguration.cs new file mode 100644 index 00000000..37f9aa08 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RolePromotionRequestRecordConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Iga.Entities; + +namespace Ums.Infrastructure.Persistence.Iga.Configurations; + +public sealed class RolePromotionRequestRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RolePromotionRequests", IgaPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.DecisionReason).HasMaxLength(1000); + builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); + builder.Property(x => x.UpdatedBy).HasMaxLength(100); + builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + + builder.HasIndex(x => x.TargetUserId); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.StatusId }); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RoleMaturityStatusRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RoleMaturityStatusRecord.cs new file mode 100644 index 00000000..feab11e2 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RoleMaturityStatusRecord.cs @@ -0,0 +1,45 @@ +using System; + +namespace Ums.Infrastructure.Persistence.Iga.Entities; + +/// +/// Registro de persistencia del agregado de elegibilidad RoleMaturityStatus (IGA, ADR-UMS-093, FR-062). +/// +/// Tabla acotada por inquilino (): la fuente de verdad de elegibilidad +/// nunca cruza fronteras de inquilino. Los niveles de madurez ( / +/// ) se guardan como el valor entero del enum +/// RoleMaturityLevel; los eventos de dominio no se persisten como filas — fluyen al Outbox +/// (ADR-0052) al guardar. +/// +public sealed class RoleMaturityStatusRecord : IAuditableRecord +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + public Guid RoleId { get; set; } + + /// Valor entero del enum RoleMaturityLevel (Junior=1 … Principal=5). + public int CurrentMaturityLevel { get; set; } + + /// Siguiente nivel elegible (nulo mientras no se confirme la elegibilidad). + public int? NextEligibleMaturityLevel { get; set; } + + public DateTime AssignedAt { get; set; } + public DateTime CurrentLevelSince { get; set; } + public DateTime? EligibleForPromotionAt { get; set; } + public int CompletedCertificationsCount { get; set; } + public int CompletedTrainingsCount { get; set; } + public decimal PerformanceScore { get; set; } + public bool HasNoComplianceIssues { get; set; } + + /// Factor de bloqueo de cumplimiento activo (nulo si no hay incidencia). + public string? BlockingFactor { get; set; } + + public DateTime? LastReviewedAt { get; set; } + + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public string? UpdatedBy { get; set; } + public DateTime? UpdatedAtUtc { get; set; } + public string AuditTimeSpan { get; set; } = string.Empty; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RolePromotionRequestRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RolePromotionRequestRecord.cs new file mode 100644 index 00000000..905c3904 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RolePromotionRequestRecord.cs @@ -0,0 +1,41 @@ +using System; + +namespace Ums.Infrastructure.Persistence.Iga.Entities; + +/// +/// Registro de persistencia del agregado de promoción RolePromotionRequest (IGA, ADR-UMS-093, FR-060/061). +/// +/// Tabla acotada por inquilino (). El estado de la máquina se guarda como +/// (valor de la enumeración de dominio RolePromotionStatus) y el +/// congelado se guarda como entero (nulo en Draft). Los identificadores +/// de aprobador/revisor/ejecutor/verificador se registran a medida que avanza la máquina para poder +/// auditar la segregación de funciones. Los eventos de transición fluyen al Outbox (ADR-0052) al +/// guardar; no se persisten como filas. +/// +public sealed class RolePromotionRequestRecord : IAuditableRecord +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public Guid TargetUserId { get; set; } + public Guid RequesterId { get; set; } + public Guid CurrentRoleId { get; set; } + public Guid TargetRoleId { get; set; } + + /// Identificador de la enumeración de dominio RolePromotionStatus (Draft=1 … Cancelled=9). + public int StatusId { get; set; } + + /// RiskScore [0,100] congelado al salir de Draft (nulo mientras es Draft). + public int? RiskScore { get; set; } + + public Guid? ApproverId { get; set; } + public Guid? SecurityReviewerId { get; set; } + public Guid? ExecutorId { get; set; } + public Guid? VerifierId { get; set; } + public string? DecisionReason { get; set; } + + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public string? UpdatedBy { get; set; } + public DateTime? UpdatedAtUtc { get; set; } + public string AuditTimeSpan { get; set; } = string.Empty; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/IgaPersistenceConstants.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/IgaPersistenceConstants.cs new file mode 100644 index 00000000..b8593f45 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/IgaPersistenceConstants.cs @@ -0,0 +1,10 @@ +namespace Ums.Infrastructure.Persistence.Iga; + +/// +/// Constantes de persistencia del contexto acotado IGA (ADR-UMS-093). +/// Las tablas de gobierno de identidad viven en su propio esquema, acotado por inquilino. +/// +internal static class IgaPersistenceConstants +{ + public const string Schema = "iga"; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRoleMaturityStatusRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRoleMaturityStatusRepository.cs new file mode 100644 index 00000000..00fe2a23 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRoleMaturityStatusRepository.cs @@ -0,0 +1,175 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Persistence.Iga.Entities; +using Ums.Infrastructure.Persistence.Reflection; + +namespace Ums.Infrastructure.Persistence.Iga; + +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; + +/// +/// Implementación PostgreSQL de sobre +/// (IGA, ADR-UMS-093). Mapea el agregado a/desde +/// y publica los eventos de dominio hacia el Outbox +/// (ADR-0052) en . El aislamiento por inquilino lo garantiza el +/// filtro global del contexto; las consultas acotadas añaden además el filtro explícito. +/// +public sealed class PostgreSqlRoleMaturityStatusRepository : IRoleMaturityStatusRepository, IUnitOfWork +{ + private readonly UmsPlatformDbContext _dbContext; + private readonly HashSet _trackedAggregates = []; + + public PostgreSqlRoleMaturityStatusRepository(UmsPlatformDbContext dbContext) + { + _dbContext = dbContext; + } + + public IUnitOfWork UnitOfWork => this; + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var record = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + return record is null ? null : IgaAggregateFactory.RehydrateRoleMaturityStatus(record); + } + + public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) + => GetByIdAsync(id, cancellationToken); + + public async Task> GetByUserAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) + { + var records = await _dbContext.Set() + .Where(x => x.TenantId == tenantId && x.UserId == userId) + .ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRoleMaturityStatus).ToList(); + } + + public async Task GetByUserAndRoleAsync(Guid tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default) + { + var record = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.UserId == userId && x.RoleId == roleId, cancellationToken); + + return record is null ? null : IgaAggregateFactory.RehydrateRoleMaturityStatus(record); + } + + public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) + { + var query = _dbContext.Set().AsQueryable(); + + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + var records = await query.ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRoleMaturityStatus).ToList(); + } + + public Task AddAsync(RoleMaturityStatusAggregate aggregate, CancellationToken cancellationToken = default) + { + _dbContext.Set().Add(ToRecord(aggregate)); + _trackedAggregates.Add(aggregate); + return Task.CompletedTask; + } + + public async Task UpdateAsync(RoleMaturityStatusAggregate aggregate, CancellationToken cancellationToken = default) + { + var existing = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"Role maturity status {aggregate.Props.Id.GetValue()} does not exist."); + + Apply(existing, aggregate); + _trackedAggregates.Add(aggregate); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _dbContext.SaveChangesAsync(cancellationToken); + + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + foreach (var aggregate in _trackedAggregates) + { + await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); + } + + try + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException ex) + { + var entry = ex.Entries.FirstOrDefault(); + var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); + throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); + } + + foreach (var aggregate in _trackedAggregates) + { + aggregate.DomainEvents.MarkChangesAsCommitted(); + } + + _trackedAggregates.Clear(); + return true; + } + + public void Dispose() + { + } + + private static RoleMaturityStatusRecord ToRecord(RoleMaturityStatusAggregate aggregate) + { + var audit = aggregate.Props.Audit.GetValue(); + return new RoleMaturityStatusRecord + { + Id = aggregate.Props.Id.GetValue(), + TenantId = aggregate.TenantId.GetValue(), + UserId = aggregate.UserId.GetValue(), + RoleId = aggregate.RoleId.GetValue(), + CurrentMaturityLevel = (int)aggregate.CurrentMaturityLevel, + NextEligibleMaturityLevel = aggregate.NextEligibleMaturityLevel.HasValue ? (int)aggregate.NextEligibleMaturityLevel.Value : null, + AssignedAt = aggregate.AssignedAt, + CurrentLevelSince = aggregate.CurrentLevelSince, + EligibleForPromotionAt = aggregate.EligibleForPromotionAt, + CompletedCertificationsCount = aggregate.CompletedCertificationsCount, + CompletedTrainingsCount = aggregate.CompletedTrainingsCount, + PerformanceScore = aggregate.PerformanceScore, + HasNoComplianceIssues = aggregate.HasNoComplianceIssues, + BlockingFactor = aggregate.BlockingFactor?.GetValue(), + LastReviewedAt = aggregate.LastReviewedAt, + CreatedBy = audit.CreatedBy, + CreatedAtUtc = audit.CreatedAt, + UpdatedBy = audit.UpdatedBy, + UpdatedAtUtc = audit.UpdatedAt, + AuditTimeSpan = audit.TimeSpan, + }; + } + + private static void Apply(RoleMaturityStatusRecord target, RoleMaturityStatusAggregate source) + { + var replacement = ToRecord(source); + + target.TenantId = replacement.TenantId; + target.UserId = replacement.UserId; + target.RoleId = replacement.RoleId; + target.CurrentMaturityLevel = replacement.CurrentMaturityLevel; + target.NextEligibleMaturityLevel = replacement.NextEligibleMaturityLevel; + target.AssignedAt = replacement.AssignedAt; + target.CurrentLevelSince = replacement.CurrentLevelSince; + target.EligibleForPromotionAt = replacement.EligibleForPromotionAt; + target.CompletedCertificationsCount = replacement.CompletedCertificationsCount; + target.CompletedTrainingsCount = replacement.CompletedTrainingsCount; + target.PerformanceScore = replacement.PerformanceScore; + target.HasNoComplianceIssues = replacement.HasNoComplianceIssues; + target.BlockingFactor = replacement.BlockingFactor; + target.LastReviewedAt = replacement.LastReviewedAt; + target.CreatedBy = replacement.CreatedBy; + target.CreatedAtUtc = replacement.CreatedAtUtc; + target.UpdatedBy = replacement.UpdatedBy; + target.UpdatedAtUtc = replacement.UpdatedAtUtc; + target.AuditTimeSpan = replacement.AuditTimeSpan; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRolePromotionRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRolePromotionRequestRepository.cs new file mode 100644 index 00000000..50704e9d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRolePromotionRequestRepository.cs @@ -0,0 +1,180 @@ +using Microsoft.EntityFrameworkCore; +using BeyondNetCode.Shell.Ddd; +using Ums.Domain.IGA; +using Ums.Domain.IGA.RolePromotionRequest; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Persistence.Iga.Entities; +using Ums.Infrastructure.Persistence.Reflection; + +namespace Ums.Infrastructure.Persistence.Iga; + +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Implementación PostgreSQL de sobre +/// (IGA, ADR-UMS-093). Mapea el agregado a/desde +/// y publica los eventos de transición de la máquina de +/// estados hacia el Outbox (ADR-0052) en — así la traza de auditoría +/// append-only recibe cada transición sin infraestructura adicional. +/// +public sealed class PostgreSqlRolePromotionRequestRepository : IRolePromotionRequestRepository, IUnitOfWork +{ + private readonly UmsPlatformDbContext _dbContext; + private readonly HashSet _trackedAggregates = []; + + public PostgreSqlRolePromotionRequestRepository(UmsPlatformDbContext dbContext) + { + _dbContext = dbContext; + } + + public IUnitOfWork UnitOfWork => this; + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var record = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + return record is null ? null : IgaAggregateFactory.RehydrateRolePromotionRequest(record); + } + + public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) + => GetByIdAsync(id, cancellationToken); + + public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) + { + var query = _dbContext.Set().AsQueryable(); + + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + var records = await query.ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRolePromotionRequest).ToList(); + } + + public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + var records = await _dbContext.Set() + .Where(x => x.TenantId == tenantId) + .ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRolePromotionRequest).ToList(); + } + + public async Task> GetByTenantAndStatusAsync(Guid tenantId, string status, CancellationToken cancellationToken = default) + { + var statusEnum = DomainEnumeration.FromDisplayName(status); + if (statusEnum is null) + { + return Array.Empty(); + } + + var records = await _dbContext.Set() + .Where(x => x.TenantId == tenantId && x.StatusId == statusEnum.Id) + .ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRolePromotionRequest).ToList(); + } + + public Task AddAsync(RolePromotionRequestAggregate aggregate, CancellationToken cancellationToken = default) + { + _dbContext.Set().Add(ToRecord(aggregate)); + _trackedAggregates.Add(aggregate); + return Task.CompletedTask; + } + + public async Task UpdateAsync(RolePromotionRequestAggregate aggregate, CancellationToken cancellationToken = default) + { + var existing = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"Role promotion request {aggregate.Props.Id.GetValue()} does not exist."); + + Apply(existing, aggregate); + _trackedAggregates.Add(aggregate); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _dbContext.SaveChangesAsync(cancellationToken); + + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + foreach (var aggregate in _trackedAggregates) + { + await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); + } + + try + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException ex) + { + var entry = ex.Entries.FirstOrDefault(); + var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); + throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); + } + + foreach (var aggregate in _trackedAggregates) + { + aggregate.DomainEvents.MarkChangesAsCommitted(); + } + + _trackedAggregates.Clear(); + return true; + } + + public void Dispose() + { + } + + private static RolePromotionRequestRecord ToRecord(RolePromotionRequestAggregate aggregate) + { + var audit = aggregate.Props.Audit.GetValue(); + return new RolePromotionRequestRecord + { + Id = aggregate.Props.Id.GetValue(), + TenantId = aggregate.TenantId.GetValue(), + TargetUserId = aggregate.TargetUserId.GetValue(), + RequesterId = aggregate.RequesterId.GetValue(), + CurrentRoleId = aggregate.CurrentRoleId.GetValue(), + TargetRoleId = aggregate.TargetRoleId.GetValue(), + StatusId = aggregate.Status.Id, + RiskScore = aggregate.RiskScore?.GetValue(), + ApproverId = aggregate.ApproverId?.GetValue(), + SecurityReviewerId = aggregate.SecurityReviewerId?.GetValue(), + ExecutorId = aggregate.ExecutorId?.GetValue(), + VerifierId = aggregate.VerifierId?.GetValue(), + DecisionReason = aggregate.DecisionReason, + CreatedBy = audit.CreatedBy, + CreatedAtUtc = audit.CreatedAt, + UpdatedBy = audit.UpdatedBy, + UpdatedAtUtc = audit.UpdatedAt, + AuditTimeSpan = audit.TimeSpan, + }; + } + + private static void Apply(RolePromotionRequestRecord target, RolePromotionRequestAggregate source) + { + var replacement = ToRecord(source); + + target.TenantId = replacement.TenantId; + target.TargetUserId = replacement.TargetUserId; + target.RequesterId = replacement.RequesterId; + target.CurrentRoleId = replacement.CurrentRoleId; + target.TargetRoleId = replacement.TargetRoleId; + target.StatusId = replacement.StatusId; + target.RiskScore = replacement.RiskScore; + target.ApproverId = replacement.ApproverId; + target.SecurityReviewerId = replacement.SecurityReviewerId; + target.ExecutorId = replacement.ExecutorId; + target.VerifierId = replacement.VerifierId; + target.DecisionReason = replacement.DecisionReason; + target.CreatedBy = replacement.CreatedBy; + target.CreatedAtUtc = replacement.CreatedAtUtc; + target.UpdatedBy = replacement.UpdatedBy; + target.UpdatedAtUtc = replacement.UpdatedAtUtc; + target.AuditTimeSpan = replacement.AuditTimeSpan; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAccessEnforcementPolicyRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAccessEnforcementPolicyRepository.cs index 10b6fec8..bb0d9c75 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAccessEnforcementPolicyRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAccessEnforcementPolicyRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -22,10 +23,10 @@ public Task> GetAllAsync(Guid? t public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { var f = _store.Values.Where(e => e.Props.TenantId.GetValue() == tenantId).ToList(); f.ForEach(e => e.BrokenRules.Clear()); return Task.FromResult>(f); } - public Task AddAsync(AccessEnforcementPolicyAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(AccessEnforcementPolicyAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(AccessEnforcementPolicyAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(AccessEnforcementPolicyAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(AccessEnforcementPolicyAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -33,3 +34,5 @@ public void Seed(AccessEnforcementPolicyAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAppConfigurationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAppConfigurationRepository.cs index 384372df..09a39a07 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAppConfigurationRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAppConfigurationRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -10,32 +11,43 @@ public sealed class InMemoryAppConfigurationRepository : IAppConfigurationReposi private readonly ConcurrentDictionary _store = new(); public IUnitOfWork UnitOfWork => this; + // Las lecturas ocultan lo eliminado lógicamente; el agregado NUNCA sale del diccionario. public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { _store.TryGetValue(id, out var entity); entity?.BrokenRules.Clear(); - return Task.FromResult(entity); + return Task.FromResult(entity is not null && entity.Status != ConfigStatus.Deleted ? entity : null); } public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) => GetByIdAsync(id, cancellationToken); + // Lookup de la ranura (ámbito, código): devuelve quién la ocupa AHORA, nunca una lápida. Espejo + // exacto del índice único parcial de PostgreSQL; si esta copia viera lo eliminado, las pruebas + // en memoria certificarían un comportamiento que la base ya no tiene. El orden desempata igual: + // gana la no archivada y, entre iguales, la más reciente. public Task GetByScopeAndCodeAsync(Guid? tenantId, Guid? systemSuiteId, Guid? moduleId, string code, CancellationToken cancellationToken = default) { - var entity = _store.Values.FirstOrDefault(item => - string.Equals(item.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase) - && item.Props.TenantId?.GetValue() == tenantId - && item.Props.SystemSuiteId?.GetValue() == systemSuiteId - && item.Props.ModuleId?.GetValue() == moduleId); + var entity = _store.Values + .Where(item => + string.Equals(item.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase) + && item.Props.TenantId?.GetValue() == tenantId + && item.Props.SystemSuiteId?.GetValue() == systemSuiteId + && item.Props.ModuleId?.GetValue() == moduleId + && item.Status != ConfigStatus.Deleted) + .OrderBy(item => item.Status == ConfigStatus.Archived ? 1 : 0) + .ThenByDescending(item => item.Props.Audit.GetValue().CreatedAt) + .FirstOrDefault(); entity?.BrokenRules.Clear(); return Task.FromResult(entity); } public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { + var live = _store.Values.Where(item => item.Status != ConfigStatus.Deleted); var items = tenantId.HasValue - ? _store.Values.Where(item => item.Props.TenantId?.GetValue() == tenantId.Value).ToList() - : _store.Values.ToList(); + ? live.Where(item => item.Props.TenantId?.GetValue() == tenantId.Value).ToList() + : live.ToList(); items.ForEach(item => item.BrokenRules.Clear()); return Task.FromResult>(items); } @@ -56,10 +68,22 @@ public Task UpdateAsync(AppConfigurationAggregate aggregate, CancellationToken c public Task UpdateAsync(AppConfigurationAggregate aggregate, byte[]? expectedRowVersion, CancellationToken cancellationToken = default) => UpdateAsync(aggregate, cancellationToken); + // Sin DeleteAsync: el borrado lógico se persiste por UpdateAsync (el agregado ya trae Deleted). + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); + /// + /// Lectura SIN filtros de ocultación: espejo en memoria de `IgnoreQueryFilters()`. + /// + /// Existe porque ADR-0164 §5 exige demostrar que la fila sobrevive al borrado, y preguntar a la + /// API no distingue «oculto» de «borrado». Desde que la ranura se libera, `GetByScopeAndCodeAsync` + /// devuelve solo la viva, así que ya no sirve como sonda del almacén. Ninguna ruta de producción + /// la usa: es la contraparte del `IgnoreQueryFilters()` explícito del repositorio PostgreSQL. + /// + public IReadOnlyList GetAllIncludingDeleted() => _store.Values.ToList(); + public void Seed(AppConfigurationAggregate aggregate) { aggregate.DomainEvents.MarkChangesAsCommitted(); @@ -68,3 +92,5 @@ public void Seed(AppConfigurationAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalRequestRepository.cs index a66ac40e..023052ab 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalRequestRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalRequestRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -33,10 +34,10 @@ public Task ExistsPendingForScopeAsync(Guid userId, Guid systemId, Guid? b return Task.FromResult(exists); } - public Task AddAsync(ApprovalRequestAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(ApprovalRequestAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(ApprovalRequestAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(ApprovalRequestAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(ApprovalRequestAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -44,3 +45,5 @@ public void Seed(ApprovalRequestAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalWorkflowRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalWorkflowRepository.cs index f8a9a9bf..3736804e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalWorkflowRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalWorkflowRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -22,10 +23,10 @@ public Task> GetAllAsync(Guid? tenantId public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { var f = _store.Values.Where(e => e.Props.TenantId.GetValue() == tenantId).ToList(); f.ForEach(e => e.BrokenRules.Clear()); return Task.FromResult>(f); } - public Task AddAsync(ApprovalWorkflowAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(ApprovalWorkflowAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(ApprovalWorkflowAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(ApprovalWorkflowAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(ApprovalWorkflowAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -33,3 +34,5 @@ public void Seed(ApprovalWorkflowAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryDocumentTypeRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryDocumentTypeRepository.cs index 7bf2d903..2949ddea 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryDocumentTypeRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryDocumentTypeRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -22,10 +23,10 @@ public Task> GetAllAsync(Guid? tenantId = n public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { var f = _store.Values.Where(e => e.Props.TenantId.GetValue() == tenantId).ToList(); f.ForEach(e => e.BrokenRules.Clear()); return Task.FromResult>(f); } - public Task AddAsync(DocumentTypeAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(DocumentTypeAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(DocumentTypeAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(DocumentTypeAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(DocumentTypeAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -33,3 +34,5 @@ public void Seed(DocumentTypeAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryFeatureFlagRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryFeatureFlagRepository.cs index 29b8b747..99f5cf96 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryFeatureFlagRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryFeatureFlagRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -55,6 +56,10 @@ public Task> GetBySystemSuiteIdAsync(Guid sy return Task.FromResult>(items); } + // En memoria no hay `Include` que evitar: la distinción sirve al lado PostgreSQL. + public Task> GetBySystemSuiteIdForEvaluationAsync(Guid systemSuiteId, CancellationToken cancellationToken = default) + => GetBySystemSuiteIdAsync(systemSuiteId, cancellationToken); + public Task AddAsync(FeatureFlagAggregate aggregate, CancellationToken cancellationToken = default) { _store[aggregate.Props.Id.GetValue()] = aggregate; @@ -79,3 +84,5 @@ public void Seed(FeatureFlagAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryIdpConfigurationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryIdpConfigurationRepository.cs index 559796b8..91fff62d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryIdpConfigurationRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryIdpConfigurationRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -60,3 +61,5 @@ public void Seed(IdpConfigurationAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryNotificationRuleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryNotificationRuleRepository.cs index ec8c02b7..cbf3767a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryNotificationRuleRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryNotificationRuleRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -32,10 +33,10 @@ public Task ExistsDuplicateAsync(Guid tenantId, string channel, string rec return Task.FromResult(exists); } - public Task AddAsync(NotificationRuleAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(NotificationRuleAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(NotificationRuleAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(NotificationRuleAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(NotificationRuleAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -43,3 +44,5 @@ public void Seed(NotificationRuleAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryParameterRepositories.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryParameterRepositories.cs index c7ec3d46..bdfb0cc2 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryParameterRepositories.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryParameterRepositories.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -20,85 +21,115 @@ public sealed class InMemoryParameterRepositories // ── IParameterDefinitionRepository ─────────────────────────────────────── - Task IParameterDefinitionRepository.GetByIdAsync(Guid id, CancellationToken ct) - => Task.FromResult(_defs.GetValueOrDefault(id)); + // Las lecturas ocultan lo eliminado lógicamente, igual que el filtro global de consulta del + // contexto PostgreSQL: la fila sigue en el diccionario (nunca se retira), simplemente no se sirve. + Task IParameterDefinitionRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + var definition = _defs.GetValueOrDefault(id); + return Task.FromResult(definition is { Props.IsDeleted: false } ? definition : null); + } - Task IParameterDefinitionRepository.GetByCodeAsync(string code, CancellationToken ct) - => Task.FromResult(_defs.Values.FirstOrDefault(d => - string.Equals(d.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase))); + Task IParameterDefinitionRepository.GetByCodeAsync(string code, CancellationToken cancellationToken) + => Task.FromResult(_defs.Values.FirstOrDefault(definition => + !definition.Props.IsDeleted && + string.Equals(definition.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase))); - Task> IParameterDefinitionRepository.GetAllAsync(CancellationToken ct) + Task> IParameterDefinitionRepository.GetAllAsync(CancellationToken cancellationToken) => Task.FromResult>( - _defs.Values.OrderBy(d => d.Props.DisplayOrder).ToList()); + _defs.Values + .Where(definition => !definition.Props.IsDeleted) + .OrderBy(definition => definition.Props.DisplayOrder) + .ToList()); - Task IParameterDefinitionRepository.AddAsync(ParameterDefinition d, CancellationToken ct) + Task IParameterDefinitionRepository.AddAsync(ParameterDefinition definition, CancellationToken cancellationToken) { - _defs[d.Props.Id.GetValue()] = d; + _defs[definition.Props.Id.GetValue()] = definition; return Task.CompletedTask; } - Task IParameterDefinitionRepository.UpdateAsync(ParameterDefinition d, CancellationToken ct) + Task IParameterDefinitionRepository.UpdateAsync(ParameterDefinition definition, CancellationToken cancellationToken) { - _defs[d.Props.Id.GetValue()] = d; + _defs[definition.Props.Id.GetValue()] = definition; return Task.CompletedTask; } - Task IParameterDefinitionRepository.CountByCodeAsync(string code, CancellationToken ct) - => Task.FromResult(_defs.Values.Count(d => - string.Equals(d.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase))); + // Cuenta solo las VIVAS: el índice único de `Code` es parcial, así que una definición eliminada + // ya no reserva su código y volver a declararlo debe poder hacerse. + Task IParameterDefinitionRepository.CountByCodeAsync(string code, CancellationToken cancellationToken) + => Task.FromResult(_defs.Values.Count(definition => + !definition.Props.IsDeleted && + string.Equals(definition.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase))); - Task IParameterDefinitionRepository.CountGlobalValuesAsync(Guid defId, CancellationToken ct) - => Task.FromResult(_gv.Values.Count(v => v.Props.ParameterDefinitionId.GetValue() == defId)); + Task IParameterDefinitionRepository.CountLiveGlobalValuesAsync(Guid definitionId, CancellationToken cancellationToken) + => Task.FromResult(_gv.Values.Count(value => + value.Props.ParameterDefinitionId.GetValue() == definitionId && + value.Props.Status != ConfigStatus.Deleted)); - Task IParameterDefinitionRepository.CountTenantValuesAsync(Guid defId, CancellationToken ct) - => Task.FromResult(_tv.Values.Count(v => v.Props.ParameterDefinitionId.GetValue() == defId)); + Task IParameterDefinitionRepository.CountLiveTenantValuesAsync(Guid definitionId, CancellationToken cancellationToken) + => Task.FromResult(_tv.Values.Count(value => + value.Props.ParameterDefinitionId.GetValue() == definitionId && + value.Props.Status != ConfigStatus.Deleted)); - Task IParameterDefinitionRepository.SaveChangesAsync(CancellationToken ct) => Task.FromResult(true); + Task IParameterDefinitionRepository.SaveChangesAsync(CancellationToken cancellationToken) => Task.FromResult(true); // ── IParameterGlobalValueRepository ────────────────────────────────────── - Task IParameterGlobalValueRepository.GetByIdAsync(Guid id, CancellationToken ct) - => Task.FromResult(_gv.GetValueOrDefault(id)); + Task IParameterGlobalValueRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + var value = _gv.GetValueOrDefault(id); + return Task.FromResult(value is not null && value.Props.Status != ConfigStatus.Deleted ? value : null); + } - Task IParameterGlobalValueRepository.GetByDefinitionIdAsync(Guid defId, CancellationToken ct) - => Task.FromResult(_gv.Values.FirstOrDefault(v => v.Props.ParameterDefinitionId.GetValue() == defId)); + // Solo el valor VIVO: el índice único parcial dejó de contar las lápidas, así que devolverlas + // bloquearía un alta que la base permite y daría por vigente un valor ya retirado. + Task IParameterGlobalValueRepository.GetByDefinitionIdAsync(Guid definitionId, CancellationToken cancellationToken) + => Task.FromResult(_gv.Values.FirstOrDefault(value => + value.Props.ParameterDefinitionId.GetValue() == definitionId && + value.Props.Status != ConfigStatus.Deleted)); - Task IParameterGlobalValueRepository.AddAsync(ParameterGlobalValue v, CancellationToken ct) + Task IParameterGlobalValueRepository.AddAsync(ParameterGlobalValue value, CancellationToken cancellationToken) { - _gv[v.Props.Id.GetValue()] = v; + _gv[value.Props.Id.GetValue()] = value; return Task.CompletedTask; } - Task IParameterGlobalValueRepository.UpdateAsync(ParameterGlobalValue v, CancellationToken ct) + Task IParameterGlobalValueRepository.UpdateAsync(ParameterGlobalValue value, CancellationToken cancellationToken) { - _gv[v.Props.Id.GetValue()] = v; + _gv[value.Props.Id.GetValue()] = value; return Task.CompletedTask; } - Task IParameterGlobalValueRepository.SaveChangesAsync(CancellationToken ct) => Task.FromResult(true); + Task IParameterGlobalValueRepository.SaveChangesAsync(CancellationToken cancellationToken) => Task.FromResult(true); // ── IParameterTenantValueRepository ────────────────────────────────────── - Task IParameterTenantValueRepository.GetByIdAsync(Guid id, CancellationToken ct) - => Task.FromResult(_tv.GetValueOrDefault(id)); + Task IParameterTenantValueRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + var value = _tv.GetValueOrDefault(id); + return Task.FromResult(value is not null && value.Props.Status != ConfigStatus.Deleted ? value : null); + } + // Solo el override VIVO, por la misma razón que en el valor global. Task IParameterTenantValueRepository.GetByTenantAndDefinitionAsync( - Guid tenantId, Guid defId, CancellationToken ct) - => Task.FromResult(_tv.Values.FirstOrDefault(v => - v.Props.TenantId.GetValue() == tenantId && - v.Props.ParameterDefinitionId.GetValue() == defId)); + Guid tenantId, Guid definitionId, CancellationToken cancellationToken) + => Task.FromResult(_tv.Values.FirstOrDefault(value => + value.Props.TenantId.GetValue() == tenantId && + value.Props.ParameterDefinitionId.GetValue() == definitionId && + value.Props.Status != ConfigStatus.Deleted)); - Task IParameterTenantValueRepository.AddAsync(ParameterTenantValue v, CancellationToken ct) + Task IParameterTenantValueRepository.AddAsync(ParameterTenantValue value, CancellationToken cancellationToken) { - _tv[v.Props.Id.GetValue()] = v; + _tv[value.Props.Id.GetValue()] = value; return Task.CompletedTask; } - Task IParameterTenantValueRepository.UpdateAsync(ParameterTenantValue v, CancellationToken ct) + Task IParameterTenantValueRepository.UpdateAsync(ParameterTenantValue value, CancellationToken cancellationToken) { - _tv[v.Props.Id.GetValue()] = v; + _tv[value.Props.Id.GetValue()] = value; return Task.CompletedTask; } - Task IParameterTenantValueRepository.SaveChangesAsync(CancellationToken ct) => Task.FromResult(true); + Task IParameterTenantValueRepository.SaveChangesAsync(CancellationToken cancellationToken) => Task.FromResult(true); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryPermissionTemplateRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryPermissionTemplateRepository.cs index 4a9c531a..d6670a1c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryPermissionTemplateRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryPermissionTemplateRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -13,7 +14,10 @@ public sealed class InMemoryPermissionTemplateRepository : IPermissionTemplateRe public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { + // Las lecturas ocultan lo lógicamente eliminado, igual que el store PostgreSQL: quien pida + // por id una plantilla eliminada debe recibir 404, no el agregado en estado terminal. _store.TryGetValue(id, out var template); + if (template is not null && template.IsDeleted) template = null; template?.BrokenRules.Clear(); return Task.FromResult(template); } @@ -23,16 +27,29 @@ public sealed class InMemoryPermissionTemplateRepository : IPermissionTemplateRe public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { + var vivas = _store.Values.Where(t => !t.IsDeleted); var all = tenantId.HasValue - ? _store.Values.Where(t => t.Props.TenantId.GetValue() == tenantId.Value).ToList() - : _store.Values.ToList(); + ? vivas.Where(t => t.Props.TenantId.GetValue() == tenantId.Value).ToList() + : vivas.ToList(); all.ForEach(t => t.BrokenRules.Clear()); return Task.FromResult>(all); } public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { - var filtered = _store.Values.Where(t => t.Props.TenantId.GetValue() == tenantId).ToList(); + var filtered = _store.Values.Where(t => !t.IsDeleted && t.Props.TenantId.GetValue() == tenantId).ToList(); + filtered.ForEach(t => t.BrokenRules.Clear()); + return Task.FromResult>(filtered); + } + + // Sin filtro de eliminadas, igual que el store PostgreSQL: alimenta el cálculo de la versión + // siguiente y una plantilla eliminada sigue ocupando su versión en el índice único. + public Task> GetByTenantRoleSuiteAsync(Guid tenantId, Guid roleId, Guid systemSuiteId, CancellationToken cancellationToken = default) + { + var filtered = _store.Values.Where(t => + t.Props.TenantId.GetValue() == tenantId && + t.Props.RoleId.GetValue() == roleId && + t.Props.SystemSuiteId.GetValue() == systemSuiteId).ToList(); filtered.ForEach(t => t.BrokenRules.Clear()); return Task.FromResult>(filtered); } @@ -49,10 +66,14 @@ public Task UpdateAsync(PermissionTemplateAggregate aggregate, CancellationToken return Task.CompletedTask; } + /// + /// Borrado LÓGICO: la entrada se queda en el store; antes hacía TryRemove y perdía la fila. + /// Replica el contrato booleano del store real: false si no existe o si ya estaba eliminada. + /// public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { - var removed = _store.TryRemove(id, out _); - return Task.FromResult(removed); + if (!_store.TryGetValue(id, out var template) || template.IsDeleted) return Task.FromResult(false); + return Task.FromResult(true); } public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); @@ -72,7 +93,10 @@ public Task CountPublishedByRoleAsync(Guid roleId, CancellationToken cancel t.Props.RoleId.GetValue() == roleId && t.Status == Ums.Domain.Enums.TemplateStatus.Published)); + // Los ítems de una plantilla eliminada no bloquean: su contenedor ya está lógicamente eliminado. public Task CountItemsByTargetAsync(Guid targetId, CancellationToken cancellationToken = default) - => Task.FromResult(_store.Values.SelectMany(t => t.Items) + => Task.FromResult(_store.Values.Where(t => !t.IsDeleted).SelectMany(t => t.Items) .Count(i => i.TargetId.GetValue() == targetId && i.IsActive)); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryProfileRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryProfileRepository.cs index 73c07e18..829469ec 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryProfileRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryProfileRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -58,6 +59,18 @@ public Task> GetByUserIdAsync(Guid userId, Cance return Task.FromResult>(filtered); } + public Task> GetActiveByUserAndTenantAsync( + Guid userId, Guid tenantId, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => x.Props.UserId.GetValue() == userId + && x.Props.TenantId.GetValue() == tenantId + && x.IsActive) + .ToList(); + items.ForEach(item => item.BrokenRules.Clear()); + return Task.FromResult>(items); + } + public Task AddAsync(ProfileAggregate aggregate, CancellationToken cancellationToken = default) { _store[aggregate.Props.Id.GetValue()] = aggregate; @@ -92,4 +105,10 @@ public Task CountActiveByTemplateAsync(Guid templateId, CancellationToken c public Task CountActiveByUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(_store.Values.Count(p => p.Props.UserId.GetValue() == userId && p.IsActive)); + + // ADR-0164 §2.2: guarda de cascada del cierre de sucursal. + public Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default) + => Task.FromResult(_store.Values.Count(p => p.Props.BranchId?.GetValue() == branchId && p.IsActive)); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleMaturityStatusRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleMaturityStatusRepository.cs new file mode 100644 index 00000000..40864199 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleMaturityStatusRepository.cs @@ -0,0 +1,67 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory +namespace Ums.Infrastructure.Persistence; + +using System.Collections.Concurrent; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; + +/// +/// Implementación en memoria de (IGA, ADR-UMS-093) para +/// dev/tests sin PostgreSQL. Guarda los agregados por identificador y respeta el acotamiento por +/// inquilino en las consultas. Mantiene la misma superficie que la variante PostgreSQL. +/// +public sealed class InMemoryRoleMaturityStatusRepository : IRoleMaturityStatusRepository, IUnitOfWork +{ + private readonly ConcurrentDictionary _store = new(); + public IUnitOfWork UnitOfWork => this; + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { _store.TryGetValue(id, out var e); e?.BrokenRules.Clear(); return Task.FromResult(e); } + + public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) + => GetByIdAsync(id, cancellationToken); + + public Task> GetByUserAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => x.TenantId.GetValue() == tenantId && x.UserId.GetValue() == userId) + .ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task GetByUserAndRoleAsync(Guid tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default) + { + var item = _store.Values.FirstOrDefault(x => + x.TenantId.GetValue() == tenantId && + x.UserId.GetValue() == userId && + x.RoleId.GetValue() == roleId); + item?.BrokenRules.Clear(); + return Task.FromResult(item); + } + + public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => !tenantId.HasValue || x.TenantId.GetValue() == tenantId.Value) + .ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task AddAsync(RoleMaturityStatusAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(RoleMaturityStatusAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); + + public void Seed(RoleMaturityStatusAggregate a) + { + a.DomainEvents.MarkChangesAsCommitted(); + _store[a.Props.Id.GetValue()] = a; + } + + public void Dispose() { } +} + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRolePromotionRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRolePromotionRequestRepository.cs new file mode 100644 index 00000000..c5919777 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRolePromotionRequestRepository.cs @@ -0,0 +1,65 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory +namespace Ums.Infrastructure.Persistence; + +using System.Collections.Concurrent; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Implementación en memoria de (IGA, ADR-UMS-093) para +/// dev/tests sin PostgreSQL. Guarda los agregados por identificador y respeta el acotamiento por +/// inquilino y el filtrado por estado en las consultas. Mantiene la misma superficie que la variante +/// PostgreSQL. +/// +public sealed class InMemoryRolePromotionRequestRepository : IRolePromotionRequestRepository, IUnitOfWork +{ + private readonly ConcurrentDictionary _store = new(); + public IUnitOfWork UnitOfWork => this; + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { _store.TryGetValue(id, out var e); e?.BrokenRules.Clear(); return Task.FromResult(e); } + + public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) + => GetByIdAsync(id, cancellationToken); + + public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => !tenantId.HasValue || x.TenantId.GetValue() == tenantId.Value) + .ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + var items = _store.Values.Where(x => x.TenantId.GetValue() == tenantId).ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task> GetByTenantAndStatusAsync(Guid tenantId, string status, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => x.TenantId.GetValue() == tenantId && x.Status.Name == status) + .ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task AddAsync(RolePromotionRequestAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(RolePromotionRequestAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); + + public void Seed(RolePromotionRequestAggregate a) + { + a.DomainEvents.MarkChangesAsCommitted(); + _store[a.Props.Id.GetValue()] = a; + } + + public void Dispose() { } +} + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleRepository.cs index 1b11f242..c9e5e51c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -71,6 +72,13 @@ public void Seed(RoleAggregate aggregate) _store[aggregate.Props.Id.GetValue()] = aggregate; } + public Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + var items = _store.Values.Where(x => ids.Contains(x.GetId().GetValue())).ToList(); + items.ForEach(item => item.BrokenRules.Clear()); + return Task.FromResult>(items); + } + public Task AddAsync(RoleAggregate aggregate, CancellationToken cancellationToken = default) { _store[aggregate.GetId().GetValue()] = aggregate; @@ -92,3 +100,5 @@ public void Dispose() { } public Task CountActiveChildRolesAsync(Guid parentRoleId, CancellationToken cancellationToken = default) => Task.FromResult(_store.Values.Count(r => r.Props.ParentRoleId?.GetValue() == parentRoleId && r.IsActive)); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemorySystemSuiteRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemorySystemSuiteRepository.cs index e0394f79..0f921968 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemorySystemSuiteRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemorySystemSuiteRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -12,9 +13,23 @@ public sealed class InMemorySystemSuiteRepository : ISystemSuiteRepository, IUni public IUnitOfWork UnitOfWork => this; + /// + /// Equivalente en memoria del filtro global de PostgreSQL (G-246): el sistema eliminado + /// lógicamente sigue en el almacén —igual que la fila sigue en la tabla— pero no se devuelve en + /// ninguna lectura. Sin esto, el host en memoria y el de PostgreSQL responderían distinto a la + /// misma petición, que es la clase de divergencia que hace inútil un doble. + /// + private static bool EsVisible(SystemSuiteAggregate suite) + => suite.Props.Status.Id != Ums.Domain.Enums.SystemStatus.Deleted.Id; + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { _store.TryGetValue(id, out var systemSuite); + if (systemSuite is not null && !EsVisible(systemSuite)) + { + systemSuite = null; + } + systemSuite?.BrokenRules.Clear(); return Task.FromResult(systemSuite); } @@ -24,27 +39,94 @@ public sealed class InMemorySystemSuiteRepository : ISystemSuiteRepository, IUni public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { + var visibles = _store.Values.Where(EsVisible); var all = tenantId.HasValue - ? _store.Values.Where(s => s.Props.TenantId.GetValue() == tenantId.Value).ToList() - : _store.Values.ToList(); + ? visibles.Where(s => s.Props.TenantId.GetValue() == tenantId.Value).ToList() + : visibles.ToList(); all.ForEach(s => s.BrokenRules.Clear()); return Task.FromResult>(all); } + // Sonda de unicidad del código, no lectura del catálogo: incluye a propósito los eliminados + // lógicamente, igual que su equivalente de PostgreSQL, porque la lápida conserva el código. public Task GetByCodeAsync(Code code, CancellationToken cancellationToken = default) { - var systemSuite = _store.Values.FirstOrDefault(s => string.Equals(s.Props.Code.GetValue(), code.GetValue(), StringComparison.Ordinal)); + var systemSuite = _store.Values + .FirstOrDefault(s => string.Equals(s.Props.Code.GetValue(), code.GetValue(), StringComparison.Ordinal)); systemSuite?.BrokenRules.Clear(); return Task.FromResult(systemSuite); } public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { - var filtered = _store.Values.Where(s => s.Props.TenantId.GetValue() == tenantId).ToList(); + var filtered = _store.Values.Where(EsVisible).Where(s => s.Props.TenantId.GetValue() == tenantId).ToList(); filtered.ForEach(s => s.BrokenRules.Clear()); return Task.FromResult>(filtered); } + public Task> GetSummariesByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(EsVisible) + .Where(x => ids.Contains(x.GetId().GetValue())) + .Select(x => new Ums.Domain.Authorization.SystemSuite.SystemSuiteSummary(x.GetId().GetValue(), x.Props.Code.GetValue(), x.Props.Name.GetValue())) + .ToList(); + + return Task.FromResult>(items); + } + + public Task GetPageAsync( + Ums.Domain.Authorization.SystemSuite.SystemSuitePageQuery query, + CancellationToken cancellationToken = default) + { + var items = _store.Values.Where(EsVisible); + + if (query.TenantId.HasValue) + items = items.Where(x => x.Props.TenantId.GetValue() == query.TenantId.Value); + + if (!string.IsNullOrWhiteSpace(query.Status)) + items = items.Where(x => string.Equals(x.Props.Status.ToString(), query.Status, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var s = query.Search; + items = query.SearchField switch + { + "code" => items.Where(x => x.Props.Code.GetValue().Contains(s, StringComparison.OrdinalIgnoreCase)), + "id" => items.Where(x => x.GetId().GetValue().ToString().Contains(s, StringComparison.OrdinalIgnoreCase)), + _ => items.Where(x => x.Props.Name.GetValue().Contains(s, StringComparison.OrdinalIgnoreCase)), + }; + } + + var lista = items.ToList(); + + var ordenados = (query.SortBy, query.Descending) switch + { + ("code", true) => lista.OrderByDescending(x => x.Props.Code.GetValue()), + ("code", false) => lista.OrderBy(x => x.Props.Code.GetValue()), + ("status", true) => lista.OrderByDescending(x => x.Props.Status.ToString()), + ("status", false) => lista.OrderBy(x => x.Props.Status.ToString()), + (_, true) => lista.OrderByDescending(x => x.Props.Name.GetValue()), + _ => lista.OrderBy(x => x.Props.Name.GetValue()), + }; + + var ids = ordenados + .Skip((query.Page - 1) * query.PageSize) + .Take(query.PageSize) + .Select(x => x.GetId().GetValue()) + .ToList(); + + return Task.FromResult(new Ums.Domain.Authorization.SystemSuite.SystemSuitePage(ids, lista.Count)); + } + + public Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + var porId = _store.Values.Where(EsVisible).ToDictionary(x => x.GetId().GetValue()); + var items = ids.Where(porId.ContainsKey).Select(id => porId[id]).ToList(); + items.ForEach(item => item.BrokenRules.Clear()); + return Task.FromResult>(items); + } + public Task AddAsync(SystemSuiteAggregate aggregate, CancellationToken cancellationToken = default) { _store[aggregate.Props.Id.GetValue()] = aggregate; @@ -57,6 +139,16 @@ public Task UpdateAsync(SystemSuiteAggregate aggregate, CancellationToken cancel return Task.CompletedTask; } + // El almacén en memoria solo conoce sistemas: no ve roles, plantillas ni inquilinos, así que no + // puede responder por ellos. Declara «sin referencias vivas» en lugar de inventar un bloqueo; la + // guarda de cascada se ejerce y se verifica contra PostgreSQL (G-246). + public Task GetDependentsAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(Ums.Domain.Authorization.SystemSuite.SystemSuiteDependents.None); + + // Aquí tampoco hay borrado: `UpdateAsync` reemplaza el agregado con su estado `Deleted` y a + // partir de ahí `EsVisible` lo oculta. El sistema sigue en el almacén, igual que la fila sigue + // en la tabla. + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); @@ -69,3 +161,5 @@ public void Seed(SystemSuiteAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTemplateAssignmentRuleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTemplateAssignmentRuleRepository.cs index 15659487..81c010d5 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTemplateAssignmentRuleRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTemplateAssignmentRuleRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -73,3 +74,5 @@ public Task UpdateAsync(AssignmentRuleAggregate aggregate, CancellationToken can public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTenantRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTenantRepository.cs index 361c91a3..57d94871 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTenantRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTenantRepository.cs @@ -1,9 +1,12 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory +#pragma warning disable S1144 namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Ums.Domain.Identity; +using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Kernel; using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; @@ -34,7 +37,8 @@ public InMemoryTenantRepository(IHttpContextAccessor? httpContextAccessor = null public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) => GetByIdAsync(id, cancellationToken); - // G-161: en memoria no hay filtro global, así que la colección del agregado está siempre completa. + // G-161: en memoria no hay filtro global, así que la colección está siempre completa. + // ADR-0164 §2.3: incluye las cerradas — su código sigue ocupado. public Task BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) { _store.TryGetValue(tenantId, out var tenant); @@ -43,6 +47,27 @@ public Task BranchCodeExistsAsync(Guid tenantId, string code, Cancellation return Task.FromResult(exists); } + /// + /// + /// El almacén en memoria conserva la MISMA instancia del agregado, así que los asientos que el + /// dominio anotó siguen en el búfer de la sucursal: no hay volcado que simular. Basta con leerlos + /// en orden para que la bitácora sea consultable igual que contra PostgreSQL. + /// + public Task> GetBranchLifecycleAsync( + Guid tenantId, Guid branchId, CancellationToken cancellationToken = default) + { + _store.TryGetValue(tenantId, out var tenant); + + var asientos = tenant?.Branches + .FirstOrDefault(b => b.Props.Id.GetValue() == branchId)? + .PendingLifecycleEntries + .OrderBy(e => e.OccurredAtUtc) + .ThenBy(e => e.Episode.Id) + .ToList() ?? []; + + return Task.FromResult>(asientos); + } + public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { var all = _store.Values.ToList(); @@ -63,7 +88,7 @@ public Task> GetAllAsync(Guid? tenantId = null, C // REC-12: InMemory — delegate to in-memory filter (acceptable for test/dev data volumes) public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( int page, int pageSize, string? search, string? status, string sortBy, string sortOrder, - Guid? tenantId = null, CancellationToken cancellationToken = default) + Guid? tenantId = null, CancellationToken cancellationToken = default, string? searchField = null) { var all = await GetAllAsync(tenantId, cancellationToken); var query = all.AsEnumerable(); @@ -71,7 +96,9 @@ public Task> GetAllAsync(Guid? tenantId = null, C if (!string.IsNullOrWhiteSpace(search)) { var lower = search.ToLower(); - query = sortBy.ToLower() == "code" + // El campo de búsqueda lo determina `searchField` (parámetro `criteria` del API), no el orden. + var field = string.IsNullOrWhiteSpace(searchField) ? sortBy : searchField; + query = field.ToLower() == "code" ? query.Where(t => t.Code.GetValue().ToLower().Contains(lower)) : query.Where(t => t.Props.Name.GetValue().ToLower().Contains(lower)); } @@ -134,3 +161,7 @@ public void Seed(TenantAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S1144 + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserAccountRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserAccountRepository.cs index a99ca8a1..5b283f00 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserAccountRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserAccountRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -141,4 +142,12 @@ public Task CountActiveByTenantAsync(Guid tenantId, CancellationToken cance => Task.FromResult(_store.Values.Count(u => u.Props.TenantId.GetValue() == tenantId && u.Props.Status == Ums.Domain.Enums.UserStatus.Active)); + + public Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default) + => Task.FromResult(_store.Values.Count(u => + u.Props.BranchId != null && + u.Props.BranchId.GetValue() == branchId && + u.Props.Status == Ums.Domain.Enums.UserStatus.Active)); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserDocumentRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserDocumentRepository.cs index 0c9f24e1..997ddfff 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserDocumentRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserDocumentRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -22,10 +23,10 @@ public Task> GetAllAsync(Guid? tenantId = n public Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) { var f = _store.Values.Where(e => e.Props.UserId.GetValue() == userId).ToList(); f.ForEach(e => e.BrokenRules.Clear()); return Task.FromResult>(f); } - public Task AddAsync(UserDocumentAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(UserDocumentAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(UserDocumentAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(UserDocumentAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(UserDocumentAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -33,3 +34,5 @@ public void Seed(UserDocumentAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserManagementDelegationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserManagementDelegationRepository.cs index af2a6227..801948d8 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserManagementDelegationRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserManagementDelegationRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -106,3 +107,5 @@ public Task UpdateAsync(UserManagementDelegationAggregate aggregate, Cancellatio public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/AuditAppendOnlyGuardInterceptor.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/AuditAppendOnlyGuardInterceptor.cs new file mode 100644 index 00000000..b23787d8 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/AuditAppendOnlyGuardInterceptor.cs @@ -0,0 +1,89 @@ +using System; +using System.Globalization; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Ums.Infrastructure.Persistence.Audit.Entities; + +namespace Ums.Infrastructure.Persistence.Interceptors; + +/// +/// G-081 — Anti-tamper de la traza de auditoría a NIVEL DE PERSISTENCIA (no repudio). +/// +/// +/// La traza de auditoría —, tabla audit."AuditRecords"— es +/// APPEND-ONLY: el dominio no expone métodos de mutación (INV-AU1) y el repositorio +/// (PostgreSqlAuditRecordRepository) solo ofrece AppendAsync/GetByIdAsync/ +/// QueryBy*. Faltaba, sin embargo, el enforcement en la capa de persistencia: nada impedía +/// que un SaveChanges mutara o borrara una traza ya escrita. Este interceptor cierra esa +/// brecha: intercepta el pipeline de SaveChanges del y +/// RECHAZA (lanza) cualquier entrada de en estado +/// o . Solo se permite +/// (INSERT). +/// +/// +/// +/// Defensa app-layer y portable (independiente del motor y del rol de BD). A diferencia de un +/// REVOKE UPDATE, DELETE sobre la tabla de auditoría —que un rol owner/superuser +/// ignora, misma causa que G-020: la app se conecta como postgres—, este guard actúa +/// siempre que la escritura pase por el . +/// +/// +/// +/// NO altera el estampado de que realiza +/// : aquel opera sobre la interfaz en TODAS las entidades +/// (y permite Modified para refrescar UpdatedBy/UpdatedAtUtc); éste opera solo +/// sobre la entidad concreta de la traza de auditoría y prohíbe su mutación/borrado. Ambos conviven +/// en el mismo pipeline: si se intenta modificar una traza, este guard aborta el SaveChanges +/// completo antes de tocar la base de datos. +/// +/// +public sealed class AuditAppendOnlyGuardInterceptor : SaveChangesInterceptor +{ + // Mensaje de no-repudio; el marcador {0} se rellena con el estado rechazado (Modified/Deleted). + internal const string TamperMessageFormat = + "No repudio (G-081): la traza de auditoría (AuditRecordRecord) es append-only a nivel de " + + "persistencia. Se rechazó una operación de {0} sobre una traza de auditoría; solo se permite " + + "INSERT (Added). UPDATE/DELETE de auditoría está denegado."; + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, + InterceptionResult result) + { + if (eventData.Context is not null) + EnforceAppendOnly(eventData.Context); + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is not null) + EnforceAppendOnly(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + /// + /// Recorre las entradas de la traza de auditoría () en el + /// y lanza + /// si alguna intenta MODIFICAR o BORRAR una traza ya + /// escrita. Se tipa contra la entidad concreta (no contra ) para + /// no interferir con el estampado general de auditoría. + /// + public static void EnforceAppendOnly(DbContext context) + { + var tampered = context.ChangeTracker + .Entries() + .FirstOrDefault(entry => entry.State is EntityState.Modified or EntityState.Deleted); + + if (tampered is not null) + { + throw new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, TamperMessageFormat, tampered.State)); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/OrganizationDbContextInterceptor.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/OrganizationDbContextInterceptor.cs index 66a6b152..f5d45962 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/OrganizationDbContextInterceptor.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/OrganizationDbContextInterceptor.cs @@ -1,14 +1,50 @@ using System.Data.Common; using Microsoft.EntityFrameworkCore.Diagnostics; +using Ums.Application.Common.Interfaces; namespace Ums.Infrastructure.Persistence.Interceptors; /// -/// EF Core interceptor that sets the organization context in the SQL Server session. -/// Application-layer tenant filtering remains the primary isolation mechanism; this context supports database RLS as a failsafe. +/// EF Core connection interceptor that publishes the current organization/tenant id to the +/// PostgreSQL session as the GUC app.current_organization_id, so it can back a +/// database-level Row-Level Security (RLS) failsafe (G-020). +/// +/// +/// The previous implementation set a SQL Server session context (sp_set_session_context). +/// Under PostgreSQL-only persistence (D-008 / ADR-UMS-089) that T-SQL no longer applies, and the +/// interceptor had degraded to a no-op. Tenant isolation is still enforced primarily by the +/// application-layer EF Core global query filters on TenantId (see +/// ); RLS is defense-in-depth on top of them. +/// +/// +/// +/// The GUC is set on every connection open (false = session scope), which is correct under +/// Npgsql pooling because the logical Open() — and therefore this interceptor — fires on +/// every checkout, overwriting any value left by a previous borrower of the physical connection. +/// A organization id (internal admin / system / seeding) is published as an +/// empty string; the RLS policy must treat an empty setting as "no tenant restriction", mirroring +/// the query-filter short-circuit (!OrganizationId.HasValue). +/// +/// +/// +/// Enabling the RLS policies is intentionally NOT done here. It requires a migration that +/// (1) ALTER TABLE … ENABLE ROW LEVEL SECURITY on every tenant-scoped table, (2) creates a +/// policy USING ((SELECT current_setting('app.current_organization_id', true)) = '' OR +/// "TenantId" = (SELECT NULLIF(current_setting('app.current_organization_id', true), '')::uuid)) +/// — la GUC va envuelta en una subconsulta escalar para que PostgreSQL la evalúe una vez por +/// sentencia (InitPlan) y no una vez por fila, y la comparación es uuid contra uuid para no +/// inutilizar IX_*_TenantId (G-173, migración FixRlsTenantComparisonTypeMismatch) —, +/// and — critically — +/// (3) accounts for the fact that a table owner bypasses RLS unless +/// FORCE ROW LEVEL SECURITY is set, and that any DbContext touching those tables without this +/// interceptor (ReadModels, MasterData, raw ADO) would be filtered by a stale/empty GUC. Because RLS +/// is fail-closed, it must be verified on a live multi-tenant database before rollout (G-020). +/// /// public class OrganizationDbContextInterceptor : DbConnectionInterceptor { + private const string Guc = "app.current_organization_id"; + private readonly ITenantContext _tenantContext; public OrganizationDbContextInterceptor(ITenantContext tenantContext) @@ -16,60 +52,48 @@ public OrganizationDbContextInterceptor(ITenantContext tenantContext) _tenantContext = tenantContext; } - /// - /// Sets the SQL Server session context immediately after the connection is opened. - /// Skipped for SQLite since sp_set_session_context is SQL Server-specific. - /// + public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) + { + ApplyOrganizationContext(connection); + base.ConnectionOpened(connection, eventData); + } + public override async Task ConnectionOpenedAsync( DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default) { - if (connection is Microsoft.Data.Sqlite.SqliteConnection) - { - await base.ConnectionOpenedAsync(connection, eventData, cancellationToken); - return; - } - - if (_tenantContext.OrganizationId.HasValue) - { - using var command = connection.CreateCommand(); - command.CommandText = "EXEC sp_set_session_context @key = N'current_organization_id', @value = @organizationId;"; - - var organizationId = command.CreateParameter(); - organizationId.ParameterName = "@organizationId"; - organizationId.Value = _tenantContext.OrganizationId.Value; - command.Parameters.Add(organizationId); + await ApplyOrganizationContextAsync(connection, cancellationToken).ConfigureAwait(false); + await base.ConnectionOpenedAsync(connection, eventData, cancellationToken).ConfigureAwait(false); + } - await command.ExecuteNonQueryAsync(cancellationToken); - } + // Empty string when there is no tenant restriction (internal admin / system / seeding), + // matching the query-filter short-circuit. set_config(..., false) = session scope; re-applied + // on every open, so pooled connections never leak a previous tenant's value. + private string CurrentOrganization() + => _tenantContext.OrganizationId?.ToString() ?? string.Empty; - await base.ConnectionOpenedAsync(connection, eventData, cancellationToken); + private void ApplyOrganizationContext(DbConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT set_config('{Guc}', @org, false)"; + command.Parameters.Add(CreateOrgParameter(command)); + command.ExecuteNonQuery(); } - public override void ConnectionOpened( - DbConnection connection, - ConnectionEndEventData eventData) + private async Task ApplyOrganizationContextAsync(DbConnection connection, CancellationToken cancellationToken) { - if (connection is Microsoft.Data.Sqlite.SqliteConnection) - { - base.ConnectionOpened(connection, eventData); - return; - } - - if (_tenantContext.OrganizationId.HasValue) - { - using var command = connection.CreateCommand(); - command.CommandText = "EXEC sp_set_session_context @key = N'current_organization_id', @value = @organizationId;"; - - var organizationId = command.CreateParameter(); - organizationId.ParameterName = "@organizationId"; - organizationId.Value = _tenantContext.OrganizationId.Value; - command.Parameters.Add(organizationId); - - command.ExecuteNonQuery(); - } + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT set_config('{Guc}', @org, false)"; + command.Parameters.Add(CreateOrgParameter(command)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } - base.ConnectionOpened(connection, eventData); + private DbParameter CreateOrgParameter(DbCommand command) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = "org"; + parameter.Value = CurrentOrganization(); + return parameter; } -} \ No newline at end of file +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.Designer.cs new file mode 100644 index 00000000..2554f3a2 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.Designer.cs @@ -0,0 +1,2809 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260716012015_AddRefreshTokens")] + partial class AddRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.cs new file mode 100644 index 00000000..f2674385 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + schema: "ums_identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + FamilyId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + IssuedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RenewalCount = table.Column(type: "integer", nullable: false), + ReplacedByTokenId = table.Column(type: "uuid", nullable: true), + RevokedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + RevokedReason = table.Column(type: "character varying(60)", maxLength: 60, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_ExpiresAtUtc", + schema: "ums_identity", + table: "RefreshTokens", + column: "ExpiresAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_FamilyId", + schema: "ums_identity", + table: "RefreshTokens", + column: "FamilyId"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TenantId_UserId", + schema: "ums_identity", + table: "RefreshTokens", + columns: new[] { "TenantId", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + schema: "ums_identity", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens", + schema: "ums_identity"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.Designer.cs new file mode 100644 index 00000000..989ad5f2 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.Designer.cs @@ -0,0 +1,2964 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260716030000_AddIgaTables")] + partial class AddIgaTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.cs new file mode 100644 index 00000000..fb392c2c --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.cs @@ -0,0 +1,127 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddIgaTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "iga"); + + migrationBuilder.CreateTable( + name: "RoleMaturityStatuses", + schema: "iga", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + CurrentMaturityLevel = table.Column(type: "integer", nullable: false), + NextEligibleMaturityLevel = table.Column(type: "integer", nullable: true), + AssignedAt = table.Column(type: "timestamp with time zone", nullable: false), + CurrentLevelSince = table.Column(type: "timestamp with time zone", nullable: false), + EligibleForPromotionAt = table.Column(type: "timestamp with time zone", nullable: true), + CompletedCertificationsCount = table.Column(type: "integer", nullable: false), + CompletedTrainingsCount = table.Column(type: "integer", nullable: false), + PerformanceScore = table.Column(type: "numeric(4,2)", nullable: false), + HasNoComplianceIssues = table.Column(type: "boolean", nullable: false), + BlockingFactor = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + LastReviewedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleMaturityStatuses", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RolePromotionRequests", + schema: "iga", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + TargetUserId = table.Column(type: "uuid", nullable: false), + RequesterId = table.Column(type: "uuid", nullable: false), + CurrentRoleId = table.Column(type: "uuid", nullable: false), + TargetRoleId = table.Column(type: "uuid", nullable: false), + StatusId = table.Column(type: "integer", nullable: false), + RiskScore = table.Column(type: "integer", nullable: true), + ApproverId = table.Column(type: "uuid", nullable: true), + SecurityReviewerId = table.Column(type: "uuid", nullable: true), + ExecutorId = table.Column(type: "uuid", nullable: true), + VerifierId = table.Column(type: "uuid", nullable: true), + DecisionReason = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RolePromotionRequests", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoleMaturityStatuses_TenantId", + schema: "iga", + table: "RoleMaturityStatuses", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_RoleMaturityStatuses_TenantId_UserId", + schema: "iga", + table: "RoleMaturityStatuses", + columns: new[] { "TenantId", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_RoleMaturityStatuses_TenantId_UserId_RoleId", + schema: "iga", + table: "RoleMaturityStatuses", + columns: new[] { "TenantId", "UserId", "RoleId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RolePromotionRequests_TargetUserId", + schema: "iga", + table: "RolePromotionRequests", + column: "TargetUserId"); + + migrationBuilder.CreateIndex( + name: "IX_RolePromotionRequests_TenantId", + schema: "iga", + table: "RolePromotionRequests", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_RolePromotionRequests_TenantId_StatusId", + schema: "iga", + table: "RolePromotionRequests", + columns: new[] { "TenantId", "StatusId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoleMaturityStatuses", + schema: "iga"); + + migrationBuilder.DropTable( + name: "RolePromotionRequests", + schema: "iga"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260606225603_InitialPostgresCreate.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260606225603_InitialPostgresCreate.cs index 9a18c471..582601c2 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260606225603_InitialPostgresCreate.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260606225603_InitialPostgresCreate.cs @@ -1853,6 +1853,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( @@ -2027,5 +2028,6 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "SystemSuites", schema: "ums_authorization"); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs index ffca898f..f13dc4b4 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs @@ -38,6 +38,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AlterColumn( @@ -176,5 +177,6 @@ protected override void Down(MigrationBuilder migrationBuilder) table: "OutboxState", column: "Created"); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040459_TestFinal.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040459_TestFinal.cs index 6c1bce11..bbcaf749 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040459_TestFinal.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040459_TestFinal.cs @@ -14,9 +14,11 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040745_UpdatePostgresFinal.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040745_UpdatePostgresFinal.cs index a4026a8c..c80b9155 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040745_UpdatePostgresFinal.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040745_UpdatePostgresFinal.cs @@ -14,9 +14,11 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607041826_UpdatePostgresFinal3.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607041826_UpdatePostgresFinal3.cs index 8597a787..91dd4924 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607041826_UpdatePostgresFinal3.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607041826_UpdatePostgresFinal3.cs @@ -14,9 +14,11 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044419_SyncDrift.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044419_SyncDrift.cs index fb2c2162..2ca26871 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044419_SyncDrift.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044419_SyncDrift.cs @@ -14,9 +14,11 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044815_AddPgCryptoExtension.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044815_AddPgCryptoExtension.cs index e96cf5b1..0a646438 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044815_AddPgCryptoExtension.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044815_AddPgCryptoExtension.cs @@ -15,10 +15,12 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AlterDatabase() .OldAnnotation("Npgsql:PostgresExtension:pgcrypto", ",,"); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204741_WidenApprovalWorkflowCode.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204741_WidenApprovalWorkflowCode.cs index 909a1194..8db46838 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204741_WidenApprovalWorkflowCode.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204741_WidenApprovalWorkflowCode.cs @@ -23,6 +23,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AlterColumn( @@ -36,5 +37,6 @@ protected override void Down(MigrationBuilder migrationBuilder) oldType: "character varying(50)", oldMaxLength: 50); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204858_PostgresRowVersionDefaults.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204858_PostgresRowVersionDefaults.cs index aa0bbd23..62bb9232 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204858_PostgresRowVersionDefaults.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204858_PostgresRowVersionDefaults.cs @@ -209,6 +209,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AlterColumn( @@ -408,5 +409,6 @@ protected override void Down(MigrationBuilder migrationBuilder) oldType: "bytea", oldDefaultValueSql: "gen_random_bytes(8)"); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.Designer.cs new file mode 100644 index 00000000..e6f7b234 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.Designer.cs @@ -0,0 +1,2917 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260714200354_AddSingleManagementOwnerIndex")] + partial class AddSingleManagementOwnerIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SubMenuId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SubMenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteOptions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MenuId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("MenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteSubMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBrandingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BackgroundStyleId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CustomDomain") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DnsCnameTarget") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DnsVerificationStatusId") + .HasColumnType("integer"); + + b.Property("FooterText") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HeadlineText") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Logo") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("LogoFormatId") + .HasColumnType("integer"); + + b.Property("MagicLinkFallbackEnabled") + .HasColumnType("boolean"); + + b.Property("PrimaryButtonLabel") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SecondaryText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CustomDomain") + .IsUnique() + .HasFilter("\"CustomDomain\" IS NOT NULL"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("TenantBrandings", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", "SubMenu") + .WithMany("Options") + .HasForeignKey("SubMenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SubMenu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", "Menu") + .WithMany("SubMenus") + .HasForeignKey("MenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBrandingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithOne("Branding") + .HasForeignKey("Ums.Infrastructure.Persistence.Identity.Entities.TenantBrandingRecord", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Navigation("SubMenus"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Menus"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("Branding"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.cs new file mode 100644 index 00000000..45464460 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddSingleManagementOwnerIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Tenants_SingleManagementOwner", + schema: "ums_identity", + table: "Tenants", + column: "IsManagementOwner", + unique: true, + filter: "\"IsManagementOwner\" = true"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tenants_SingleManagementOwner", + schema: "ums_identity", + table: "Tenants"); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.Designer.cs new file mode 100644 index 00000000..925a41ae --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.Designer.cs @@ -0,0 +1,2966 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260715165202_AddSystemSuiteNodes")] + partial class AddSystemSuiteNodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SubMenuId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SubMenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteOptions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MenuId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("MenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteSubMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", "SubMenu") + .WithMany("Options") + .HasForeignKey("SubMenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SubMenu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", "Menu") + .WithMany("SubMenus") + .HasForeignKey("MenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Navigation("SubMenus"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Menus"); + + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.cs new file mode 100644 index 00000000..5a035311 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddSystemSuiteNodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SystemSuiteNodes", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ModuleId = table.Column(type: "uuid", nullable: false), + ParentNodeId = table.Column(type: "uuid", nullable: true), + NodeKindId = table.Column(type: "integer", nullable: false), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + StatusId = table.Column(type: "integer", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + Responsable = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Criticidad = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ProductoImpactado = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + ComponenteTecnico = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Dependencias = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Evidencias = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + TrazabilidadSdlc = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteNodes", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteNodes_SystemSuiteModules_ModuleId", + column: x => x.ModuleId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteModules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SystemSuiteNodes_SystemSuiteNodes_ParentNodeId", + column: x => x.ParentNodeId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteNodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SystemSuiteNodeActions", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + NodeId = table.Column(type: "uuid", nullable: false), + ActionCode = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteNodeActions", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteNodeActions_SystemSuiteNodes_NodeId", + column: x => x.NodeId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteNodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteNodeActions_NodeId_ActionCode", + schema: "ums_authorization", + table: "SystemSuiteNodeActions", + columns: new[] { "NodeId", "ActionCode" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteNodes_ModuleId_ParentNodeId_Code", + schema: "ums_authorization", + table: "SystemSuiteNodes", + columns: new[] { "ModuleId", "ParentNodeId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteNodes_ParentNodeId", + schema: "ums_authorization", + table: "SystemSuiteNodes", + column: "ParentNodeId"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SystemSuiteNodeActions", + schema: "ums_authorization"); + + migrationBuilder.DropTable( + name: "SystemSuiteNodes", + schema: "ums_authorization"); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.Designer.cs new file mode 100644 index 00000000..345eb8c9 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.Designer.cs @@ -0,0 +1,2966 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260715171139_ProjectHierarchyToNodes")] + partial class ProjectHierarchyToNodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SubMenuId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SubMenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteOptions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MenuId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("MenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteSubMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", "SubMenu") + .WithMany("Options") + .HasForeignKey("SubMenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SubMenu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", "Menu") + .WithMany("SubMenus") + .HasForeignKey("MenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Navigation("SubMenus"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Menus"); + + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.cs new file mode 100644 index 00000000..403265b4 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// Proyección de datos (ADR-0090): copia la jerarquía rígida + /// Menús → Submenús → Opciones al árbol recursivo `SystemSuiteNodes`, + /// reusando los `Id` para que la jerarquía se enlace sola + /// (submenu.MenuId ⇒ nodo-menú; option.SubMenuId ⇒ nodo-submenú) y las + /// funcionalidades de las opciones al puente N:M `SystemSuiteNodeActions`. + /// Idempotente (NOT EXISTS). No borra el modelo rígido (se retira en Fase E). + /// + public partial class ProjectHierarchyToNodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1) Menús → nodos raíz (NodeKindId = 1, sin padre). + migrationBuilder.Sql(@" +INSERT INTO ums_authorization.""SystemSuiteNodes"" + (""Id"", ""ModuleId"", ""ParentNodeId"", ""NodeKindId"", ""Code"", ""Label"", ""Description"", + ""StatusId"", ""SortOrder"", ""CreatedBy"", ""CreatedAtUtc"", ""UpdatedBy"", ""UpdatedAtUtc"", ""AuditTimeSpan"") +SELECT m.""Id"", m.""ModuleId"", NULL, 1, m.""Code"", m.""Label"", m.""Description"", + 1, m.""SortOrder"", m.""CreatedBy"", m.""CreatedAtUtc"", m.""UpdatedBy"", m.""UpdatedAtUtc"", m.""AuditTimeSpan"" +FROM ums_authorization.""SystemSuiteMenus"" m +WHERE NOT EXISTS (SELECT 1 FROM ums_authorization.""SystemSuiteNodes"" n WHERE n.""Id"" = m.""Id"");"); + + // 2) Submenús → nodos (NodeKindId = 2, padre = nodo-menú; módulo del menú). + migrationBuilder.Sql(@" +INSERT INTO ums_authorization.""SystemSuiteNodes"" + (""Id"", ""ModuleId"", ""ParentNodeId"", ""NodeKindId"", ""Code"", ""Label"", ""Description"", + ""StatusId"", ""SortOrder"", ""CreatedBy"", ""CreatedAtUtc"", ""UpdatedBy"", ""UpdatedAtUtc"", ""AuditTimeSpan"") +SELECT sm.""Id"", mn.""ModuleId"", sm.""MenuId"", 2, sm.""Code"", sm.""Label"", sm.""Description"", + 1, sm.""SortOrder"", sm.""CreatedBy"", sm.""CreatedAtUtc"", sm.""UpdatedBy"", sm.""UpdatedAtUtc"", sm.""AuditTimeSpan"" +FROM ums_authorization.""SystemSuiteSubMenus"" sm +JOIN ums_authorization.""SystemSuiteMenus"" mn ON mn.""Id"" = sm.""MenuId"" +WHERE NOT EXISTS (SELECT 1 FROM ums_authorization.""SystemSuiteNodes"" n WHERE n.""Id"" = sm.""Id"");"); + + // 3) Opciones → nodos hoja (NodeKindId = 3, padre = nodo-submenú; módulo del menú). + migrationBuilder.Sql(@" +INSERT INTO ums_authorization.""SystemSuiteNodes"" + (""Id"", ""ModuleId"", ""ParentNodeId"", ""NodeKindId"", ""Code"", ""Label"", ""Description"", + ""StatusId"", ""SortOrder"", ""CreatedBy"", ""CreatedAtUtc"", ""UpdatedBy"", ""UpdatedAtUtc"", ""AuditTimeSpan"") +SELECT o.""Id"", mn.""ModuleId"", o.""SubMenuId"", 3, o.""Code"", o.""Label"", o.""Description"", + 1, o.""SortOrder"", o.""CreatedBy"", o.""CreatedAtUtc"", o.""UpdatedBy"", o.""UpdatedAtUtc"", o.""AuditTimeSpan"" +FROM ums_authorization.""SystemSuiteOptions"" o +JOIN ums_authorization.""SystemSuiteSubMenus"" sm ON sm.""Id"" = o.""SubMenuId"" +JOIN ums_authorization.""SystemSuiteMenus"" mn ON mn.""Id"" = sm.""MenuId"" +WHERE NOT EXISTS (SELECT 1 FROM ums_authorization.""SystemSuiteNodes"" n WHERE n.""Id"" = o.""Id"");"); + + // 4) ActionCode de cada opción → puente N:M. + migrationBuilder.Sql(@" +INSERT INTO ums_authorization.""SystemSuiteNodeActions"" (""Id"", ""NodeId"", ""ActionCode"") +SELECT gen_random_uuid(), o.""Id"", o.""ActionCode"" +FROM ums_authorization.""SystemSuiteOptions"" o +WHERE o.""ActionCode"" IS NOT NULL AND o.""ActionCode"" <> '' +AND NOT EXISTS ( + SELECT 1 FROM ums_authorization.""SystemSuiteNodeActions"" na + WHERE na.""NodeId"" = o.""Id"" AND na.""ActionCode"" = o.""ActionCode"");"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + // Revierte solo los nodos proyectados (cuyo Id proviene del modelo rígido). + migrationBuilder.Sql(@" +DELETE FROM ums_authorization.""SystemSuiteNodeActions"" na +USING ums_authorization.""SystemSuiteOptions"" o +WHERE na.""NodeId"" = o.""Id"";"); + + migrationBuilder.Sql(@" +DELETE FROM ums_authorization.""SystemSuiteNodes"" n +WHERE n.""Id"" IN (SELECT ""Id"" FROM ums_authorization.""SystemSuiteOptions"") + OR n.""Id"" IN (SELECT ""Id"" FROM ums_authorization.""SystemSuiteSubMenus"") + OR n.""Id"" IN (SELECT ""Id"" FROM ums_authorization.""SystemSuiteMenus"");"); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.Designer.cs new file mode 100644 index 00000000..4f45ab80 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.Designer.cs @@ -0,0 +1,2751 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260715184938_DropSystemSuiteMenusSubMenusOptions")] + partial class DropSystemSuiteMenusSubMenusOptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.cs new file mode 100644 index 00000000..1b16b5a3 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.cs @@ -0,0 +1,142 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class DropSystemSuiteMenusSubMenusOptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SystemSuiteOptions", + schema: "ums_authorization"); + + migrationBuilder.DropTable( + name: "SystemSuiteSubMenus", + schema: "ums_authorization"); + + migrationBuilder.DropTable( + name: "SystemSuiteMenus", + schema: "ums_authorization"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SystemSuiteMenus", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ModuleId = table.Column(type: "uuid", nullable: false), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteMenus", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteMenus_SystemSuiteModules_ModuleId", + column: x => x.ModuleId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteModules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SystemSuiteSubMenus", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MenuId = table.Column(type: "uuid", nullable: false), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteSubMenus", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteSubMenus_SystemSuiteMenus_MenuId", + column: x => x.MenuId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteMenus", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SystemSuiteOptions", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SubMenuId = table.Column(type: "uuid", nullable: false), + ActionCode = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteOptions", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteOptions_SystemSuiteSubMenus_SubMenuId", + column: x => x.SubMenuId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteSubMenus", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteMenus_ModuleId_Code", + schema: "ums_authorization", + table: "SystemSuiteMenus", + columns: new[] { "ModuleId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteOptions_SubMenuId_Code", + schema: "ums_authorization", + table: "SystemSuiteOptions", + columns: new[] { "SubMenuId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteSubMenus_MenuId_Code", + schema: "ums_authorization", + table: "SystemSuiteSubMenus", + columns: new[] { "MenuId", "Code" }, + unique: true); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.Designer.cs new file mode 100644 index 00000000..0214a1c4 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.Designer.cs @@ -0,0 +1,3146 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260717163004_AddUmsPlatformOutbox")] + partial class AddUmsPlatformOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.cs new file mode 100644 index 00000000..c0051455 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.cs @@ -0,0 +1,158 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUmsPlatformOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InboxState", + schema: "ums_platform", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MessageId = table.Column(type: "uuid", nullable: false), + ConsumerId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", nullable: true, defaultValueSql: "gen_random_bytes(8)"), + Received = table.Column(type: "timestamp with time zone", nullable: false), + ReceiveCount = table.Column(type: "integer", nullable: false), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true), + Consumed = table.Column(type: "timestamp with time zone", nullable: true), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxState", x => x.Id); + table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId }); + }); + + migrationBuilder.CreateTable( + name: "OutboxState", + schema: "ums_platform", + columns: table => new + { + OutboxId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", nullable: true, defaultValueSql: "gen_random_bytes(8)"), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxState", x => x.OutboxId); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessage", + schema: "ums_platform", + columns: table => new + { + SequenceNumber = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + EnqueueTime = table.Column(type: "timestamp with time zone", nullable: true), + SentTime = table.Column(type: "timestamp with time zone", nullable: false), + Headers = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + InboxMessageId = table.Column(type: "uuid", nullable: true), + InboxConsumerId = table.Column(type: "uuid", nullable: true), + OutboxId = table.Column(type: "uuid", nullable: true), + MessageId = table.Column(type: "uuid", nullable: false), + ContentType = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + MessageType = table.Column(type: "text", nullable: false), + Body = table.Column(type: "text", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: true), + CorrelationId = table.Column(type: "uuid", nullable: true), + InitiatorId = table.Column(type: "uuid", nullable: true), + RequestId = table.Column(type: "uuid", nullable: true), + SourceAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + DestinationAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ResponseAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + FaultAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber); + table.ForeignKey( + name: "FK_OutboxMessage_InboxState_InboxMessageId_InboxConsumerId", + columns: x => new { x.InboxMessageId, x.InboxConsumerId }, + principalSchema: "ums_platform", + principalTable: "InboxState", + principalColumns: new[] { "MessageId", "ConsumerId" }); + table.ForeignKey( + name: "FK_OutboxMessage_OutboxState_OutboxId", + column: x => x.OutboxId, + principalSchema: "ums_platform", + principalTable: "OutboxState", + principalColumn: "OutboxId"); + }); + + migrationBuilder.CreateIndex( + name: "IX_InboxState_Delivered", + schema: "ums_platform", + table: "InboxState", + column: "Delivered"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_EnqueueTime", + schema: "ums_platform", + table: "OutboxMessage", + column: "EnqueueTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_ExpirationTime", + schema: "ums_platform", + table: "OutboxMessage", + column: "ExpirationTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber", + schema: "ums_platform", + table: "OutboxMessage", + columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_OutboxId_SequenceNumber", + schema: "ums_platform", + table: "OutboxMessage", + columns: new[] { "OutboxId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxState_Created", + schema: "ums_platform", + table: "OutboxState", + column: "Created"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OutboxMessage", + schema: "ums_platform"); + + migrationBuilder.DropTable( + name: "InboxState", + schema: "ums_platform"); + + migrationBuilder.DropTable( + name: "OutboxState", + schema: "ums_platform"); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.Designer.cs new file mode 100644 index 00000000..08bae661 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.Designer.cs @@ -0,0 +1,3146 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260720152552_EnableRowLevelSecurity")] + partial class EnableRowLevelSecurity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.cs new file mode 100644 index 00000000..d82db407 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + /// + public partial class EnableRowLevelSecurity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + var tables = new (string Schema, string TableName, bool IsGlobalNullable)[] + { + ("ums_identity", "TenantBranches", false), + ("ums_identity", "TenantParameters", false), + ("ums_identity", "TenantIdentityProviders", false), + ("ums_identity", "UserAccounts", false), + ("ums_authorization", "Profiles", false), + ("ums_authorization", "Roles", false), + ("ums_authorization", "SystemSuites", false), + ("ums_authorization", "PermissionTemplates", false), + ("ums_identity", "UserManagementDelegations", false), + ("ums_configuration", "IdpConfigurations", false), + ("ums_configuration", "AppConfigurations", true), + ("ums_configuration", "ParameterTenantValues", false), + ("approvals", "ApprovalWorkflows", false), + ("approvals", "NotificationRules", false), + ("approvals", "DocumentTypes", false), + ("approvals", "AccessEnforcementPolicies", false), + ("iga", "RoleMaturityStatuses", false), + ("iga", "RolePromotionRequests", false) + }; + + foreach (var t in tables) + { + migrationBuilder.Sql($@"ALTER TABLE ""{t.Schema}"".""{t.TableName}"" ENABLE ROW LEVEL SECURITY;"); + migrationBuilder.Sql($@"ALTER TABLE ""{t.Schema}"".""{t.TableName}"" FORCE ROW LEVEL SECURITY;"); + + string policyUsing = t.IsGlobalNullable + ? @"current_setting('app.current_organization_id', true) = '' OR ""TenantId"" IS NULL OR ""TenantId""::text = current_setting('app.current_organization_id', true)" + : @"current_setting('app.current_organization_id', true) = '' OR ""TenantId""::text = current_setting('app.current_organization_id', true)"; + + migrationBuilder.Sql($@" + CREATE POLICY tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"" + FOR ALL + USING ({policyUsing}); + "); + } + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + var tables = new (string Schema, string TableName)[] + { + ("ums_identity", "TenantBranches"), + ("ums_identity", "TenantParameters"), + ("ums_identity", "TenantIdentityProviders"), + ("ums_identity", "UserAccounts"), + ("ums_authorization", "Profiles"), + ("ums_authorization", "Roles"), + ("ums_authorization", "SystemSuites"), + ("ums_authorization", "PermissionTemplates"), + ("ums_identity", "UserManagementDelegations"), + ("ums_configuration", "IdpConfigurations"), + ("ums_configuration", "AppConfigurations"), + ("ums_configuration", "ParameterTenantValues"), + ("approvals", "ApprovalWorkflows"), + ("approvals", "NotificationRules"), + ("approvals", "DocumentTypes"), + ("approvals", "AccessEnforcementPolicies"), + ("iga", "RoleMaturityStatuses"), + ("iga", "RolePromotionRequests") + }; + + foreach (var t in tables) + { + migrationBuilder.Sql($@"DROP POLICY IF EXISTS tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"";"); + migrationBuilder.Sql($@"ALTER TABLE ""{t.Schema}"".""{t.TableName}"" DISABLE ROW LEVEL SECURITY;"); + } + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.Designer.cs new file mode 100644 index 00000000..cc7f31d6 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.Designer.cs @@ -0,0 +1,3147 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260720154635_FixFeatureFlagUniqueIndex")] + partial class FixFeatureFlagUniqueIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.cs new file mode 100644 index 00000000..25d6dc6c --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class FixFeatureFlagUniqueIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_FeatureFlags_SystemSuiteId_FlagCode", + schema: "ums_configuration", + table: "FeatureFlags"); + + migrationBuilder.CreateIndex( + name: "IX_FeatureFlags_SystemSuiteId_FlagCode", + schema: "ums_configuration", + table: "FeatureFlags", + columns: new[] { "SystemSuiteId", "FlagCode" }, + unique: true, + filter: "\"StatusId\" != 3"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_FeatureFlags_SystemSuiteId_FlagCode", + schema: "ums_configuration", + table: "FeatureFlags"); + + migrationBuilder.CreateIndex( + name: "IX_FeatureFlags_SystemSuiteId_FlagCode", + schema: "ums_configuration", + table: "FeatureFlags", + columns: new[] { "SystemSuiteId", "FlagCode" }, + unique: true); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.Designer.cs new file mode 100644 index 00000000..e7b400bd --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.Designer.cs @@ -0,0 +1,3155 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260721154054_AddAccountLockout")] + partial class AddAccountLockout + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.cs new file mode 100644 index 00000000..20978833 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddAccountLockout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FailedLoginAttempts", + schema: "ums_identity", + table: "UserAccounts", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LockedUntilUtc", + schema: "ums_identity", + table: "UserAccounts", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FailedLoginAttempts", + schema: "ums_identity", + table: "UserAccounts"); + + migrationBuilder.DropColumn( + name: "LockedUntilUtc", + schema: "ums_identity", + table: "UserAccounts"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.Designer.cs new file mode 100644 index 00000000..648c2fa9 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.Designer.cs @@ -0,0 +1,3158 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260722012535_AddTenantDefaultSystemSuite")] + partial class AddTenantDefaultSystemSuite + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.cs new file mode 100644 index 00000000..3f6e6f9e --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddTenantDefaultSystemSuite : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DefaultSystemSuiteId", + schema: "ums_identity", + table: "Tenants", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DefaultSystemSuiteId", + schema: "ums_identity", + table: "Tenants"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.Designer.cs new file mode 100644 index 00000000..8d0f86a1 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.Designer.cs @@ -0,0 +1,3161 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260722191208_AddGracePeriodToEnforcementPolicy")] + partial class AddGracePeriodToEnforcementPolicy + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.cs new file mode 100644 index 00000000..f5376a6f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddGracePeriodToEnforcementPolicy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GracePeriodDays", + schema: "approvals", + table: "AccessEnforcementPolicies", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GracePeriodDays", + schema: "approvals", + table: "AccessEnforcementPolicies"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.Designer.cs new file mode 100644 index 00000000..276f9b37 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.Designer.cs @@ -0,0 +1,3173 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260801214202_AddAuthorizationLookupIndexes")] + partial class AddAuthorizationLookupIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.cs new file mode 100644 index 00000000..75696d36 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddAuthorizationLookupIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteDomainResources_ModuleId", + schema: "ums_authorization", + table: "SystemSuiteDomainResources", + column: "ModuleId"); + + migrationBuilder.CreateIndex( + name: "IX_Profiles_UserId_Active", + schema: "ums_authorization", + table: "Profiles", + column: "UserId", + filter: "\"IsActive\" = true"); + + migrationBuilder.CreateIndex( + name: "IX_ProfilePermissions_TemplateId", + schema: "ums_authorization", + table: "ProfilePermissions", + column: "TemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_PermissionTemplateItems_TargetId_IsActive", + schema: "ums_authorization", + table: "PermissionTemplateItems", + columns: new[] { "TargetId", "IsActive" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_SystemSuiteDomainResources_ModuleId", + schema: "ums_authorization", + table: "SystemSuiteDomainResources"); + + migrationBuilder.DropIndex( + name: "IX_Profiles_UserId_Active", + schema: "ums_authorization", + table: "Profiles"); + + migrationBuilder.DropIndex( + name: "IX_ProfilePermissions_TemplateId", + schema: "ums_authorization", + table: "ProfilePermissions"); + + migrationBuilder.DropIndex( + name: "IX_PermissionTemplateItems_TargetId_IsActive", + schema: "ums_authorization", + table: "PermissionTemplateItems"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.Designer.cs new file mode 100644 index 00000000..500fd3db --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.Designer.cs @@ -0,0 +1,3178 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260802002315_AddAppSettingClientVisibility")] + partial class AddAppSettingClientVisibility + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.cs new file mode 100644 index 00000000..b6890173 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddAppSettingClientVisibility : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsClientVisible", + schema: "ums_authorization", + table: "SystemSuiteAppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsClientVisible", + schema: "ums_authorization", + table: "SystemSuiteAppSettings"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.Designer.cs new file mode 100644 index 00000000..3cee327f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.Designer.cs @@ -0,0 +1,3186 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260802004743_AddMenuNodePresentation")] + partial class AddMenuNodePresentation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.cs new file mode 100644 index 00000000..540e1417 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddMenuNodePresentation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Icon", + schema: "ums_authorization", + table: "SystemSuiteNodes", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "Route", + schema: "ums_authorization", + table: "SystemSuiteNodes", + type: "character varying(400)", + maxLength: 400, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Icon", + schema: "ums_authorization", + table: "SystemSuiteNodes"); + + migrationBuilder.DropColumn( + name: "Route", + schema: "ums_authorization", + table: "SystemSuiteNodes"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.Designer.cs new file mode 100644 index 00000000..d536215c --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.Designer.cs @@ -0,0 +1,3190 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260802014735_AddModuleIcon")] + partial class AddModuleIcon + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.cs new file mode 100644 index 00000000..50ab96af --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddModuleIcon : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Icon", + schema: "ums_authorization", + table: "SystemSuiteModules", + type: "character varying(64)", + maxLength: 64, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Icon", + schema: "ums_authorization", + table: "SystemSuiteModules"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.Designer.cs new file mode 100644 index 00000000..8ce2d904 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.Designer.cs @@ -0,0 +1,3237 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260802060255_AddPasswordResetTokens")] + partial class AddPasswordResetTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.cs new file mode 100644 index 00000000..f21dcb29 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddPasswordResetTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasswordResetTokens", + schema: "ums_identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + IssuedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ConsumedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + InvalidatedReason = table.Column(type: "character varying(60)", maxLength: 60, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordResetTokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_ExpiresAtUtc", + schema: "ums_identity", + table: "PasswordResetTokens", + column: "ExpiresAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TenantId_UserId", + schema: "ums_identity", + table: "PasswordResetTokens", + columns: new[] { "TenantId", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TokenHash", + schema: "ums_identity", + table: "PasswordResetTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasswordResetTokens", + schema: "ums_identity"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.Designer.cs new file mode 100644 index 00000000..8e86ceb4 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.Designer.cs @@ -0,0 +1,3237 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804135513_FixRlsTenantComparisonTypeMismatch")] + partial class FixRlsTenantComparisonTypeMismatch + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.cs new file mode 100644 index 00000000..a7797f1a --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// G-173: reescribe el predicado de la politica tenant_isolation_policy creada en + /// 20260720152552_EnableRowLevelSecurity para que la comparacion por inquilino sea + /// del mismo tipo por ambos lados y deje de costar una llamada por fila. + /// + /// Que estaba mal. El predicado original era + /// current_setting('app.current_organization_id', true) = '' OR "TenantId"::text = current_setting(...). + /// Dos defectos independientes: + /// + /// + /// "TenantId"::text compara uuid convertido a texto contra texto. El indice + /// IX_*_TenantId esta construido sobre el uuid, no sobre su representacion textual, + /// asi que el predicado nunca puede resolverse por indice; ademas obliga a materializar + /// una cadena de 36 bytes por cada fila evaluada. + /// + /// + /// current_setting es STABLE, no IMMUTABLE: PostgreSQL no la pliega en tiempo de + /// planificacion y la invoca una vez por fila. Con el OR por delante, el + /// coste se paga sobre todas las filas de la tabla, no sobre las del inquilino. + /// + /// + /// + /// Que hace este arreglo. Envuelve cada lectura de la GUC en una subconsulta + /// escalar sin correlacion. PostgreSQL la convierte en un InitPlan y la evalua + /// una sola vez por sentencia, dejando en el filtro una simple comparacion contra un + /// parametro de ejecucion; y compara uuid = uuid, sin conversion. Medido sobre 600.000 + /// filas y 300 inquilinos, el plan de la ruta caliente pasa de + /// Filter: (current_setting(...) = '' OR ("TenantId")::text = current_setting(...)) + /// a Filter: (($0 = ''::text) OR ("TenantId" = $1)): mismo Index Cond sobre + /// IX_*_TenantId, entre 3x y 5x menos tiempo de ejecucion. + /// + /// Por que se conserva el OR. La compuerta "GUC vacia = sin restriccion" + /// es contractual (admin interno, sistema y siembra; ver OrganizationDbContextInterceptor) + /// y no puede expresarse como igualdad. Se probo sustituirla por un rango conjuntivo + /// ("TenantId" BETWEEN $0 AND $1), que si convierte el propio predicado RLS en + /// Index Cond; se descarto porque el planificador no conoce los parametros y elige ese + /// indice tambien en modo bypass, donde debe leer la tabla entera: 129.354 buffers frente a + /// 7.999 del recorrido secuencial. Se prefiere no degradar siembra ni migraciones. + /// + /// Semantica preservada, bit a bit. En particular el caso fail-closed: + /// si la GUC no se fijo nunca en la sesion, current_setting(..., true) devuelve NULL, + /// NULL = '' es NULL y la fila queda oculta. Por eso la compuerta compara el texto + /// crudo contra '' y NO usa NULLIF(...) IS NULL: esa variante, aparentemente + /// equivalente, trata la GUC ausente como "sin restriccion" y abre la tabla completa + /// (verificado: 600.000 filas visibles en lugar de 0). + /// + /// Efecto colateral positivo: al evaluarse en el lider y propagarse como parametro + /// (Params Evaluated), los planes paralelos dejan de depender de que cada worker + /// reciba la GUC, un caso en el que la politica anterior devolvia recuentos incompletos. + /// + /// Referencia: ADR-0111 (arquitectura de datos PostgreSQL) y ADR-0010 (el aislamiento + /// vive en RBAC/ABAC de aplicacion; la RLS es defensa en profundidad, no el control primario). + /// + public partial class FixRlsTenantComparisonTypeMismatch : Migration + { + // Mismo conjunto de tablas que 20260720152552_EnableRowLevelSecurity: si esa lista cambia, + // esta debe cambiar con ella. El flag indica TenantId anulable (filas globales). + private static readonly (string Schema, string TableName, bool IsGlobalNullable)[] Tables = + [ + ("ums_identity", "TenantBranches", false), + ("ums_identity", "TenantParameters", false), + ("ums_identity", "TenantIdentityProviders", false), + ("ums_identity", "UserAccounts", false), + ("ums_authorization", "Profiles", false), + ("ums_authorization", "Roles", false), + ("ums_authorization", "SystemSuites", false), + ("ums_authorization", "PermissionTemplates", false), + ("ums_identity", "UserManagementDelegations", false), + ("ums_configuration", "IdpConfigurations", false), + ("ums_configuration", "AppConfigurations", true), + ("ums_configuration", "ParameterTenantValues", false), + ("approvals", "ApprovalWorkflows", false), + ("approvals", "NotificationRules", false), + ("approvals", "DocumentTypes", false), + ("approvals", "AccessEnforcementPolicies", false), + ("iga", "RoleMaturityStatuses", false), + ("iga", "RolePromotionRequests", false) + ]; + + // Subconsulta escalar sin correlacion: PostgreSQL la resuelve como InitPlan, una vez por + // sentencia, en lugar de invocar current_setting() una vez por fila. + private const string GucRaw = + "(SELECT current_setting('app.current_organization_id', true))"; + + // NULLIF(...,'') distingue "sin restriccion" (cadena vacia) de un identificador real; el + // cast a uuid se aplica al PARAMETRO, no a la columna, que es lo que mantiene el indice usable. + private const string GucAsUuid = + "(SELECT NULLIF(current_setting('app.current_organization_id', true), '')::uuid)"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + foreach (var t in Tables) + { + // El predicado nuevo. El orden de los terminos importa poco al planificador, pero + // deja primero la compuerta para que el modo bypass corte sin tocar el InitPlan uuid. + var policyUsing = t.IsGlobalNullable + ? $@"{GucRaw} = '' OR ""TenantId"" IS NULL OR ""TenantId"" = {GucAsUuid}" + : $@"{GucRaw} = '' OR ""TenantId"" = {GucAsUuid}"; + + // DROP + CREATE en lugar de ALTER POLICY: la migracion corre dentro de una + // transaccion, asi que no existe ventana en la que la tabla quede sin politica + // (con FORCE ROW LEVEL SECURITY eso equivaldria a denegar cualquier lectura). + migrationBuilder.Sql( + $@"DROP POLICY IF EXISTS tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"";"); + + migrationBuilder.Sql($@" + CREATE POLICY tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"" + FOR ALL + USING ({policyUsing}); + "); + } + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Restaura literalmente el predicado de 20260720152552_EnableRowLevelSecurity, + // incluida la conversion a texto: el Down debe devolver la base al estado anterior, + // no a uno "mejor". + foreach (var t in Tables) + { + var policyUsing = t.IsGlobalNullable + ? @"current_setting('app.current_organization_id', true) = '' OR ""TenantId"" IS NULL OR ""TenantId""::text = current_setting('app.current_organization_id', true)" + : @"current_setting('app.current_organization_id', true) = '' OR ""TenantId""::text = current_setting('app.current_organization_id', true)"; + + migrationBuilder.Sql( + $@"DROP POLICY IF EXISTS tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"";"); + + migrationBuilder.Sql($@" + CREATE POLICY tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"" + FOR ALL + USING ({policyUsing}); + "); + } + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.Designer.cs new file mode 100644 index 00000000..fe0fd00a --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.Designer.cs @@ -0,0 +1,3249 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804161744_AddParameterDefinitionSoftDelete")] + partial class AddParameterDefinitionSoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.cs new file mode 100644 index 00000000..0081fee7 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddParameterDefinitionSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAtUtc", + schema: "ums_configuration", + table: "ParameterDefinitions", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "DeletedBy", + schema: "ums_configuration", + table: "ParameterDefinitions", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + schema: "ums_configuration", + table: "ParameterDefinitions", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_ParameterDefinitions_IsDeleted", + schema: "ums_configuration", + table: "ParameterDefinitions", + column: "IsDeleted"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ParameterDefinitions_IsDeleted", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropColumn( + name: "DeletedAtUtc", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropColumn( + name: "DeletedBy", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + schema: "ums_configuration", + table: "ParameterDefinitions"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.Designer.cs new file mode 100644 index 00000000..2160533f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.Designer.cs @@ -0,0 +1,3245 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804164227_AddTenantParameterSoftDelete")] + partial class AddTenantParameterSoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.cs new file mode 100644 index 00000000..fb8f4c5e --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// Borrado LÓGICO de parámetros de inquilino. El repositorio hacía DELETE físico y la + /// configuración histórica del inquilino se perdía sin remedio; la política del propietario es que + /// solo exista borrado lógico porque el negocio consulta datos antiguos. + /// + /// La columna nace con DEFAULT false, así que ninguna fila existente cambia de semántica: todo lo + /// que hay hoy está vivo. El índice parcial cubre el predicado que el filtro global añade a TODA + /// consulta de parámetros, igual que en Tenants y UserAccounts. + /// + public partial class AddTenantParameterSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsDeleted", + schema: "ums_identity", + table: "TenantParameters", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_TenantParameters_IsDeleted", + schema: "ums_identity", + table: "TenantParameters", + column: "IsDeleted", + filter: "\"IsDeleted\" = false"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_TenantParameters_IsDeleted", + schema: "ums_identity", + table: "TenantParameters"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + schema: "ums_identity", + table: "TenantParameters"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.Designer.cs new file mode 100644 index 00000000..4cdbe3ea --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.Designer.cs @@ -0,0 +1,3333 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804194508_AddBranchClosureAndLifecycleLog")] + partial class AddBranchClosureAndLifecycleLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("EpisodeId") + .HasColumnType("integer"); + + b.Property("GeofencingSnapshot") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("NameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("BranchId", "OccurredAtUtc"); + + b.ToTable("TenantBranchLifecycleEntries", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("IsClosed") + .HasFilter("\"IsClosed\" = false"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", "Branch") + .WithMany("LifecycleEntries") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Navigation("LifecycleEntries"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.cs new file mode 100644 index 00000000..e9a82f4b --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.cs @@ -0,0 +1,179 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// ADR-0164 aplicado a las SUCURSALES. Dos cosas a la vez, porque son la misma decisión: + /// + /// 1. Cierre definitivo en lugar de borrado físico. Tenant.RemoveBranch quitaba + /// la sucursal de la colección y el reconciliador de EF lo traducía en un DELETE real. Como + /// ni Profiles.BranchId ni UserAccounts.BranchId tienen clave ajena contra esta + /// tabla, la base no decía nada y las filas quedaban huérfanas en silencio; y un despacho de 2024 + /// se quedaba sin la sucursal que lo explicaba. Ahora hay IsClosed / ClosedAtUtc / + /// ClosedBy: la fila permanece. La columna nace con DEFAULT false, así que ninguna + /// fila existente cambia de semántica — todo lo que hay hoy sigue vivo. + /// + /// Lo que esta migración NO hace, a propósito: no toca + /// IX_TenantBranches_TenantId_Code. El índice único sigue SIN filtrar por el estado de + /// cierre (§2.3), que es lo que mantiene ocupado el código de una sucursal cerrada y evita que dos + /// sucursales distintas compartan código dentro del mismo inquilino. Es una omisión deliberada, + /// no un olvido. + /// + /// 2. Bitácora de episodios. TenantBranchLifecycleEntries registra cada + /// apertura, baja, reapertura y cierre con su fecha, su autor y la foto (nombre y geocerca) de la + /// época. Sin ella, un bool IsActive y una sola marca de auditoría hacen indistinguibles + /// dos épocas de la misma sucursal, y una auditoría de 2028 sobre un despacho de 2024 vería la + /// dirección y el responsable de hoy. + /// + /// Relleno del histórico. Se siembra UN asiento de apertura por cada sucursal + /// existente, tomado de su propia auditoría de creación, para que la bitácora no empiece a media + /// historia. NO se inventan bajas ni reaperturas de las sucursales hoy inactivas: se sabe que + /// están inactivas, no cuándo ni quién las desactivó, y un asiento inventado sería peor que la + /// ausencia. Ese hueco se cierra solo con el uso. + /// + public partial class AddBranchClosureAndLifecycleLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClosedAtUtc", + schema: "ums_identity", + table: "TenantBranches", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "ClosedBy", + schema: "ums_identity", + table: "TenantBranches", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsClosed", + schema: "ums_identity", + table: "TenantBranches", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "TenantBranchLifecycleEntries", + schema: "ums_identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + BranchId = table.Column(type: "uuid", nullable: false), + EpisodeId = table.Column(type: "integer", nullable: false), + OccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ActorId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + NameSnapshot = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + GeofencingSnapshot = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + Reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantBranchLifecycleEntries", x => x.Id); + table.ForeignKey( + name: "FK_TenantBranchLifecycleEntries_TenantBranches_BranchId", + column: x => x.BranchId, + principalSchema: "ums_identity", + principalTable: "TenantBranches", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TenantBranches_IsClosed", + schema: "ums_identity", + table: "TenantBranches", + column: "IsClosed", + filter: "\"IsClosed\" = false"); + + migrationBuilder.CreateIndex( + name: "IX_TenantBranchLifecycleEntries_BranchId_OccurredAtUtc", + schema: "ums_identity", + table: "TenantBranchLifecycleEntries", + columns: new[] { "BranchId", "OccurredAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_TenantBranchLifecycleEntries_TenantId", + schema: "ums_identity", + table: "TenantBranchLifecycleEntries", + column: "TenantId"); + + // ── Relleno del histórico ──────────────────────────────────────────────────────────── + // Va ANTES de activar la RLS: la política es fail-closed —con la GUC de inquilino sin + // fijar, `current_setting(...,true)` devuelve NULL y el predicado no se cumple— y una + // migración no tiene inquilino de contexto, así que un INSERT posterior sería rechazado. + // EpisodeId = 1 es BranchLifecycleEpisode.Opened. + migrationBuilder.Sql(@" + INSERT INTO ums_identity.""TenantBranchLifecycleEntries"" + (""Id"", ""TenantId"", ""BranchId"", ""EpisodeId"", ""OccurredAtUtc"", + ""ActorId"", ""NameSnapshot"", ""GeofencingSnapshot"", ""Reason"") + SELECT + gen_random_uuid(), + b.""TenantId"", + b.""Id"", + 1, + b.""CreatedAtUtc"", + b.""CreatedBy"", + b.""Name"", + b.""GeofencingMetadata"", + 'Asiento de apertura reconstruido desde la auditoría de creación al estrenar la bitácora.' + FROM ums_identity.""TenantBranches"" b; + "); + + // ── Aislamiento por inquilino ──────────────────────────────────────────────────────── + // Mismo tratamiento que el resto de tablas acotadas por inquilino: ENABLE + FORCE y la + // política con el predicado ya optimizado de 20260804135513 (InitPlan por sentencia y + // comparación uuid = uuid, sin conversión a texto, para no perder el índice). + migrationBuilder.Sql(@"ALTER TABLE ums_identity.""TenantBranchLifecycleEntries"" ENABLE ROW LEVEL SECURITY;"); + migrationBuilder.Sql(@"ALTER TABLE ums_identity.""TenantBranchLifecycleEntries"" FORCE ROW LEVEL SECURITY;"); + migrationBuilder.Sql(@" + CREATE POLICY tenant_isolation_policy ON ums_identity.""TenantBranchLifecycleEntries"" + FOR ALL + USING ( + (SELECT current_setting('app.current_organization_id', true)) = '' + OR ""TenantId"" = (SELECT NULLIF(current_setting('app.current_organization_id', true), '')::uuid) + ); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@"DROP POLICY IF EXISTS tenant_isolation_policy ON ums_identity.""TenantBranchLifecycleEntries"";"); + + migrationBuilder.DropTable( + name: "TenantBranchLifecycleEntries", + schema: "ums_identity"); + + migrationBuilder.DropIndex( + name: "IX_TenantBranches_IsClosed", + schema: "ums_identity", + table: "TenantBranches"); + + migrationBuilder.DropColumn( + name: "ClosedAtUtc", + schema: "ums_identity", + table: "TenantBranches"); + + migrationBuilder.DropColumn( + name: "ClosedBy", + schema: "ums_identity", + table: "TenantBranches"); + + migrationBuilder.DropColumn( + name: "IsClosed", + schema: "ums_identity", + table: "TenantBranches"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.Designer.cs new file mode 100644 index 00000000..cd72f879 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.Designer.cs @@ -0,0 +1,3337 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804225858_LiberarRanuraDeConfiguracionAlBorrar")] + partial class LiberarRanuraDeConfiguracionAlBorrar + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique() + .HasFilter("\"StatusId\" != 4"); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique() + .HasFilter("\"StatusId\" != 4"); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique() + .HasFilter("\"StatusId\" != 4"); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("EpisodeId") + .HasColumnType("integer"); + + b.Property("GeofencingSnapshot") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("NameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("BranchId", "OccurredAtUtc"); + + b.ToTable("TenantBranchLifecycleEntries", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("IsClosed") + .HasFilter("\"IsClosed\" = false"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", "Branch") + .WithMany("LifecycleEntries") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Navigation("LifecycleEntries"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.cs new file mode 100644 index 00000000..a1a79fd1 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.cs @@ -0,0 +1,136 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// El borrado lógico LIBERA la ranura de configuración: los cuatro índices únicos del esquema + /// `ums_configuration` pasan a ser PARCIALES y dejan fuera lo eliminado. + /// + /// Por qué solo aquí. ADR-0164 §2.3 fijó que la clave natural queda ocupada para siempre, y para + /// una sucursal o un sistema es correcto: su código lo inventa quien opera —cierras LIMA-01 y + /// abres LIMA-05— y reutilizarlo volvería ambigua una consulta histórica. El código de una + /// configuración es otra cosa: viene de un catálogo cerrado. `MFA_REQUIRED_FOR_ADMIN` es *el* + /// nombre de ese parámetro, no uno que se elija. Dejar esa ranura ocupada equivalía a que borrar + /// la configuración global de un parámetro impidiera volver a configurarlo NUNCA. El propietario + /// del producto acotó la regla el 2026-08-04; sucursales y `SystemSuite` NO cambian. + /// + /// Qué NO cambia: la fila eliminada sigue en la tabla. Esto no reintroduce el borrado físico —el + /// repositorio sigue sin exponerlo— y todo lo demás de ADR-0164 continúa vigente. + /// + /// Sobre la reversión: `Down` restaura los índices totales y solo puede aplicarse si en ese + /// momento no conviven una fila viva y una lápida con la misma clave. Es la consecuencia + /// esperable de revertir una regla que ya se usó; el dato no se pierde, la reversión se rechaza. + /// + public partial class LiberarRanuraDeConfiguracionAlBorrar : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ParameterTenantValues_TenantId_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterTenantValues"); + + migrationBuilder.DropIndex( + name: "IX_ParameterGlobalValues_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterGlobalValues"); + + migrationBuilder.DropIndex( + name: "IX_ParameterDefinitions_Code", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropIndex( + name: "IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code", + schema: "ums_configuration", + table: "AppConfigurations"); + + migrationBuilder.CreateIndex( + name: "IX_ParameterTenantValues_TenantId_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterTenantValues", + columns: new[] { "TenantId", "ParameterDefinitionId" }, + unique: true, + filter: "\"StatusId\" != 4"); + + migrationBuilder.CreateIndex( + name: "IX_ParameterGlobalValues_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterGlobalValues", + column: "ParameterDefinitionId", + unique: true, + filter: "\"StatusId\" != 4"); + + migrationBuilder.CreateIndex( + name: "IX_ParameterDefinitions_Code", + schema: "ums_configuration", + table: "ParameterDefinitions", + column: "Code", + unique: true, + filter: "\"IsDeleted\" = false"); + + migrationBuilder.CreateIndex( + name: "IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code", + schema: "ums_configuration", + table: "AppConfigurations", + columns: new[] { "TenantId", "SystemSuiteId", "ModuleId", "Code" }, + unique: true, + filter: "\"StatusId\" != 4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ParameterTenantValues_TenantId_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterTenantValues"); + + migrationBuilder.DropIndex( + name: "IX_ParameterGlobalValues_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterGlobalValues"); + + migrationBuilder.DropIndex( + name: "IX_ParameterDefinitions_Code", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropIndex( + name: "IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code", + schema: "ums_configuration", + table: "AppConfigurations"); + + migrationBuilder.CreateIndex( + name: "IX_ParameterTenantValues_TenantId_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterTenantValues", + columns: new[] { "TenantId", "ParameterDefinitionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ParameterGlobalValues_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterGlobalValues", + column: "ParameterDefinitionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ParameterDefinitions_Code", + schema: "ums_configuration", + table: "ParameterDefinitions", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code", + schema: "ums_configuration", + table: "AppConfigurations", + columns: new[] { "TenantId", "SystemSuiteId", "ModuleId", "Code" }, + unique: true); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/UmsPlatformDbContextModelSnapshot.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/UmsPlatformDbContextModelSnapshot.cs index ba2b35d4..1d0ab6e9 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/UmsPlatformDbContextModelSnapshot.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/UmsPlatformDbContextModelSnapshot.cs @@ -8,7 +8,7 @@ #nullable disable -namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +namespace Ums.Infrastructure.Persistence.Migrations { [DbContext(typeof(UmsPlatformDbContext))] partial class UmsPlatformDbContextModelSnapshot : ModelSnapshot @@ -24,6 +24,176 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => { b.Property("Id") @@ -46,6 +216,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EnforcementActionId") .HasColumnType("integer"); + b.Property("GracePeriodDays") + .HasColumnType("integer"); + b.Property("IsActive") .HasColumnType("boolean"); @@ -635,6 +808,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") .IsUnique(); @@ -762,6 +938,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ProfileId"); + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); b.ToTable("ProfilePermissions", "ums_authorization"); @@ -822,10 +1001,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TenantId"); - b.HasIndex("UserId"); - b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + b.ToTable("Profiles", "ums_authorization"); }); @@ -978,6 +1160,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(4000) .HasColumnType("character varying(4000)"); + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("ScopeId") .HasColumnType("integer"); @@ -1049,13 +1236,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + b.HasIndex("SystemSuiteId", "Code") .IsUnique(); b.ToTable("SystemSuiteDomainResources", "ums_authorization"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1084,17 +1274,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(1000) .HasColumnType("character varying(1000)"); - b.Property("Label") + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("ModuleId") - .HasColumnType("uuid"); - b.Property("SortOrder") .HasColumnType("integer"); + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + b.Property("UpdatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -1104,81 +1301,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("ModuleId", "Code") + b.HasIndex("SystemSuiteId", "Code") .IsUnique(); - b.ToTable("SystemSuiteMenus", "ums_authorization"); + b.ToTable("SystemSuiteModules", "ums_authorization"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("AuditTimeSpan") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") + b.Property("ActionCode") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); - b.Property("Description") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("SortOrder") - .HasColumnType("integer"); - - b.Property("StatusId") - .HasColumnType("integer"); - - b.Property("SystemSuiteId") + b.Property("NodeId") .HasColumnType("uuid"); - b.Property("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - b.HasKey("Id"); - b.HasIndex("SystemSuiteId", "Code") + b.HasIndex("NodeId", "ActionCode") .IsUnique(); - b.ToTable("SystemSuiteModules", "ums_authorization"); + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("ActionCode") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - b.Property("AuditTimeSpan") .IsRequired() .HasMaxLength(100) @@ -1189,6 +1345,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -1197,22 +1357,63 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + b.Property("Description") .IsRequired() .HasMaxLength(1000) .HasColumnType("character varying(1000)"); + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.Property("Label") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("SortOrder") + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") .HasColumnType("integer"); - b.Property("SubMenuId") + b.Property("ParentNodeId") .HasColumnType("uuid"); + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + b.Property("UpdatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -1222,10 +1423,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("SubMenuId", "Code") + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") .IsUnique(); - b.ToTable("SystemSuiteOptions", "ums_authorization"); + b.ToTable("SystemSuiteNodes", "ums_authorization"); }); modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => @@ -1292,7 +1495,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SystemSuites", "ums_authorization"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1300,97 +1503,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AuditTimeSpan") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); b.Property("CreatedBy") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); + .HasColumnType("text"); - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + b.Property("Priority") + .HasColumnType("integer"); - b.Property("MenuId") + b.Property("RoleId") .HasColumnType("uuid"); - b.Property("SortOrder") + b.Property("StatusId") .HasColumnType("integer"); + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + b.Property("UpdatedAtUtc") .HasColumnType("timestamp with time zone"); b.Property("UpdatedBy") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.HasKey("Id"); - b.HasIndex("MenuId", "Code") - .IsUnique(); - - b.ToTable("SystemSuiteSubMenus", "ums_authorization"); + b.ToTable("TemplateAssignmentRules", "ums_platform"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AuditTimeSpan") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .IsRequired() - .HasColumnType("text"); - - b.Property("Priority") - .HasColumnType("integer"); - - b.Property("RoleId") - .HasColumnType("uuid"); - - b.Property("StatusId") - .HasColumnType("integer"); - - b.Property("TemplateId") - .HasColumnType("uuid"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("TemplateAssignmentRules", "ums_platform"); - }); - - modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1474,7 +1622,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("StatusId"); b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") - .IsUnique(); + .IsUnique() + .HasFilter("\"StatusId\" != 4"); b.ToTable("AppConfigurations", "ums_configuration"); }); @@ -1618,7 +1767,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SystemSuiteId"); b.HasIndex("SystemSuiteId", "FlagCode") - .IsUnique(); + .IsUnique() + .HasFilter("\"StatusId\" != 3"); b.ToTable("FeatureFlags", "ums_configuration"); }); @@ -1737,6 +1887,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(4000) .HasColumnType("character varying(4000)"); + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("Description") .IsRequired() .HasMaxLength(1000) @@ -1748,6 +1905,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("IsMandatory") .HasColumnType("boolean"); @@ -1774,10 +1934,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.HasIndex("Code") - .IsUnique(); + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); b.HasIndex("IsActive"); + b.HasIndex("IsDeleted"); + b.HasIndex("ScopeId"); b.ToTable("ParameterDefinitions", "ums_configuration"); @@ -1828,7 +1991,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.HasIndex("ParameterDefinitionId") - .IsUnique(); + .IsUnique() + .HasFilter("\"StatusId\" != 4"); b.HasIndex("StatusId"); @@ -1887,11 +2051,162 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TenantId"); b.HasIndex("TenantId", "ParameterDefinitionId") - .IsUnique(); + .IsUnique() + .HasFilter("\"StatusId\" != 4"); b.ToTable("ParameterTenantValues", "ums_configuration"); }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("EpisodeId") + .HasColumnType("integer"); + + b.Property("GeofencingSnapshot") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("NameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("BranchId", "OccurredAtUtc"); + + b.ToTable("TenantBranchLifecycleEntries", "ums_identity"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => { b.Property("Id") @@ -1903,6 +2218,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("Code") .IsRequired() .HasMaxLength(100) @@ -1923,6 +2245,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("IsClosed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -1940,6 +2267,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("IsClosed") + .HasFilter("\"IsClosed\" = false"); + b.HasIndex("TenantId", "Code") .IsUnique(); @@ -1954,8 +2284,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AuditTimeSpan") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.Property("BackgroundStyleId") .HasColumnType("integer"); @@ -1965,35 +2294,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.Property("CustomDomain") - .HasMaxLength(255) - .HasColumnType("character varying(255)"); + .HasColumnType("text"); b.Property("DnsCnameTarget") .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); + .HasColumnType("text"); b.Property("DnsVerificationStatusId") .HasColumnType("integer"); b.Property("FooterText") .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + .HasColumnType("text"); b.Property("HeadlineText") .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + .HasColumnType("text"); b.Property("Logo") .IsRequired() - .HasMaxLength(4000) - .HasColumnType("character varying(4000)"); + .HasColumnType("text"); b.Property("LogoFormatId") .HasColumnType("integer"); @@ -2003,18 +2326,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PrimaryButtonLabel") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.Property("PrimaryColor") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); + .HasColumnType("text"); b.Property("SecondaryText") .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); + .HasColumnType("text"); b.Property("TenantId") .HasColumnType("uuid"); @@ -2023,19 +2343,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("UpdatedBy") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.HasKey("Id"); - b.HasIndex("CustomDomain") - .IsUnique() - .HasFilter("\"CustomDomain\" IS NOT NULL"); - b.HasIndex("TenantId") .IsUnique(); - b.ToTable("TenantBrandings", "ums_identity"); + b.ToTable("TenantBrandingRecord", "ums_platform"); }); modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => @@ -2139,6 +2454,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("IsSensitive") .HasColumnType("boolean"); @@ -2168,6 +2488,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + b.HasIndex("TenantId", "Code", "IsActive") .IsUnique() .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") @@ -2204,6 +2527,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + b.Property("DeletedAtUtc") .HasColumnType("timestamp with time zone"); @@ -2260,6 +2586,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("IsDeleted") .HasFilter("\"IsDeleted\" = false"); + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + b.HasIndex("ParentTenantId"); b.ToTable("Tenants", "ums_identity"); @@ -2469,6 +2800,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ExpiresAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property("IdentityReference") .HasMaxLength(255) .HasColumnType("character varying(255)"); @@ -2481,6 +2817,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + b.Property("RowVersion") .IsConcurrencyToken() .IsRequired() @@ -2610,6 +2949,173 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserManagementDelegations", "ums_identity"); }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => { b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") @@ -2714,17 +3220,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SystemSuite"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => - { - b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") - .WithMany("Menus") - .HasForeignKey("ModuleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Module"); - }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => { b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") @@ -2736,26 +3231,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SystemSuite"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => { - b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", "SubMenu") - .WithMany("Options") - .HasForeignKey("SubMenuId") + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("SubMenu"); + b.Navigation("Node"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => { - b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", "Menu") - .WithMany("SubMenus") - .HasForeignKey("MenuId") + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Menu"); + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); }); modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => @@ -2780,6 +3282,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("FeatureFlag"); }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", "Branch") + .WithMany("LifecycleEntries") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => { b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") @@ -2855,14 +3368,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Permissions"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => { - b.Navigation("SubMenus"); + b.Navigation("Nodes"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => { - b.Navigation("Menus"); + b.Navigation("Actions"); + + b.Navigation("Children"); }); modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => @@ -2876,11 +3391,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Modules"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => - { - b.Navigation("Options"); - }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => { b.Navigation("Criteria"); @@ -2888,6 +3398,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("EvaluationLogs"); }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Navigation("LifecycleEntries"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => { b.Navigation("Branches"); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs index 68e4935e..0bc76041 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs @@ -8,24 +8,61 @@ public sealed class PersistenceOptions public AggregateStoreMode AggregateStoreMode { get; init; } = AggregateStoreMode.InMemory; - public bool UseSqliteIdentityStores { get; init; } = false; public bool UsePostgreSqlIdentityStores { get; init; } = false; - public bool UseSqliteAuthorizationStores { get; init; } = false; public bool UsePostgreSqlAuthorizationStores { get; init; } = false; - public bool UseSqliteConfigurationStores { get; init; } = false; public bool UsePostgreSqlConfigurationStores { get; init; } = false; - public bool UseSqliteApprovalsStores { get; init; } = false; public bool UsePostgreSqlApprovalsStores { get; init; } = false; - public bool UseSqliteIgaStores { get; init; } = false; public bool UsePostgreSqlIgaStores { get; init; } = false; + /// + /// G-127: cuando es true, siembra el dataset determinista FS-25 (datos de referencia + /// —roles, plantillas de permiso, suites, catálogos— + datos demo —tenants, usuarios, + /// aprobaciones—) al arrancar. Está DESACOPLADO del entorno: habilita el stage UAT (usuarios + /// reales con datos visibles al ingresar), no solo Development. Nunca se siembra en Production + /// (guarda de defensa en profundidad en InitializeUmsPlatformAsync). La siembra es + /// idempotente (guarda por tenant ancla + seeders idempotentes), segura ante reinicios. + /// public bool SeedDevData { get; init; } = true; + /// + /// Fuerza la siembra aunque el inquilino ancla ya exista. + /// + /// POR QUÉ HACE FALTA. La guarda de idempotencia de SeedAllAsync omite toda + /// la siembra en cuanto detecta el inquilino ancla, para que un reinicio de pod no re-ejecute + /// los siete sembradores. El efecto secundario es que el conjunto sembrado queda congelado en + /// lo que hubiera el primer día: si más tarde se añade un sistema, un rol o una plantilla al + /// catálogo, ninguna base existente lo recibe jamás. Así se quedó BEYONDNET sin la suite UMS. + /// + /// Hasta ahora la única salida era un reset que dropea el esquema, lo cual también + /// borra lo que NO siembra el código —el Tablero SDLC se carga por API— y obliga a rehacerlo. + /// Esta bandera permite converger sin perder nada. + /// + /// Es de un solo uso deliberado: se enciende para un despliegue, se comprueba el + /// resultado y se apaga. Dejarla encendida devolvería el coste que la guarda evita —los siete + /// sembradores en cada arranque de cada réplica— y convertiría en rutina algo que conviene + /// mirar mientras pasa. + /// + public bool ForceReseed { get; init; } = false; + public bool EnableOutbox { get; init; } = true; public bool InitializePlatformStoreOnStartup { get; init; } = false; + + /// + /// Cuando es true, el proceso migra, siembra y termina sin levantar el servidor. + /// + /// Existe para sacar la migración del arranque de cada réplica (G-169). Con una sola + /// réplica no se nota; con dos o más, las dos arrancan a la vez y las dos intentan migrar la + /// misma base. EF Core toma un bloqueo de aviso, así que no corrompe nada, pero la segunda + /// espera a la primera y puede agotar su sonda de vivacidad antes de servir una sola + /// petición. + /// + /// Con esto, el chart lo ejecuta UNA vez como Job previo al despliegue y las réplicas + /// arrancan contra una base ya migrada. + /// + public bool MigrateAndExit { get; init; } = false; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs index 0216fe9a..69cfdfe8 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs @@ -3,6 +3,5 @@ namespace Ums.Infrastructure.Persistence.Options; public enum PersistenceProvider { InMemory = 0, - Sqlite = 2, PostgreSql = 3, } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/PostgreSqlSchemaBootstrapper.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/PostgreSqlSchemaBootstrapper.cs deleted file mode 100644 index 785dad83..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/PostgreSqlSchemaBootstrapper.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Reflection; -using System.Text.RegularExpressions; -using Microsoft.EntityFrameworkCore; - -namespace Ums.Infrastructure.Persistence; - -public static partial class PostgreSqlSchemaBootstrapper -{ - private static readonly string[] ScriptOrder = - [ - "20260521_sqlserver_platform_outbox.sql", - "20260521_sqlserver_identity_aggregates.sql", - "20260521_sqlserver_authorization_profiles.sql", - "20260522_sqlserver_identity_delegations.sql", - "20260524_sqlserver_authorization_advanced.sql", - "20260523_sqlserver_configuration_aggregates.sql", - "20260523_sqlserver_audit_records.sql", - "20260523_sqlserver_approvals.sql", - "20260523_soft_delete_gdpr.sql", // REC-16 - "20260523_outbox_dispatch_lease.sql", // HARDENING-01 - "20260524_sqlserver_approvals_ep07_tables.sql", // EP-07: DocumentType, UserDocument, AEP - ]; - - /// - /// HARDENING-05: Runs all pending migration scripts under a SQL Server distributed lock - /// (sp_getapplock) so that concurrent pod startups do not run migrations simultaneously. - /// - /// Lock semantics: - /// - Mode = Exclusive → only one session holds the lock at a time. - /// - Timeout = 60 000 ms → pods wait up to 60 s for the lock; they do not crash on startup. - /// - The lock is released automatically when the connection is returned to the pool (end of scope). - /// - All migration SQL scripts are idempotent (IF NOT EXISTS guards) so a second pod that - /// acquires the lock after the first finishes will simply execute no-ops. - /// - public static async Task InitializeAsync( - UmsPlatformDbContext dbContext, - IDistributedLockProvider lockProvider, - CancellationToken cancellationToken = default) - { - await dbContext.Database.EnsureCreatedAsync(cancellationToken); - - // Open connection explicitly so the session spans the lock acquisition and release - await dbContext.Database.OpenConnectionAsync(cancellationToken); - try - { - await using var lockScope = await lockProvider.AcquireLockAsync( - dbContext, - "ums_schema_migrations", - TimeSpan.FromSeconds(60), - cancellationToken); - - var assembly = typeof(PostgreSqlSchemaBootstrapper).Assembly; - - // In PostgreSQL, EF Core Migrations and OnModelCreating fully define the schemas and tables, - // so we do not execute the SQL Server-specific .sql scripts. - } - finally - { - await dbContext.Database.CloseConnectionAsync(); - } - } - - private static IEnumerable SplitBatches(string sql) - { - return GoBatchRegex() - .Split(sql) - .Select(batch => batch.Trim()) - .Where(batch => !string.IsNullOrWhiteSpace(batch)); - } - - [GeneratedRegex(@"^\s*GO\s*$(\r?\n)?", RegexOptions.Multiline | RegexOptions.IgnoreCase)] - private static partial Regex GoBatchRegex(); -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ApprovalsAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ApprovalsAggregateFactory.cs index 06adfd17..684a398d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ApprovalsAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ApprovalsAggregateFactory.cs @@ -29,6 +29,11 @@ namespace Ums.Infrastructure.Persistence.Reflection; internal static class ApprovalsAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; public static ApprovalWorkflowAggregate RehydrateWorkflow( @@ -204,6 +209,7 @@ public static AccessEnforcementPolicyAggregate RehydrateAccessEnforcementPolicy( record.RoleId.HasValue ? RoleId.Load(record.RoleId.Value) : null, DomainEnumerationMapper.FromValue(record.EnforcementActionId), record.IsActive, + record.GracePeriodDays, ActorId.Create(record.CreatedBy)); SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuditAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuditAggregateFactory.cs index a56e3a13..ac56a350 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuditAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuditAggregateFactory.cs @@ -12,6 +12,11 @@ namespace Ums.Infrastructure.Persistence.Reflection; internal static class AuditAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; public static AuditRecordAggregate RehydrateAuditRecord(AuditRecordRecord record) diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuthorizationAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuthorizationAggregateFactory.cs index 5dbee18d..3c05e546 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuthorizationAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuthorizationAggregateFactory.cs @@ -1,11 +1,10 @@ +using System.Collections.Concurrent; using System.Reflection; using Ums.Domain.Authorization.Profile; using Ums.Domain.Authorization.Profile.ProfilePermission; using Ums.Domain.Authorization.SystemSuite.AppSetting; using Ums.Domain.Authorization.SystemSuite.Module; -using Ums.Domain.Authorization.SystemSuite.Menu; -using Ums.Domain.Authorization.SystemSuite.SubMenu; -using Ums.Domain.Authorization.SystemSuite.Option; +using Ums.Domain.Authorization.SystemSuite.MenuNode; using Ums.Domain.Authorization.SystemSuite.Action; using Ums.Domain.Authorization.SystemSuite.DomainResource; using Ums.Domain.Authorization.Template; @@ -26,15 +25,18 @@ namespace Ums.Infrastructure.Persistence.Reflection; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; using AssignmentRuleAggregate = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule; using ModuleEntity = Ums.Domain.Authorization.SystemSuite.Module.Module; -using MenuEntity = Ums.Domain.Authorization.SystemSuite.Menu.Menu; -using SubMenuEntity = Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu; -using OptionEntity = Ums.Domain.Authorization.SystemSuite.Option.Option; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using ActionEntity = Ums.Domain.Authorization.SystemSuite.Action.Action; using DomainResourceEntity = Ums.Domain.Authorization.SystemSuite.DomainResource.DomainResource; using PermissionTemplateItemEntity = Ums.Domain.Authorization.Template.PermissionTemplateItem.PermissionTemplateItem; internal static class AuthorizationAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; public static ProfileAggregate RehydrateProfile( @@ -214,70 +216,72 @@ private static ModuleEntity RehydrateModule(SystemSuiteModuleRecord record) Description.Create(record.Description), DomainEnumerationMapper.FromValue(record.StatusId), record.SortOrder, - ActorId.Create(record.CreatedBy)); + ActorId.Create(record.CreatedBy), + record.Icon); SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); var module = Construct(props); - var menus = record.Menus.OrderBy(x => x.SortOrder).Select(RehydrateMenu).ToList(); - SetField(module, "_menus", menus); + + // Árbol recursivo (ADR-0090): los nodos llegan planos (todos con el mismo + // ModuleId); se agrupan por padre y se reconstruye el árbol en memoria. + var allNodes = record.Nodes.ToList(); + var childrenByParent = allNodes + .Where(n => n.ParentNodeId.HasValue) + .GroupBy(n => n.ParentNodeId!.Value) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.OrderBy(x => x.SortOrder).ToList()); + var roots = allNodes + .Where(n => !n.ParentNodeId.HasValue) + .OrderBy(n => n.SortOrder) + .Select(r => RehydrateNode(r, childrenByParent)) + .ToList(); + SetField(module, "_nodes", roots); + module.BrokenRules.Clear(); return module; } - private static MenuEntity RehydrateMenu(SystemSuiteMenuRecord record) + private static MenuNodeEntity RehydrateNode( + SystemSuiteNodeRecord record, + IReadOnlyDictionary> childrenByParent) { - var props = new MenuProps( + var metadata = MenuNodeMetadata.Create( + record.Responsable, + record.Criticidad, + record.ProductoImpactado, + record.ComponenteTecnico, + record.Dependencias, + record.Evidencias, + record.TrazabilidadSdlc); + + var props = new MenuNodeProps( IdValueObject.Load(record.Id), ModuleId.Load(record.ModuleId), + record.ParentNodeId.HasValue ? IdValueObject.Load(record.ParentNodeId.Value) : null, + (NodeKind)record.NodeKindId, Code.Create(record.Code), Name.Create(record.Label), Description.Create(record.Description), + DomainEnumerationMapper.FromValue(record.StatusId), record.SortOrder, - ActorId.Create(record.CreatedBy)); + metadata, + ActorId.Create(record.CreatedBy), + MenuNodePresentation.Create(record.Icon, record.Route)); SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); - var menu = Construct(props); - var subMenus = record.SubMenus.OrderBy(x => x.SortOrder).Select(RehydrateSubMenu).ToList(); - SetField(menu, "_subMenus", subMenus); - menu.BrokenRules.Clear(); - return menu; - } - - private static SubMenuEntity RehydrateSubMenu(SystemSuiteSubMenuRecord record) - { - var props = new SubMenuProps( - IdValueObject.Load(record.Id), - MenuId.Load(record.MenuId), - Code.Create(record.Code), - Name.Create(record.Label), - Description.Create(record.Description), - record.SortOrder, - ActorId.Create(record.CreatedBy)); + var node = Construct(props); - SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); - var subMenu = Construct(props); - var options = record.Options.OrderBy(x => x.SortOrder).Select(RehydrateOption).ToList(); - SetField(subMenu, "_options", options); - subMenu.BrokenRules.Clear(); - return subMenu; - } + var children = childrenByParent.TryGetValue(record.Id, out var childRecords) + ? childRecords.Select(c => RehydrateNode(c, childrenByParent)).ToList() + : new List(); + SetField(node, "_children", children); - private static OptionEntity RehydrateOption(SystemSuiteOptionRecord record) - { - var props = new OptionProps( - IdValueObject.Load(record.Id), - SubMenuId.Load(record.SubMenuId), - Code.Create(record.Code), - Name.Create(record.Label), - Description.Create(record.Description), - ActionCode.Create(record.ActionCode), - record.SortOrder, - ActorId.Create(record.CreatedBy)); + var actionCodes = record.Actions + .Select(a => ActionCode.Create(a.ActionCode)) + .ToList(); + SetField(node, "_actionCodes", actionCodes); - SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); - var option = Construct(props); - option.BrokenRules.Clear(); - return option; + node.BrokenRules.Clear(); + return node; } private static ActionEntity RehydrateAction(SystemSuiteActionRecord record) @@ -322,7 +326,10 @@ private static AppSetting RehydrateAppSetting(SystemSuiteAppSettingRecord record ?? throw new InvalidOperationException("AppSetting.Create factory not found."); var result = (Result)method.Invoke(null, - [ConfigurationKey.Create(record.ConfigKey), ConfigurationValue.Create(record.ConfigValue), DomainEnumerationMapper.FromValue(record.ScopeId)])!; + [ConfigurationKey.Create(record.ConfigKey), + ConfigurationValue.Create(record.ConfigValue), + DomainEnumerationMapper.FromValue(record.ScopeId), + record.IsClientVisible])!; if (result.IsFailure) { @@ -351,28 +358,44 @@ private static PermissionTemplateItemEntity RehydrateTemplateItem(PermissionTemp return item; } + // ── Memoización de metadatos de reflexión (R-12) ───────────────────────── + // + // Rehidratar el grafo de una suite instancia del orden de 500 entidades por login, y cada + // una resolvía de nuevo su constructor, su campo o su propiedad por reflexión. Buscar + // metadatos es la parte cara de la reflexión; invocar, una vez resueltos, es barato. Los + // metadatos de un tipo no cambian en tiempo de ejecución, así que se cachean para siempre. + // `ConcurrentDictionary` porque la rehidratación ocurre en peticiones concurrentes. + + private static readonly ConcurrentDictionary<(Type Entity, Type Props), ConstructorInfo> CtorCache = new(); + private static readonly ConcurrentDictionary<(Type Target, string Field), FieldInfo> FieldCache = new(); + private static readonly ConcurrentDictionary AuditCache = new(); + private static readonly ConcurrentDictionary StringCtorCache = new(); + private static TEntity Construct(TProps props) where TEntity : class where TProps : class { - var ctor = typeof(TEntity).GetConstructor(InstanceFlags, null, [typeof(TProps)], null) - ?? throw new InvalidOperationException($"Constructor for {typeof(TEntity).Name} not found."); + var ctor = CtorCache.GetOrAdd((typeof(TEntity), typeof(TProps)), static llave => + llave.Entity.GetConstructor(InstanceFlags, null, [llave.Props], null) + ?? throw new InvalidOperationException($"Constructor for {llave.Entity.Name} not found.")); return (TEntity)ctor.Invoke([props]); } private static void SetField(object target, string fieldName, TTarget value) { - var field = target.GetType().GetField(fieldName, InstanceFlags) - ?? throw new InvalidOperationException($"Field {fieldName} not found on {target.GetType().Name}."); + var field = FieldCache.GetOrAdd((target.GetType(), fieldName), static llave => + llave.Target.GetField(llave.Field, InstanceFlags) + ?? throw new InvalidOperationException($"Field {llave.Field} not found on {llave.Target.Name}.")); field.SetValue(target, value); } private static void SetAudit(object props, string createdBy, DateTime createdAtUtc, string? updatedBy, DateTime? updatedAtUtc, string auditTimeSpan) { - var property = props.GetType().GetProperty("Audit", InstanceFlags) - ?? throw new InvalidOperationException($"Audit property not found on {props.GetType().Name}."); + var property = AuditCache.GetOrAdd(props.GetType(), static tipo => + tipo.GetProperty("Audit", InstanceFlags) + ?? throw new InvalidOperationException($"Audit property not found on {tipo.Name}.")); property.SetValue(props, AuditValueObject.Load(new AuditProps { @@ -387,8 +410,9 @@ private static void SetAudit(object props, string createdBy, DateTime createdAtU private static TValueObject ConstructStringValueObject(string value) where TValueObject : class { - var ctor = typeof(TValueObject).GetConstructor(InstanceFlags, null, [typeof(string)], null) - ?? throw new InvalidOperationException($"String constructor for {typeof(TValueObject).Name} not found."); + var ctor = StringCtorCache.GetOrAdd(typeof(TValueObject), static tipo => + tipo.GetConstructor(InstanceFlags, null, [typeof(string)], null) + ?? throw new InvalidOperationException($"String constructor for {tipo.Name} not found.")); return (TValueObject)ctor.Invoke([value]); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ConfigurationAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ConfigurationAggregateFactory.cs index 7f939621..bde208e1 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ConfigurationAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ConfigurationAggregateFactory.cs @@ -22,6 +22,11 @@ namespace Ums.Infrastructure.Persistence.Reflection; internal static class ConfigurationAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; private static readonly Type AppConfigurationIdType = Type.GetType("Ums.Domain.Kernel.ValueObjects.AppConfigurationId, Ums.Domain")!; @@ -188,12 +193,13 @@ public static ParameterDefinition RehydrateParameterDefinition(ParameterDefiniti }); // Use the private rehydration constructor via reflection. + // (13 parámetros desde que la definición lleva su marca de borrado lógico `IsDeleted`.) var propsCtor = typeof(ParameterDefinitionProps) .GetConstructors(InstanceFlags) .Single(c => { var ps = c.GetParameters(); - return ps.Length == 12 && ps[10].ParameterType == typeof(string); // version param + return ps.Length == 13 && ps[11].ParameterType == typeof(string); // version param }); var props = (ParameterDefinitionProps)propsCtor.Invoke([ @@ -205,6 +211,7 @@ public static ParameterDefinition RehydrateParameterDefinition(ParameterDefiniti DefaultValue.Create(r.DefaultValue), ParameterScope.FromValue(r.ScopeId), r.IsActive, r.IsMandatory, r.DisplayOrder, + r.IsDeleted, r.Version, audit, ]); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IdentityAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IdentityAggregateFactory.cs index 1daa81a3..28febebe 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IdentityAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IdentityAggregateFactory.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Ums.Domain.Enums; using Ums.Domain.Identity.Tenant; -using Ums.Domain.Identity.Tenant.Branding; using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Identity.Tenant.IdentityProvider; using Ums.Domain.Identity.Tenant.TenantParameter; @@ -23,13 +22,17 @@ namespace Ums.Infrastructure.Persistence.Reflection; internal static class IdentityAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; public static TenantAggregate RehydrateTenant( TenantRecord tenantRecord, IReadOnlyCollection branchRecords, - IReadOnlyCollection providerRecords, - TenantBrandingRecord? brandingRecord) + IReadOnlyCollection providerRecords) { var audit = AuditValueObject.Load(new AuditProps { @@ -49,6 +52,8 @@ public static TenantAggregate RehydrateTenant( string.IsNullOrWhiteSpace(tenantRecord.CompanyReference) ? null : CompanyReference.Create(tenantRecord.CompanyReference), tenantRecord.ParentTenantId.HasValue ? TenantId.Load(tenantRecord.ParentTenantId.Value) : null, tenantRecord.IsManagementOwner, + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino (nullable/retrocompatible). + tenantRecord.DefaultSystemSuiteId.HasValue ? SystemSuiteId.Load(tenantRecord.DefaultSystemSuiteId.Value) : null, DomainEnumerationMapper.FromValue(tenantRecord.StatusId), audit); @@ -56,11 +61,9 @@ public static TenantAggregate RehydrateTenant( var branches = branchRecords.Select(RehydrateBranch).ToList(); var providers = providerRecords.Select(RehydrateIdentityProvider).ToList(); - var branding = brandingRecord is null ? null : RehydrateBranding(brandingRecord); SetField(tenant, "_branches", branches); SetField(tenant, "_identityProviders", providers); - SetField(tenant, "_branding", branding); tenant.DomainEvents.MarkChangesAsCommitted(); tenant.BrokenRules.Clear(); @@ -85,6 +88,11 @@ public static UserAccountAggregate RehydrateUserAccount( ? (DateTimeOffset?)new DateTimeOffset(record.ExpiresAtUtc.Value, TimeSpan.Zero) : null; + // ADR-UMS-095: rehidratación del estado de bloqueo temporal. + var lockedUntil = record.LockedUntilUtc.HasValue + ? (DateTimeOffset?)new DateTimeOffset(record.LockedUntilUtc.Value, TimeSpan.Zero) + : null; + var props = new UserAccountProps( UserAccountId.Load(record.Id), TenantId.Load(record.TenantId), @@ -96,7 +104,9 @@ public static UserAccountAggregate RehydrateUserAccount( record.IdentityReferenceTypeId.HasValue ? DomainEnumerationMapper.FromValue(record.IdentityReferenceTypeId.Value) : null, audit, string.IsNullOrWhiteSpace(record.DisplayName) ? null : Name.Create(record.DisplayName), - expiresAt); + expiresAt, + record.FailedLoginAttempts, + lockedUntil); var account = Construct(props); @@ -174,7 +184,8 @@ public static TenantParameterAggregate RehydrateTenantParameter(TenantParameterR UpdatedBy = record.UpdatedBy, UpdatedAt = record.UpdatedAtUtc, TimeSpan = record.AuditTimeSpan - })); + }), + record.IsDeleted); var parameter = Construct(props); parameter.DomainEvents.MarkChangesAsCommitted(); @@ -194,6 +205,13 @@ private static Branch RehydrateBranch(TenantBranchRecord record) ActorId.Create(record.CreatedBy)); props.IsActive = record.IsActive; + // ADR-0164: el cierre definitivo se rehidrata como estado; la BITÁCORA no, a propósito. + // Ninguna invariante del agregado depende de la historia, y `Branch.Create` —que sí anota el + // episodio de apertura— no interviene aquí: la rehidratación usa el constructor privado, así + // que una relectura del inquilino no puede inventar asientos que nunca ocurrieron. + props.IsClosed = record.IsClosed; + props.ClosedAtUtc = record.ClosedAtUtc; + props.ClosedBy = record.ClosedBy; SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); return Construct(props); @@ -216,37 +234,6 @@ private static IdentityProvider RehydrateIdentityProvider(TenantIdentityProvider return Construct(props); } - private static Branding RehydrateBranding(TenantBrandingRecord record) - { - var audit = AuditValueObject.Load(new AuditProps - { - CreatedBy = record.CreatedBy, - CreatedAt = record.CreatedAtUtc, - UpdatedBy = record.UpdatedBy, - UpdatedAt = record.UpdatedAtUtc, - TimeSpan = record.AuditTimeSpan - }); - - var props = new BrandingProps( - BrandingId.Load(record.Id), - TenantId.Load(record.TenantId), - Logo.Create(record.Logo), - DomainEnumerationMapper.FromValue(record.LogoFormatId), - HexColor.Create(record.PrimaryColor), - DomainEnumerationMapper.FromValue(record.BackgroundStyleId), - LoginText.Create(record.HeadlineText), - LoginText.Create(record.SecondaryText), - LoginText.Create(record.PrimaryButtonLabel), - LoginText.Create(record.FooterText), - string.IsNullOrWhiteSpace(record.CustomDomain) ? null : CustomDomain.Create(record.CustomDomain), - DomainEnumerationMapper.FromValue(record.DnsVerificationStatusId), - DnsCnameTarget.Create(), - record.MagicLinkFallbackEnabled, - audit); - - return Construct(props); - } - private static MfaEnrollment RehydrateEnrollment(UserAccountMfaEnrollmentRecord record) { var audit = AuditValueObject.Load(new AuditProps diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IgaAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IgaAggregateFactory.cs new file mode 100644 index 00000000..29817d16 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IgaAggregateFactory.cs @@ -0,0 +1,99 @@ +using System; +using System.Reflection; +using Ums.Domain.Enums; +using Ums.Domain.IGA.RoleMaturityStatus; +using Ums.Domain.IGA.RolePromotionRequest; +using Ums.Domain.Kernel.ValueObjects; +using Ums.Infrastructure.Persistence.Iga.Entities; +using BeyondNetCode.Shell.Ddd.ValueObjects.Audit; + +namespace Ums.Infrastructure.Persistence.Reflection; + +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Rehidrata los agregados del contexto acotado IGA (ADR-UMS-093) desde sus registros de persistencia. +/// Invoca el constructor de rehidratación privado por reflexión (mismo patrón que +/// ) y marca los eventos como confirmados para que la carga +/// no vuelva a emitir el evento de creación ni deje reglas rotas colgando. +/// +internal static class IgaAggregateFactory +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] + private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + public static RoleMaturityStatusAggregate RehydrateRoleMaturityStatus(RoleMaturityStatusRecord record) + { + var props = new RoleMaturityStatusProps( + RoleMaturityStatusId.Load(record.Id), + TenantId.Load(record.TenantId), + UserId.Load(record.UserId), + RoleId.Load(record.RoleId), + (RoleMaturityLevel)record.CurrentMaturityLevel, + record.NextEligibleMaturityLevel.HasValue ? (RoleMaturityLevel)record.NextEligibleMaturityLevel.Value : null, + record.AssignedAt, + record.CurrentLevelSince, + record.EligibleForPromotionAt, + record.CompletedCertificationsCount, + record.CompletedTrainingsCount, + record.PerformanceScore, + record.HasNoComplianceIssues, + string.IsNullOrEmpty(record.BlockingFactor) ? null : TextValueObject.Create(record.BlockingFactor), + record.LastReviewedAt, + BuildAudit(record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan)); + + var aggregate = Construct(props); + aggregate.DomainEvents.MarkChangesAsCommitted(); + aggregate.BrokenRules.Clear(); + return aggregate; + } + + public static RolePromotionRequestAggregate RehydrateRolePromotionRequest(RolePromotionRequestRecord record) + { + var props = new RolePromotionRequestProps( + RolePromotionRequestId.Load(record.Id), + TenantId.Load(record.TenantId), + UserId.Load(record.TargetUserId), + UserId.Load(record.RequesterId), + RoleId.Load(record.CurrentRoleId), + RoleId.Load(record.TargetRoleId), + DomainEnumerationMapper.FromValue(record.StatusId), + record.RiskScore.HasValue ? RiskScore.Load(record.RiskScore.Value) : null, + record.ApproverId.HasValue ? UserId.Load(record.ApproverId.Value) : null, + record.SecurityReviewerId.HasValue ? UserId.Load(record.SecurityReviewerId.Value) : null, + record.ExecutorId.HasValue ? UserId.Load(record.ExecutorId.Value) : null, + record.VerifierId.HasValue ? UserId.Load(record.VerifierId.Value) : null, + record.DecisionReason, + BuildAudit(record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan)); + + var aggregate = Construct(props); + aggregate.DomainEvents.MarkChangesAsCommitted(); + aggregate.BrokenRules.Clear(); + return aggregate; + } + + private static AuditValueObject BuildAudit(string createdBy, DateTime createdAtUtc, string? updatedBy, DateTime? updatedAtUtc, string auditTimeSpan) + => AuditValueObject.Load(new AuditProps + { + CreatedBy = createdBy, + CreatedAt = createdAtUtc, + UpdatedBy = updatedBy, + UpdatedAt = updatedAtUtc, + TimeSpan = auditTimeSpan, + }); + + private static TEntity Construct(TProps props) + where TEntity : class + where TProps : class + { + var ctor = typeof(TEntity).GetConstructor(InstanceFlags, null, [typeof(TProps)], null) + ?? throw new InvalidOperationException($"Constructor for {typeof(TEntity).Name} not found."); + + return (TEntity)ctor.Invoke([props]); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs index a133fe02..79a2a69f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs @@ -46,6 +46,7 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio var actor = ActorId.Create(CoreDevDataSeeder.SystemActorId); var ransaTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.RansaTenantId)); var internalAdminTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)); + var beyondNetTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId)); var adminUserId = UserId.Load(Guid.Parse(CoreDevDataSeeder.RansaAdminUserId)); var internalAdminUserId = UserId.Load(Guid.Parse(CoreDevDataSeeder.SuperAdminUserId)); @@ -118,6 +119,20 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } } + // BEYONDNET tenant workflows (FS-25 §4.5 / criterio 13): ALTA_CLIENTE y ACCESO_EXPEDIENTE + var beyondNetWorkflows = BuildBeyondNetSeedWorkflows(beyondNetTenantId, actor); + if (inMemoryWfRepository is not null) + foreach (var wf in beyondNetWorkflows) inMemoryWfRepository.Seed(wf); + else if (wfRepository is not null) + { + var existing = await wfRepository.GetByTenantIdAsync(beyondNetTenantId.GetValue(), cancellationToken); + if (existing.Count == 0) + { + foreach (var wf in beyondNetWorkflows) await wfRepository.AddAsync(wf, cancellationToken); + await wfRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + } + // User Documents var userDocs = BuildSeedUserDocs(adminUserId, docTypes, actor); if (inMemoryUserDocRepository is not null) @@ -179,6 +194,38 @@ private static IReadOnlyList BuildInternalAdminSeedWo return workflow.IsSuccess ? new[] { workflow.Value } : Array.Empty(); } + // BEYONDNET tenant approval flows (FS-25 §4.5): alta de empresa cliente y acceso externo a expediente. + private static IReadOnlyList BuildBeyondNetSeedWorkflows(TenantId tenantId, ActorId actor) + { + var results = new List(); + + var altaCliente = ApprovalWorkflowAggregate.Create( + tenantId, + Code.Create("ALTA_CLIENTE"), + Name.Create("Alta de Empresa Cliente"), + Description.Create("Aprobación del alta de una empresa cliente en el operador BEYONDNET"), + UserCategory.External, + true, + null, + actor, + requiredDocumentCount: 1); + if (altaCliente.IsSuccess) results.Add(altaCliente.Value); + + var accesoExpediente = ApprovalWorkflowAggregate.Create( + tenantId, + Code.Create("ACCESO_EXPEDIENTE"), + Name.Create("Acceso a Expediente"), + Description.Create("Aprobación de acceso externo a un expediente del operador BEYONDNET"), + UserCategory.External, + true, + null, + actor, + requiredDocumentCount: 1); + if (accesoExpediente.IsSuccess) results.Add(accesoExpediente.Value); + + return results; + } + private static IReadOnlyList BuildInternalAdminSeedRequests( UserId requesterId, IReadOnlyList workflows, @@ -262,6 +309,15 @@ private static IReadOnlyList BuildSeedWorkflows(Tenan /// ApprovalsAggregateFactory.RehydrateWorkflow) so that integration tests can reference /// workflows by well-known IDs without relying on dynamic GUIDs. /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "'requiredDocumentCount' expresa la intención de datos-semilla (MANUAL_REVIEW exige 1 doc); " + + "ApprovalWorkflowProps aún no lo modela, se conserva para no perder la intención (ver G-016).")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. Instancia el agregado por su " + + "constructor privado para fijar un ID bien conocido; no se ejecuta en producción " + + "(SeedDevData && !IsProduction).")] private static ApprovalWorkflowAggregate CreateWorkflowWithFixedId( Guid fixedId, TenantId tenantId, diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs index e40c993c..008217c4 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs @@ -11,7 +11,6 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio var auditRepository = serviceProvider.GetService(); var inMemoryAuditRepository = serviceProvider.GetService(); - var actor = ActorId.Create(CoreDevDataSeeder.SystemActorId); var ransaTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.RansaTenantId)); var recordResult = AuditRecord.Record( diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs index d1214f50..f4f64c7a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Infrastructure.Persistence.Seeders; using System.Reflection; @@ -7,6 +8,7 @@ namespace Ums.Infrastructure.Persistence.Seeders; using Ums.Domain.Authorization.Role; using Ums.Domain.Authorization.SystemSuite; using Ums.Domain.Authorization.SystemSuite.DomainResource; +using Ums.Domain.Authorization.SystemSuite.MenuNode; using Ums.Domain.Authorization.Template; using Ums.Domain.Kernel.ValueObjects; using Ums.Infrastructure.Persistence.Authorization.Entities; @@ -15,6 +17,8 @@ namespace Ums.Infrastructure.Persistence.Seeders; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; using PermissionTemplateAggregate = Ums.Domain.Authorization.Template.PermissionTemplate; using RoleAggregate = Ums.Domain.Authorization.Role.Role; +using ModuleEntity = Ums.Domain.Authorization.SystemSuite.Module.Module; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using Ums.Domain.Enums; public static class AuthorizationDevDataSeeder @@ -42,8 +46,11 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio TenantId.Load(Guid.Parse("c9b736b4-6a84-48f8-b34d-176bc5a6d542")), // NEPTUNIA TenantId.Load(Guid.Parse("a3f5b9d2-7c3d-4c8e-a9b0-123456789abc")), // APM_CALLAO TenantId.Load(Guid.Parse("9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe")), // PAITA_PORT - TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")), // UNIMAR + TenantId.Load(Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId)), // BEYONDNET TenantId.Load(Guid.Parse("f3e2d1c0-b9a8-7f6e-5d4c-321098765432")), // INTRADEVCO + TenantId.Load(Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId)), // COMEX_ANDINA (BEYONDNET client) + TenantId.Load(Guid.Parse(CoreDevDataSeeder.AgronorteTenantId)), // AGRONORTE (BEYONDNET client) + TenantId.Load(Guid.Parse(CoreDevDataSeeder.ImpoAndinaSubTenantId)), // IMPO_ANDINA_SUB (cliente de COMEX_ANDINA) }; foreach (var tenantId in allTenantIds) @@ -56,17 +63,28 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } else if (suiteRepository is not null) { + // La guarda es POR SUITE, no por inquilino. + // + // Antes la condición era «el inquilino no tiene ninguna suite»: todo o nada. + // Bastaba que tuviera UNA de cualquier otro sembrador para que este no añadiera + // ninguna de las suyas, ni entonces ni nunca. Así se quedó BEYONDNET sin la suite + // UMS —tenía las siete de su catálogo, luego la cuenta no era cero, luego nada + // que hacer— mientras los seis inquilinos genéricos sí la recibían. Un sembrador + // que solo actúa sobre una base vacía no siembra: inicializa una vez y después + // miente. var existing = await suiteRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); - if (existing.Count == 0) + var yaPresentes = existing.Select(s => s.Code.GetValue()).ToHashSet(StringComparer.OrdinalIgnoreCase); + var faltantes = suites.Where(s => !yaPresentes.Contains(s.Code.GetValue())).ToList(); + + if (faltantes.Count > 0) { - foreach (var suite in suites) await suiteRepository.AddAsync(suite, cancellationToken); + foreach (var suite in faltantes) await suiteRepository.AddAsync(suite, cancellationToken); await suiteRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + existing = await suiteRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); } - else - { - await EnsureDomainResourcesAsync(existing, tenantId, actor, suiteRepository, cancellationToken); - suites = existing; // use persisted suites so IDs match for roles/templates - } + + await EnsureDomainResourcesAsync(existing, actor, suiteRepository, cancellationToken); + suites = existing; // se usan las persistidas para que los ids casen con roles y plantillas } // Seed Roles @@ -77,16 +95,20 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } else if (roleRepository is not null) { + // Igual que arriba: por código de rol, no por inquilino. Los roles de una suite + // recién añadida no llegarían nunca si basta con que el inquilino tenga otros. var existing = await roleRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); - if (existing.Count == 0) + var yaPresentes = existing.Select(r => r.Code.GetValue()).ToHashSet(StringComparer.OrdinalIgnoreCase); + var faltantes = roles.Where(r => !yaPresentes.Contains(r.Code.GetValue())).ToList(); + + if (faltantes.Count > 0) { - foreach (var role in roles) await roleRepository.AddAsync(role, cancellationToken); + foreach (var role in faltantes) await roleRepository.AddAsync(role, cancellationToken); await roleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + existing = await roleRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); } - else - { - roles = existing; - } + + roles = existing; } // Seed PermissionTemplates @@ -97,16 +119,20 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } else if (templateRepository is not null) { + // Por ROL: una plantilla pertenece a un rol, así que un rol nuevo necesita la suya + // aunque el inquilino ya tenga plantillas de otros roles. var existing = await templateRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); - if (existing.Count == 0) + var rolesConPlantilla = existing.Select(p => p.RoleId.GetValue()).ToHashSet(); + var faltantes = templates.Where(p => !rolesConPlantilla.Contains(p.RoleId.GetValue())).ToList(); + + if (faltantes.Count > 0) { - foreach (var template in templates) await templateRepository.AddAsync(template, cancellationToken); + foreach (var template in faltantes) await templateRepository.AddAsync(template, cancellationToken); await templateRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + existing = await templateRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); } - else - { - templates = existing; - } + + templates = existing; } // Seed Profiles @@ -117,13 +143,24 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } else if (profileRepository is not null) { + // Por par (usuario, rol): es la clave natural de un perfil. Sin esto, el usuario + // que ya tiene perfil en otra suite jamás recibiría el de una suite nueva —que es + // justo lo que hacía falta para que quien administra UMS pudiera entrar a UMS. var existing = await profileRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); - if (existing.Count == 0) + var yaAsignados = existing + .Select(p => (Usuario: p.UserId.GetValue(), Rol: p.RoleId.GetValue())) + .ToHashSet(); + var faltantes = profiles + .Where(p => !yaAsignados.Contains((p.UserId.GetValue(), p.RoleId.GetValue()))) + .ToList(); + + if (faltantes.Count > 0) { - foreach (var profile in profiles) await profileRepository.AddAsync(profile, cancellationToken); + foreach (var profile in faltantes) await profileRepository.AddAsync(profile, cancellationToken); await profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } - else if (tenantId.GetValue() == Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)) + + if (tenantId.GetValue() == Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)) { await EnsureInternalAdminProfileAsync(profileRepository, roleRepository, templateRepository, cancellationToken); } @@ -131,23 +168,748 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. La siembra determinista con " + + "IDs bien conocidos exige fijar identidades sobre miembros no públicos; no se ejecuta en " + + "producción (SeedDevData && !IsProduction).")] private static readonly BindingFlags PrivateInstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic; - private static void SetRoleId(RoleAggregate role, Guid id) + // ── FS-25 dispatch: BEYONDNET gets the 6 domain suites/roles/templates/profiles; its + // client tenants get a Portal-only subset; every other tenant keeps the generic set. + private static readonly Guid[] BeyondNetClientTenantIds = + { + Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId), + Guid.Parse(CoreDevDataSeeder.AgronorteTenantId), + Guid.Parse(CoreDevDataSeeder.FrupiuraTenantId), + Guid.Parse(CoreDevDataSeeder.ImpoAndinaSubTenantId), // cliente de mi cliente — mismo trato acotado + }; + + private static bool IsBeyondNet(TenantId tenantId) => tenantId.GetValue() == Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId); + + private static bool IsBeyondNetClient(TenantId tenantId) => Array.IndexOf(BeyondNetClientTenantIds, tenantId.GetValue()) >= 0; + + private static IReadOnlyList BuildSeedSystemSuites(TenantId tenantId, ActorId actor) { - var propsField = typeof(RoleAggregate).GetField("_props", PrivateInstanceFlags); - var props = propsField?.GetValue(role) as RoleProps; - var idProperty = typeof(RoleProps).GetProperty("Id", PrivateInstanceFlags); - idProperty?.SetValue(props, RoleId.Load(id)); + if (IsBeyondNet(tenantId)) + { + return BuildBeyondNetSystemSuites(tenantId, actor); + } + + if (IsBeyondNetClient(tenantId)) + { + var portal = BuildPortalClienteSuite(tenantId, actor); + return portal is null ? Array.Empty() : new[] { portal }; + } + + return BuildGenericSystemSuites(tenantId, actor); } private static IReadOnlyList BuildSeedRoles(TenantId tenantId, IReadOnlyList suites, ActorId actor) + { + if (IsBeyondNet(tenantId)) + { + return BuildBeyondNetRoles(tenantId, suites, actor); + } + + if (IsBeyondNetClient(tenantId)) + { + return BuildClienteExternoRoles(tenantId, suites, actor); + } + + return BuildGenericRoles(tenantId, suites, actor); + } + + private static IReadOnlyList BuildSeedPermissionTemplates(TenantId tenantId, IReadOnlyList suites, IReadOnlyList roles, ActorId actor) + { + if (IsBeyondNet(tenantId) || IsBeyondNetClient(tenantId)) + { + return BuildRoleAnchoredPermissionTemplates(tenantId, suites, roles, actor); + } + + return BuildGenericPermissionTemplates(tenantId, suites, roles, actor); + } + + private static IReadOnlyList BuildSeedProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) + { + if (IsBeyondNet(tenantId)) + { + return BuildBeyondNetProfiles(tenantId, roles, templates, actor); + } + + if (IsBeyondNetClient(tenantId)) + { + return BuildClienteProfiles(tenantId, roles, templates, actor); + } + + return BuildGenericProfiles(tenantId, roles, templates, actor); + } + + // ── FS-25 suite tree builder (module → menu → submenu → options) ───────────── + private sealed record OptionSpec(string Code, string Name, string Action); + private sealed record MenuSpec(string Code, string Name, OptionSpec[] Options); + private sealed record ModuleSpec(string Code, string Name, MenuSpec[] Menus); + private sealed record SuiteSpec(string Code, string Name, string Description, ModuleSpec[] Modules); + + private static readonly (string Code, string Name)[] StandardSuiteActions = + { + ("VIEW", "Ver"), + ("CREATE", "Crear"), + ("READ", "Leer"), + ("UPDATE", "Actualizar"), + ("DELETE", "Eliminar"), + ("APPROVE", "Aprobar"), + ("SEARCH", "Buscar"), + }; + + // A menu with the two standard options (view + manage). Option codes stay unique + // per suite because menu codes are unique per suite. + private static MenuSpec Menu(string code, string name) => new(code, name, new[] + { + new OptionSpec("VIEW_" + code, "Ver " + name, "VIEW"), + new OptionSpec("MANAGE_" + code, "Gestionar " + name, "UPDATE"), + }); + + private static SuiteSpec PortalClienteSuiteSpec() => new( + "PORTAL_CLIENTE", "Portal del Cliente", "Portal de consulta acotado para clientes externos", + new[] + { + new ModuleSpec("QUERY", "Consultas", new[] { Menu("FILE_STATUS", "Estado de Expediente"), Menu("DOCUMENTS", "Documentos") }), + new ModuleSpec("NOTIF", "Notificaciones", new[] { Menu("NOTICES", "Avisos") }), + }); + + private static IReadOnlyList BeyondNetSuiteSpecs() => new[] + { + new SuiteSpec("TMS", "Transporte", "Sistema de gestión de transporte", new[] + { + new ModuleSpec("PLAN", "Planificación", new[] { Menu("TORDERS", "Órdenes de Transporte"), Menu("ROUTES", "Rutas") }), + new ModuleSpec("FLEET", "Flota", new[] { Menu("VEHICLES", "Vehículos"), Menu("DRIVERS", "Conductores") }), + new ModuleSpec("TRACK", "Seguimiento", new[] { Menu("MONITOR", "Monitoreo"), Menu("MILESTONES", "Hitos") }), + }), + new SuiteSpec("WMS", "Almacén", "Sistema de gestión de almacén", new[] + { + new ModuleSpec("INV", "Inventario", new[] { Menu("STOCK", "Stock"), Menu("OPS", "Operaciones") }), + new ModuleSpec("RCV", "Recepción y Despacho", new[] { Menu("RECEIPTS", "Recepciones"), Menu("DISPATCHES", "Despachos") }), + new ModuleSpec("REPORTS", "Reportes", new[] { Menu("INV_REPORTS", "Reportes de Inventario"), Menu("IO", "Importar / Exportar") }), + }), + new SuiteSpec("SIL", "Sistema Integral Logístico", "Expedientes, costos y trazabilidad", new[] + { + new ModuleSpec("FILES", "Expedientes", new[] { Menu("FILES_IMPO", "Expedientes Impo"), Menu("FILES_EXPO", "Expedientes Expo") }), + new ModuleSpec("COST", "Costos", new[] { Menu("COST_SETTLE", "Liquidación de Costos") }), + new ModuleSpec("TRACE", "Trazabilidad", new[] { Menu("TIMELINE", "Línea de Tiempo") }), + }), + new SuiteSpec("ADUANAS", "Aduanas", "Declaraciones, canales y agentes de aduana", new[] + { + new ModuleSpec("DECL", "Declaraciones", new[] { Menu("DUA_IMPO", "DUA Importación"), Menu("DAM_EXPO", "DAM Exportación") }), + new ModuleSpec("CHANNEL", "Canales", new[] { Menu("CH_ROJO", "Canal Rojo"), Menu("CH_NARANJA", "Canal Naranja"), Menu("CH_VERDE", "Canal Verde") }), + new ModuleSpec("AGENT", "Agentes", new[] { Menu("CUSTOMS_AGENTS", "Agentes de Aduana"), Menu("POWERS", "Poderes") }), + }), + PortalClienteSuiteSpec(), + new SuiteSpec("FACTURACION", "Facturación", "Facturación, liquidación y cobranzas", new[] + { + new ModuleSpec("BILL", "Facturación", new[] { Menu("INVOICES", "Facturas"), Menu("CREDIT_NOTES", "Notas de Crédito") }), + new ModuleSpec("SETTLE", "Liquidación", new[] { Menu("SETTLEMENTS", "Liquidaciones") }), + new ModuleSpec("COLLECT", "Cobranzas", new[] { Menu("ACCOUNT_STATE", "Estado de Cuenta") }), + }), + }; + + /// + /// Ajustes VISIBLES para el cliente: branding, tema, disposición e idioma. + /// + /// Son los que permiten al frontend inicializar la aplicación sin llamadas adicionales + /// (G-178). Van marcados uno a uno con `isClientVisible: true` a propósito: el default es no + /// publicar, porque esta misma bolsa alberga ajustes operativos que no deben salir del + /// servidor. + /// + /// La convención de clave es `ESPACIO_RESTO`, que el grafo proyecta como + /// `settings.espacio.resto`. + /// + private static void SembrarAjustesDeCliente(SystemSuiteAggregate suite, SuiteSpec spec, ActorId actor) + { + void Visible(string clave, string valor) => + suite.AddAppSetting( + ConfigurationKey.Create(clave), + ConfigurationValue.Create(valor), + ConfigurationScope.Global, + actor, + isClientVisible: true); + + Visible("BRAND_DISPLAY_NAME", spec.Name); + Visible("BRAND_SHORT_NAME", spec.Code); + // Descriptor bajo el nombre comercial. Es la descripción del sistema, no una frase de + // marketing: quien lee la barra quiere saber en qué sistema está, no que se lo vendan. + Visible("BRAND_TAGLINE", spec.Description); + Visible("BRAND_LOGO_URL", $"/branding/{spec.Code.ToLowerInvariant()}/logo.svg"); + Visible("BRAND_ICON_URL", $"/branding/{spec.Code.ToLowerInvariant()}/icon.svg"); + + // Azul corporativo de BeyondNet; el acento y la superficie derivan de él. + Visible("THEME_PRIMARY", "#0f3e67"); + Visible("THEME_ACCENT", "#27ae60"); + Visible("THEME_MODE", "system"); + + // Disposición del shell. El cliente entiende `nav-rail` y `nav-rail-compact`; cualquier + // otro valor cae en la primera en vez de dejar la pantalla sin navegación. + Visible("UI_LAYOUT", "nav-rail"); + Visible("UI_HOME_ROUTE", "/"); + Visible("UI_DENSITY", "comfortable"); + + Visible("LOCALE_LANGUAGE", "es-PE"); + Visible("LOCALE_TIMEZONE", "America/Lima"); + Visible("LOCALE_CURRENCY", "PEN"); + + // Contraejemplo deliberado: un ajuste operativo del mismo sistema que NO se publica. + // Si alguien vuelca la bolsa entera por comodidad, esta clave aparecería en el cable. + suite.AddAppSetting( + ConfigurationKey.Create("OPS_HEALTHCHECK_INTERVAL_S"), + ConfigurationValue.Create("30"), + ConfigurationScope.Global, + actor); + } + + private static SystemSuiteAggregate? BuildSuiteFromSpec(TenantId tenantId, ActorId actor, SuiteSpec spec) + { + var result = SystemSuiteAggregate.Create(tenantId, Code.Create(spec.Code), Name.Create(spec.Name), Description.Create(spec.Description), actor); + if (result.IsFailure) + { + return null; + } + + var suite = result.Value; + + foreach (var action in StandardSuiteActions) + { + suite.RegisterAction(ActionCode.Create(action.Code), Name.Create(action.Name), actor); + } + + SembrarAjustesDeCliente(suite, spec, actor); + + var moduleOrder = 1; + foreach (var moduleSpec in spec.Modules) + { + var moduleResult = suite.AddModule(Code.Create(moduleSpec.Code), Name.Create(moduleSpec.Name), Description.Create(moduleSpec.Name), moduleOrder++, actor, IconoDeModulo(moduleSpec.Code)); + if (moduleResult.IsFailure) + { + continue; + } + + var module = suite.Modules.First(m => m.Code.GetValue() == moduleSpec.Code); + suite.ActivateModule(module.Props.Id, actor); + + var menuOrder = 1; + foreach (var menuSpec in moduleSpec.Menus) + { + var subMenuCode = menuSpec.Code + "_LIST"; + SeedNavMenu( + suite, module, menuOrder++, + menuSpec.Code, menuSpec.Name, + subMenuCode, menuSpec.Name, + menuSpec.Options.Select(o => (o.Code, o.Name, o.Action)).ToArray(), + actor); + } + } + + return suite; + } + + /// + /// Siembra un menú de navegación como árbol de nodos (ADR-0090): un nodo raíz + /// , un submenú y + /// las opciones hoja con su funcionalidad N:M. + /// Reemplaza la antigua cadena rígida Menú/Submenú/Opción. + /// + private static void SeedNavMenu( + SystemSuiteAggregate suite, + ModuleEntity module, + int menuOrder, + string menuCode, + string menuName, + string subCode, + string subName, + (string Code, string Name, string Action)[] options, + ActorId actor, + string? rutaMenu = null) + { + // Icono en el menú —es el nivel que el usuario ve en la barra lateral— y ruta en la + // opción, que es la que navega. Un submenú solo agrupa: ni icono ni ruta. + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create(menuCode), Name.Create(menuName), Description.Create(menuName), menuOrder, actor, + presentation: MenuNodePresentation.Create(IconoDeMenu(menuCode), rutaMenu)); + var menuNode = module.Nodes.First(n => n.Code.GetValue() == menuCode); + + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create(subCode), Name.Create(subName), Description.Create(subName), 1, actor); + var subNode = menuNode.Children.First(n => n.Code.GetValue() == subCode); + + var optionOrder = 1; + foreach (var opt in options) + { + // Con ruta real en el menú, las opciones son PERMISOS sobre esa pantalla, no destinos + // distintos: darles una ruta inventada llenaría el menú de enlaces rotos. + var ruta = rutaMenu is null + ? $"/{module.Code.GetValue().ToLowerInvariant()}/{opt.Code.ToLowerInvariant().Replace('_', '-')}" + : null; + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create(opt.Code), Name.Create(opt.Name), Description.Create(opt.Name), optionOrder++, actor, + presentation: MenuNodePresentation.Create(null, ruta)); + var optNode = subNode.Children.First(n => n.Code.GetValue() == opt.Code); + suite.LinkModuleNodeAction(module.Props.Id, optNode.GetId(), ActionCode.Create(opt.Action), actor); + } + } + + /// + /// Icono del módulo por convención sobre su código, con el mismo criterio que el del menú: un + /// IDENTIFICADOR que el cliente resuelve, nunca un recurso. + /// + private static string IconoDeModulo(string moduleCode) => moduleCode switch + { + // Portal de administración de UMS. + "IDM" => "shield-check", + "AUTH" => "shield-check", + "SYS" => "settings", + // Sistemas satélite del grupo. + "INV" or "RCV" => "package", + "REPORTS" => "file-text", + "PLAN" or "FLEET" => "truck", + "TRACK" or "TRACE" => "activity", + "FILES" => "folder-kanban", + "DECL" or "CHANNEL" => "file-text", + "AGENT" => "users", + "BILL" or "SETTLE" or "COLLECT" or "COST" => "receipt", + "QUERY" => "inbox", + "NOTIF" => "bell", + _ => "layout-grid", + }; + + /// + /// Icono del menú por convención sobre su código. + /// + /// Es un IDENTIFICADOR, no un recurso: el catálogo gráfico lo resuelve el cliente. Guardar + /// aquí una URL o un SVG ataría el servidor a la biblioteca de iconos de un frontend concreto, + /// y cambiarla obligaría a migrar datos. + /// + private static string IconoDeMenu(string menuCode) => menuCode switch + { + "SATELITES" or "TORDERS" or "VEHICLES" => "truck", + "STOCK" or "INV_REPORTS" or "RECEIPTS" or "DISPATCHES" => "package", + "FILES_IMPO" or "FILES_EXPO" or "DUA_IMPO" or "DAM_EXPO" => "file-text", + "INVOICES" or "CREDIT_NOTES" or "SETTLEMENTS" or "ACCOUNT_STATE" => "receipt", + "MONITOR" or "MILESTONES" or "TIMELINE" => "activity", + "CUSTOMS_AGENTS" or "POWERS" or "DRIVERS" => "users", + "NOTICES" => "bell", + // Portal de administración de UMS: los identificadores casan con el registro del shell. + "TENANTS" => "building", + "USERS" => "users", + "DELEGATIONS" => "git-merge", + "SYSTEM_SUITES" => "cpu", + "PERMISSION_TEMPLATES" or "PROFILES" => "shield-check", + "FEATURE_FLAGS" => "flag", + "APP_CONFIG" or "PARAM_CATALOG" => "settings", + _ => "layout-grid", + }; + + /// Añade un nodo (raíz si es nulo) y lo devuelve. + private static MenuNodeEntity AddNode( + SystemSuiteAggregate suite, + ModuleEntity module, + MenuNodeEntity? parent, + NodeKind kind, + string code, + string name, + int order, + ActorId actor) + { + if (parent is null) + suite.AddModuleRootNode(module.Props.Id, kind, Code.Create(code), Name.Create(name), Description.Create(name), order, actor); + else + suite.AddModuleChildNode(module.Props.Id, parent.GetId(), kind, Code.Create(code), Name.Create(name), Description.Create(name), order, actor); + + return FindNodeByCode(module, code)!; + } + + /// Busca un nodo por código en todo el árbol recursivo del módulo (ADR-0090). + private static MenuNodeEntity? FindNodeByCode(ModuleEntity module, string code) + { + static MenuNodeEntity? Search(IEnumerable nodes, string code) + { + foreach (var n in nodes) + { + if (n.Code.GetValue() == code) return n; + var found = Search(n.Children, code); + if (found is not null) return found; + } + return null; + } + return Search(module.Nodes, code); + } + + /// Enumera todos los nodos del árbol del módulo en preorden. + private static IEnumerable EnumerateNodes(ModuleEntity module) + { + static IEnumerable Walk(IEnumerable nodes) + { + foreach (var n in nodes) + { + yield return n; + foreach (var c in Walk(n.Children)) + yield return c; + } + } + return Walk(module.Nodes); + } + + private static IReadOnlyList BuildBeyondNetSystemSuites(TenantId tenantId, ActorId actor) + { + var suites = BeyondNetSuiteSpecs() + .Select(spec => BuildSuiteFromSpec(tenantId, actor, spec)) + .Where(suite => suite is not null) + .Select(suite => suite!) + .ToList(); + + // BEYONDNET opera el sistema de identidad de la suite y era el unico inquilino que NO lo tenia + // en su catalogo: quien lo administra no podia entrar a el. Se toma la MISMA suite que + // reciben los demas —no una version propia— porque un sistema debe verse igual desde + // cualquier inquilino, o el grafo deja de ser comparable entre ellos. + var ums = BuildUmsCoreSuite(tenantId, actor); + if (ums is not null) + { + suites.Add(ums); + } + + return suites; + } + + private static SystemSuiteAggregate? BuildPortalClienteSuite(TenantId tenantId, ActorId actor) + => BuildSuiteFromSpec(tenantId, actor, PortalClienteSuiteSpec()); + + // ── FS-25 §4.3 roles ───────────────────────────────────────────────────────── + private static IReadOnlyList BuildBeyondNetRoles(TenantId tenantId, IReadOnlyList suites, ActorId actor) + { + var roles = new List(); + + void Add(string code, string name, string description, string suiteCode) + { + var suite = suites.FirstOrDefault(s => s.Code.GetValue() == suiteCode); + if (suite is null) + { + return; + } + + var result = RoleAggregate.Create(tenantId, suite.GetId(), Code.Create(code), Name.Create(name), Description.Create(description), null, 0, 0, actor); + if (result.IsSuccess) + { + roles.Add(result.Value); + } + } + + Add("AGENTE_ADUANAS", "Agente de Aduanas", "Gestiona declaraciones y trámites aduaneros", "ADUANAS"); + Add("DESPACHADOR", "Despachador", "Ejecuta despachos aduaneros y de expedientes", "ADUANAS"); + Add("JEFE_ALMACEN", "Jefe de Almacén", "Dirige las operaciones de almacén", "WMS"); + Add("OPERARIO_ALMACEN", "Operario de Almacén", "Ejecuta operaciones físicas de almacén", "WMS"); + Add("COORD_TRANSPORTE", "Coordinador de Transporte", "Coordina la planificación y la flota de transporte", "TMS"); + Add("EJECUTIVO_CUENTA", "Ejecutivo de Cuenta", "Atiende la relación comercial con el cliente", "SIL"); + Add("ANALISTA_DOC", "Analista Documentario", "Gestiona la documentación de los expedientes", "SIL"); + Add("CLIENTE_EXTERNO", "Cliente Externo", "Acceso acotado de solo lectura al Portal del Cliente", "PORTAL_CLIENTE"); + Add("AUDITOR", "Auditor", "Acceso transversal de solo lectura", "SIL"); + Add("ADMINISTRADOR", "Administrador", "Acceso administrativo total del operador", "SIL"); + + // Administración del propio UMS. Se separa en tres y no en uno porque quien da de alta a + // un operario de almacén no es quien decide qué permisos concede un rol: la primera es + // tarea diaria de mesa de ayuda, la segunda cambia el modelo de acceso de toda la suite. + // Un único rol «administrador de UMS» obligaría a conceder lo segundo para poder hacer lo + // primero. + Add("ADMIN_UMS", "Administrador de UMS", "Gobierna identidad, autorización y configuración de la suite", "UMS"); + Add("MESA_ACCESOS", "Mesa de Accesos", "Da de alta usuarios y asigna perfiles; no altera roles ni plantillas", "UMS"); + Add("AUDITOR_ACCESOS", "Auditor de Accesos", "Consulta quién pudo hacer qué, sin poder cambiarlo", "UMS"); + + return roles; + } + + private static IReadOnlyList BuildClienteExternoRoles(TenantId tenantId, IReadOnlyList suites, ActorId actor) + { + var roles = new List(); + var portal = suites.FirstOrDefault(s => s.Code.GetValue() == "PORTAL_CLIENTE"); + if (portal is null) + { + return roles; + } + + var result = RoleAggregate.Create(tenantId, portal.GetId(), Code.Create("CLIENTE_EXTERNO"), Name.Create("Cliente Externo"), Description.Create("Acceso acotado de solo lectura al Portal del Cliente"), null, 0, 0, actor); + if (result.IsSuccess) + { + roles.Add(result.Value); + } + + return roles; + } + + // ── FS-25 permission templates — one published template per role, anchored to + // the role's suite; a role's template never references another suite. ──────── + private static IReadOnlyList BuildRoleAnchoredPermissionTemplates(TenantId tenantId, IReadOnlyList suites, IReadOnlyList roles, ActorId actor) + { + var templates = new List(); + + foreach (var role in roles) + { + var suite = suites.FirstOrDefault(s => s.GetId().GetValue() == role.SystemSuiteId.GetValue()); + if (suite is null) + { + continue; + } + + var templateResult = PermissionTemplateAggregate.Create(tenantId, role.GetId(), suite.GetId(), actor); + if (templateResult.IsFailure) + { + continue; + } + + var template = templateResult.Value; + AddFullSuiteNavigation(template, suite, actor, ModulosVisiblesPara(role.Code.GetValue())); + template.Publish(actor); + templates.Add(template); + } + + return templates; + } + + /// + /// Módulos que ve un rol, o null para «todos». + /// + /// Por omisión cada rol recibe la navegación completa de su suite, que es lo que hace + /// falta para que la siembra sea navegable de inmediato. Pero en UMS eso dejaba a los tres + /// roles viendo exactamente lo mismo —24 alcances cada uno—, y tres roles indistinguibles no + /// permiten comprobar lo único que un sistema de permisos tiene que demostrar: que la interfaz + /// cambia según quién entra. Aquí la diferencia es el dato, no un adorno. + /// + /// El corte es por módulo y no por acción porque es el eje que el grafo proyecta al + /// frontend: quien no tiene el módulo no ve su menú. Un corte más fino exigiría modelar + /// acciones por nodo, que es otra decisión. + /// + private static string[]? ModulosVisiblesPara(string roleCode) => roleCode switch + { + // Gobierna la identidad de la suite: identidad, autorización y configuración. + "ADMIN_UMS" => null, + // Da de alta usuarios y asigna perfiles. No toca la configuración del sistema: cambiar un + // parámetro global no es tarea de mesa de ayuda. + "MESA_ACCESOS" => new[] { "IDM", "AUTH" }, + // Consulta quién pudo hacer qué. Vive en el modelo de acceso —roles, perfiles, plantillas— + // y no necesita el padrón de usuarios para responder esa pregunta. + "AUDITOR_ACCESOS" => new[] { "AUTH" }, + _ => null, + }; + + private static void AddFullSuiteNavigation( + PermissionTemplateAggregate template, + SystemSuiteAggregate suite, + ActorId actor, + string[]? modulosVisibles = null) + { + template.AddItem(ExclusiveArcTarget.SystemSuite, suite.GetId(), ActionId.Create(), true, false, actor); + foreach (var module in suite.Modules) + { + if (modulosVisibles is not null + && !modulosVisibles.Contains(module.Code.GetValue(), StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + template.AddItem(ExclusiveArcTarget.Module, module.Props.Id, ActionId.Create(), true, false, actor); + foreach (var root in module.Nodes) + { + AddNodeNavigation(template, root, suite, actor); + } + } + } + + // Recorre el árbol de nodos (ADR-0090) mapeando el rol del nodo al destino + // del permiso: Menú → Submodule; Submenú/Opción → Option. + private static void AddNodeNavigation(PermissionTemplateAggregate template, MenuNodeEntity node, SystemSuiteAggregate suite, ActorId actor) + { + if (node.Kind == NodeKind.Option) + { + // Opción hoja: el grafo evalúa el efecto aquí casando (optionId, actionId). + // Debe usar el ActionId REAL de cada acción vinculada (N:M, ADR-0090); un + // ActionId aleatorio nunca casaría y todo resolvería NotGranted (G-038). + AddOptionActionItems(template, node, suite, actor); + } + else + { + // Nodo estructural (Menú → Submodule; Submenú → Option): concede la + // navegación al nodo. El grafo no evalúa efecto a este nivel, por lo que + // el ActionId es indiferente. + var target = node.Kind == NodeKind.Menu ? ExclusiveArcTarget.Submodule : ExclusiveArcTarget.Option; + template.AddItem(target, node.GetId(), ActionId.Create(), true, false, actor); + } + + foreach (var child in node.Children) + { + AddNodeNavigation(template, child, suite, actor); + } + } + + // Añade una fila de permiso de opción por cada acción REAL vinculada al nodo + // (N:M, ADR-0090), usando el ActionId de la Action registrada en la suite. Es + // la clave del concesión efectiva: AuthorizationGraphBuilderService casa + // (optionId, actionId real) — con un ActionId aleatorio todo sería NotGranted (G-038). + private static void AddOptionActionItems( + PermissionTemplateAggregate template, + MenuNodeEntity optionNode, + SystemSuiteAggregate suite, + ActorId actor, + bool isAllowed = true, + bool isDenied = false) + { + foreach (var actionCode in optionNode.ActionCodes) + { + var action = suite.Actions.FirstOrDefault(a => a.Code.GetValue() == actionCode.GetValue()); + if (action is null) + { + continue; + } + + template.AddItem(ExclusiveArcTarget.Option, optionNode.GetId(), action.GetId(), isAllowed, isDenied, actor); + } + } + + // ── FS-25 §4.4 profiles — one per internal user, linked by deterministic GUID. + // Each profile groups the conceptual "perfil" roles via their published templates. + private static IReadOnlyList BuildBeyondNetProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) + { + var profiles = new List(); + + var baseBytes = tenantId.GetValue().ToByteArray(); + Guid UserGuid(byte idx) + { + var b = (byte[])baseBytes.Clone(); + b[0] = idx; + return new Guid(b); + } + + var roleByCode = roles + .GroupBy(r => r.Code.GetValue()) + .ToDictionary(g => g.Key, g => g.First()); + var templateByRoleId = templates + .GroupBy(t => t.RoleId.GetValue()) + .ToDictionary(g => g.Key, g => g.First()); + + // (user index, primary role, roles grouped by the conceptual profile) — FS-25 §4.3/§4.4. + var opAduanas = new[] { "AGENTE_ADUANAS", "DESPACHADOR", "ANALISTA_DOC" }; + var almacen = new[] { "JEFE_ALMACEN", "OPERARIO_ALMACEN" }; + var admin = new[] { "ADMINISTRADOR", "AUDITOR" }; + + // Los tres perfiles de UMS son ESCALONADOS y no equivalentes: quien gobierna ve lo de la + // mesa y lo del auditor; la mesa ve lo del auditor; el auditor solo lo suyo. Es lo que + // hace comparable el grafo entre ellos — si los tres cargaran las mismas plantillas, la + // prueba de que la interfaz cambia por rol no probaría nada. + var umsAuditoria = new[] { "AUDITOR_ACCESOS" }; + var umsMesa = new[] { "MESA_ACCESOS", "AUDITOR_ACCESOS" }; + var umsGobierno = new[] { "ADMIN_UMS", "MESA_ACCESOS", "AUDITOR_ACCESOS" }; + var mapping = new (byte Index, string RoleCode, string[] TemplateRoleCodes)[] + { + (1, "ADMINISTRADOR", admin), // admin.callao — PERF_ADMIN (admin por sucursal) + (2, "AGENTE_ADUANAS", opAduanas), // agente.aduanas.callao — PERF_OP_ADUANAS + (3, "DESPACHADOR", opAduanas), // despachador.callao — PERF_OP_ADUANAS + (4, "JEFE_ALMACEN", almacen), // jefe.almacen.callao — PERF_ALMACEN + (5, "COORD_TRANSPORTE", new[] { "COORD_TRANSPORTE" }), // coordinador.transporte.callao — PERF_TRANSPORTE + (6, "EJECUTIVO_CUENTA", new[] { "EJECUTIVO_CUENTA" }), // ejecutivo.cuenta.callao — PERF_COMERCIAL + (7, "ANALISTA_DOC", opAduanas), // analista.doc.callao — PERF_OP_ADUANAS + (8, "AUDITOR", admin), // auditor.callao — PERF_ADMIN + (CoreDevDataSeeder.BeyondNetJefeAlmacenPaitaUserIndex, "JEFE_ALMACEN", almacen), // jefe.almacen.paita — criterio 7 + (10, "OPERARIO_ALMACEN", almacen), // operario.almacen.paita — PERF_ALMACEN + (11, "AGENTE_ADUANAS", opAduanas), // agente.aduanas.paita — PERF_OP_ADUANAS + (12, "EJECUTIVO_CUENTA", new[] { "EJECUTIVO_CUENTA" }), // ejecutivo.cuenta.paita — PERF_COMERCIAL + (CoreDevDataSeeder.BeyondNetRootAdminUserIndex, "ADMINISTRADOR", admin), // admin@beyondnet.com.pe — root BEYONDNET transversal + + // Perfiles sobre el propio UMS. Van como entradas APARTE y no añadiendo el rol a las + // de arriba: administrar la suite logística y administrar la identidad de la suite son + // dos responsabilidades distintas, y separarlas en dos perfiles del mismo usuario es lo + // que permite comprobar el cambio de perfil —la interfaz debe cambiar al conmutar—. + (CoreDevDataSeeder.BeyondNetRootAdminUserIndex, "ADMIN_UMS", umsGobierno), // admin@beyondnet.com.pe + (1, "MESA_ACCESOS", umsMesa), // admin.callao + (8, "AUDITOR_ACCESOS", umsAuditoria), // auditor.callao + }; + + foreach (var entry in mapping) + { + if (!roleByCode.TryGetValue(entry.RoleCode, out var primaryRole)) + { + continue; + } + + var profileResult = ProfileAggregate.Create(tenantId, UserId.Load(UserGuid(entry.Index)), primaryRole.GetId(), null, actor); + if (profileResult.IsFailure) + { + continue; + } + + var profile = profileResult.Value; + foreach (var templateRoleCode in entry.TemplateRoleCodes) + { + if (roleByCode.TryGetValue(templateRoleCode, out var templateRole) + && templateByRoleId.TryGetValue(templateRole.GetId().GetValue(), out var template)) + { + profile.AssignTemplate(template, actor); + } + } + + profiles.Add(profile); + } + + return profiles; + } + + private static IReadOnlyList BuildClienteProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) + { + var profiles = new List(); + + var role = roles.FirstOrDefault(r => r.Code.GetValue() == "CLIENTE_EXTERNO"); + if (role is null) + { + return profiles; + } + + var template = templates.FirstOrDefault(t => t.RoleId.GetValue() == role.GetId().GetValue()); + + var baseBytes = tenantId.GetValue().ToByteArray(); + baseBytes[0] = 1; // single external user per client tenant + var userId = UserId.Load(new Guid(baseBytes)); + + var profileResult = ProfileAggregate.Create(tenantId, userId, role.GetId(), null, actor); + if (profileResult.IsSuccess) + { + if (template is not null) + { + profileResult.Value.AssignTemplate(template, actor); + } + + profiles.Add(profileResult.Value); + } + + return profiles; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. Fija el ID de rol semilla " + + "(RiskScoreCalculator lo resuelve por id) sobre la propiedad Id de setter privado; no se " + + "ejecuta en producción (SeedDevData && !IsProduction).")] + private static void SetRoleId(RoleAggregate role, Guid id) + { + // G-058: SetRoleId nunca fijaba el id — doble fallo enmascarado. (1) `_props` vive en la base + // AggregateRoot<> y `GetField` no busca en clases base → el campo devolvía null; (2) RoleProps.Id + // es una propiedad PÚBLICA (setter privado) y buscarla solo con NonPublic devolvía null. El + // SetValue nunca corría (no-op silencioso), así que los roles demo (DemoAdminRoleId/ + // DemoOperatorRoleId) conservaban su GUID aleatorio y el RiskScoreCalculator no los resolvía por + // id → `submit` IGA fallaba con 400. Fix: usar el getter PÚBLICO Props (RoleProps es clase, misma + // instancia) y buscar la propiedad Id incluyendo Public. + var idProperty = typeof(RoleProps).GetProperty( + "Id", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + idProperty?.SetValue(role.Props, RoleId.Load(id)); + } + + private static IReadOnlyList BuildGenericRoles(TenantId tenantId, IReadOnlyList suites, ActorId actor) { var roles = new List(); if (suites.Count == 0) return roles; // ── UMS Suite Roles ────────────────────────────────────────────────────── - var adminRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("ADMIN"), Name.Create("System Administrator"), Description.Create("Full administrative access"), null, 0, 0, actor); + var adminRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("ADMIN"), Name.Create("Administrador del Sistema"), Description.Create("Acceso administrativo completo"), null, 0, 0, actor); if (adminRoleResult.IsSuccess) { var role = adminRoleResult.Value; @@ -156,22 +918,22 @@ private static IReadOnlyList BuildSeedRoles(TenantId tenantId, IR roles.Add(role); } - var supervisorRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("SUPERVISOR"), Name.Create("Core Supervisor"), Description.Create("Supervises core operations"), null, 0, 0, actor); + var supervisorRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("SUPERVISOR"), Name.Create("Supervisor"), Description.Create("Supervisa las operaciones principales"), null, 0, 0, actor); if (supervisorRoleResult.IsSuccess) roles.Add(supervisorRoleResult.Value); - var auditorRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("AUDITOR"), Name.Create("Compliance Auditor"), Description.Create("Read-only access for audits"), null, 0, 0, actor); + var auditorRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("AUDITOR"), Name.Create("Auditor de Cumplimiento"), Description.Create("Acceso de solo lectura para auditorías"), null, 0, 0, actor); if (auditorRoleResult.IsSuccess) roles.Add(auditorRoleResult.Value); - var readonlyRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("READONLY"), Name.Create("Read Only Viewer"), Description.Create("View-only access, no modifications"), null, 0, 0, actor); + var readonlyRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("READONLY"), Name.Create("Solo Lectura"), Description.Create("Acceso de solo visualización, sin modificaciones"), null, 0, 0, actor); if (readonlyRoleResult.IsSuccess) roles.Add(readonlyRoleResult.Value); - var dataEntryRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("DATA_ENTRY"), Name.Create("Data Entry Clerk"), Description.Create("Create and update records only"), null, 0, 0, actor); + var dataEntryRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("DATA_ENTRY"), Name.Create("Digitador"), Description.Create("Solo crear y actualizar registros"), null, 0, 0, actor); if (dataEntryRoleResult.IsSuccess) roles.Add(dataEntryRoleResult.Value); // ── WMS Suite Roles ────────────────────────────────────────────── if (suites.Count > 1) { - var operatorRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("OPERATOR"), Name.Create("Warehouse Operator"), Description.Create("Standard warehouse operations"), null, 0, 0, actor); + var operatorRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("OPERATOR"), Name.Create("Operario de Almacén"), Description.Create("Operaciones estándar de almacén"), null, 0, 0, actor); if (operatorRoleResult.IsSuccess) { var role = operatorRoleResult.Value; @@ -180,31 +942,43 @@ private static IReadOnlyList BuildSeedRoles(TenantId tenantId, IR roles.Add(role); } - var inspectorRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("INSPECTOR"), Name.Create("Quality Inspector"), Description.Create("Quality control and inspections"), null, 0, 0, actor); + var inspectorRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("INSPECTOR"), Name.Create("Inspector de Calidad"), Description.Create("Control de calidad e inspecciones"), null, 0, 0, actor); if (inspectorRoleResult.IsSuccess) roles.Add(inspectorRoleResult.Value); - var managerRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("WMS_MANAGER"), Name.Create("Warehouse Manager"), Description.Create("Manages all warehouse operations"), null, 0, 0, actor); + var managerRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("WMS_MANAGER"), Name.Create("Jefe de Almacén"), Description.Create("Gestiona todas las operaciones de almacén"), null, 0, 0, actor); if (managerRoleResult.IsSuccess) roles.Add(managerRoleResult.Value); - var dispatcherRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("DISPATCHER"), Name.Create("Dispatch Coordinator"), Description.Create("Manages stock transfers and dispatches"), null, 0, 0, actor); + var dispatcherRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("DISPATCHER"), Name.Create("Coordinador de Despacho"), Description.Create("Gestiona transferencias y despachos de stock"), null, 0, 0, actor); if (dispatcherRoleResult.IsSuccess) roles.Add(dispatcherRoleResult.Value); - var reporterRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("REPORTER"), Name.Create("Report Analyst"), Description.Create("Generates and exports warehouse reports"), null, 0, 0, actor); + var reporterRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("REPORTER"), Name.Create("Analista de Reportes"), Description.Create("Genera y exporta reportes de almacén"), null, 0, 0, actor); if (reporterRoleResult.IsSuccess) roles.Add(reporterRoleResult.Value); } return roles; } - private static IReadOnlyList BuildSeedSystemSuites(TenantId tenantId, ActorId actor) + /// + /// La suite UMS, identica para TODOS los inquilinos. + /// + /// Estaba embebida en BuildGenericSystemSuites, asi que BEYONDNET —que va por su + /// catalogo propio— no podia recibirla sin duplicarla. Se intento con una spec declarativa y + /// salio peor: genero nodos con rutas inventadas (/idm/manage-branches) que el shell + /// tomo como navegacion real, porque el grafo CONSTRUYE el menu (G-181) y solo cae a la + /// configuracion estatica cuando no hay grafo. El resultado fue una barra de navegacion que no + /// llevaba a ninguna pantalla existente. + /// + /// De ahi que se extraiga en vez de reescribirse: un mismo sistema debe verse igual desde + /// cualquier inquilino, y la unica forma de garantizarlo es que salga del mismo sitio. + /// + private static SystemSuiteAggregate? BuildUmsCoreSuite(TenantId tenantId, ActorId actor) { var suites = new List(); - var coreResult = SystemSuiteAggregate.Create( tenantId, Code.Create("UMS"), - Name.Create("User Management System"), - Description.Create("Core UMS functionality"), + Name.Create("Sistema de Gestión de Usuarios"), + Description.Create("Funcionalidad principal del UMS"), actor); if (coreResult.IsSuccess) @@ -212,16 +986,16 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI var suite = coreResult.Value; // Register standard actions - suite.RegisterAction(ActionCode.Create("VIEW"), Name.Create("View Logistics Core"), actor); - suite.RegisterAction(ActionCode.Create("MANAGE"), Name.Create("Manage Logistics Core"), actor); - suite.RegisterAction(ActionCode.Create("APPROVE"), Name.Create("Approve Operations"), actor); + suite.RegisterAction(ActionCode.Create("VIEW"), Name.Create("Ver Núcleo Logístico"), actor); + suite.RegisterAction(ActionCode.Create("MANAGE"), Name.Create("Gestionar Núcleo Logístico"), actor); + suite.RegisterAction(ActionCode.Create("APPROVE"), Name.Create("Aprobar Operaciones"), actor); // Register standard domain actions - suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Create Record"), actor); - suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Read Record"), actor); - suite.RegisterAction(ActionCode.Create("UPDATE"), Name.Create("Update Record"), actor); - suite.RegisterAction(ActionCode.Create("DELETE"), Name.Create("Delete Record"), actor); - suite.RegisterAction(ActionCode.Create("SEARCH"), Name.Create("Search Records"), actor); + suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Crear Registro"), actor); + suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Leer Registro"), actor); + suite.RegisterAction(ActionCode.Create("UPDATE"), Name.Create("Actualizar Registro"), actor); + suite.RegisterAction(ActionCode.Create("DELETE"), Name.Create("Eliminar Registro"), actor); + suite.RegisterAction(ActionCode.Create("SEARCH"), Name.Create("Buscar Registros"), actor); // Add app settings suite.AddAppSetting( @@ -236,90 +1010,155 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI actor); // Add IDM module - var modIdm = suite.AddModule(Code.Create("IDM"), Name.Create("Identity & Access"), Description.Create("Tenants, users, and delegations management"), 1, actor); + var modIdm = suite.AddModule(Code.Create("IDM"), Name.Create("Identidad y Accesos"), Description.Create("Gestión de empresas, usuarios y delegaciones"), 1, actor, IconoDeModulo("IDM")); if (modIdm.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "IDM"); suite.ActivateModule(module.Props.Id, actor); - module.AddMenu(Code.Create("TENANTS"), Name.Create("Tenants"), Description.Create("Manage tenants"), 1, actor); - var menuTenants = module.Menus.First(m => m.Code.GetValue() == "TENANTS"); - menuTenants.AddSubMenu(Code.Create("TENANTS_LIST"), Name.Create("Tenants List"), Description.Create("Tenants List"), 1, actor); - var subMenuTenants = menuTenants.SubMenus.First(); - subMenuTenants.AddOption(Code.Create("VIEW_TENANTS"), Name.Create("View Tenants"), Description.Create("View Tenants"), ActionCode.Create("VIEW"), 1, actor); - subMenuTenants.AddOption(Code.Create("MANAGE_TENANTS"), Name.Create("Manage Tenants"), Description.Create("Manage Tenants"), ActionCode.Create("MANAGE"), 2, actor); - - module.AddMenu(Code.Create("USERS"), Name.Create("Users"), Description.Create("Manage users"), 2, actor); - var menuUsers = module.Menus.First(m => m.Code.GetValue() == "USERS"); - menuUsers.AddSubMenu(Code.Create("USERS_LIST"), Name.Create("Users List"), Description.Create("Users List"), 1, actor); - var subMenuUsers = menuUsers.SubMenus.First(); - subMenuUsers.AddOption(Code.Create("VIEW_USERS"), Name.Create("View Users"), Description.Create("View Users"), ActionCode.Create("VIEW"), 1, actor); - subMenuUsers.AddOption(Code.Create("MANAGE_USERS"), Name.Create("Manage Users"), Description.Create("Manage Users"), ActionCode.Create("MANAGE"), 2, actor); - - module.AddMenu(Code.Create("DELEGATIONS"), Name.Create("Delegations"), Description.Create("Manage delegations"), 3, actor); - var menuDelegations = module.Menus.First(m => m.Code.GetValue() == "DELEGATIONS"); - menuDelegations.AddSubMenu(Code.Create("DELEGATIONS_LIST"), Name.Create("Delegations List"), Description.Create("Delegations List"), 1, actor); - var subMenuDelegations = menuDelegations.SubMenus.First(); - subMenuDelegations.AddOption(Code.Create("VIEW_DELEGATIONS"), Name.Create("View Delegations"), Description.Create("View Delegations"), ActionCode.Create("VIEW"), 1, actor); - subMenuDelegations.AddOption(Code.Create("MANAGE_DELEGATIONS"), Name.Create("Manage Delegations"), Description.Create("Manage Delegations"), ActionCode.Create("MANAGE"), 2, actor); + SeedNavMenu(suite, module, 1, "TENANTS", "Empresas", "TENANTS_LIST", "Lista de Empresas", + new[] { ("VIEW_TENANTS", "Ver Empresas", "VIEW"), ("MANAGE_TENANTS", "Gestionar Empresas", "MANAGE") }, actor, "/tenants"); + SeedNavMenu(suite, module, 2, "USERS", "Usuarios", "USERS_LIST", "Lista de Usuarios", + new[] { ("VIEW_USERS", "Ver Usuarios", "VIEW"), ("MANAGE_USERS", "Gestionar Usuarios", "MANAGE") }, actor, "/users"); + SeedNavMenu(suite, module, 3, "DELEGATIONS", "Delegaciones", "DELEGATIONS_LIST", "Lista de Delegaciones", + new[] { ("VIEW_DELEGATIONS", "Ver Delegaciones", "VIEW"), ("MANAGE_DELEGATIONS", "Gestionar Delegaciones", "MANAGE") }, actor, "/delegations"); } // Add AUTH module - var modAuth = suite.AddModule(Code.Create("AUTH"), Name.Create("Authorization"), Description.Create("Profiles, templates and suites"), 2, actor); + var modAuth = suite.AddModule(Code.Create("AUTH"), Name.Create("Autorización"), Description.Create("Perfiles, plantillas y sistemas"), 2, actor, IconoDeModulo("AUTH")); if (modAuth.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "AUTH"); suite.ActivateModule(module.Props.Id, actor); - module.AddMenu(Code.Create("SYSTEM_SUITES"), Name.Create("System Suites"), Description.Create("Manage System Suites"), 1, actor); - var menuSuites = module.Menus.First(m => m.Code.GetValue() == "SYSTEM_SUITES"); - menuSuites.AddSubMenu(Code.Create("SUITES_LIST"), Name.Create("Suites List"), Description.Create("Suites List"), 1, actor); - var subMenuSuites = menuSuites.SubMenus.First(); - subMenuSuites.AddOption(Code.Create("VIEW_SUITES"), Name.Create("View Suites"), Description.Create("View Suites"), ActionCode.Create("VIEW"), 1, actor); - subMenuSuites.AddOption(Code.Create("MANAGE_SUITES"), Name.Create("Manage Suites"), Description.Create("Manage Suites"), ActionCode.Create("MANAGE"), 2, actor); - - module.AddMenu(Code.Create("PERMISSION_TEMPLATES"), Name.Create("Permission Templates"), Description.Create("Manage Templates"), 2, actor); - var menuTemplates = module.Menus.First(m => m.Code.GetValue() == "PERMISSION_TEMPLATES"); - menuTemplates.AddSubMenu(Code.Create("TEMPLATES_LIST"), Name.Create("Templates List"), Description.Create("Templates List"), 1, actor); - var subMenuTemplates = menuTemplates.SubMenus.First(); - subMenuTemplates.AddOption(Code.Create("VIEW_TEMPLATES"), Name.Create("View Templates"), Description.Create("View Templates"), ActionCode.Create("VIEW"), 1, actor); - subMenuTemplates.AddOption(Code.Create("MANAGE_TEMPLATES"), Name.Create("Manage Templates"), Description.Create("Manage Templates"), ActionCode.Create("MANAGE"), 2, actor); - - module.AddMenu(Code.Create("PROFILES"), Name.Create("Profiles"), Description.Create("Manage Profiles"), 3, actor); - var menuProfiles = module.Menus.First(m => m.Code.GetValue() == "PROFILES"); - menuProfiles.AddSubMenu(Code.Create("PROFILES_LIST"), Name.Create("Profiles List"), Description.Create("Profiles List"), 1, actor); - var subMenuProfiles = menuProfiles.SubMenus.First(); - subMenuProfiles.AddOption(Code.Create("VIEW_PROFILES"), Name.Create("View Profiles"), Description.Create("View Profiles"), ActionCode.Create("VIEW"), 1, actor); - subMenuProfiles.AddOption(Code.Create("MANAGE_PROFILES"), Name.Create("Manage Profiles"), Description.Create("Manage Profiles"), ActionCode.Create("MANAGE"), 2, actor); + SeedNavMenu(suite, module, 1, "SYSTEM_SUITES", "Sistemas", "SUITES_LIST", "Lista de Sistemas", + new[] { ("VIEW_SUITES", "Ver Sistemas", "VIEW"), ("MANAGE_SUITES", "Gestionar Sistemas", "MANAGE") }, actor, "/system-suites"); + SeedNavMenu(suite, module, 2, "PERMISSION_TEMPLATES", "Plantillas de Permisos", "TEMPLATES_LIST", "Lista de Plantillas", + new[] { ("VIEW_TEMPLATES", "Ver Plantillas", "VIEW"), ("MANAGE_TEMPLATES", "Gestionar Plantillas", "MANAGE") }, actor, "/permission-templates"); + SeedNavMenu(suite, module, 3, "PROFILES", "Perfiles", "PROFILES_LIST", "Lista de Perfiles", + new[] { ("VIEW_PROFILES", "Ver Perfiles", "VIEW"), ("MANAGE_PROFILES", "Gestionar Perfiles", "MANAGE") }, actor, "/profiles"); } // Add SYS module - var modSys = suite.AddModule(Code.Create("SYS"), Name.Create("System Configuration"), Description.Create("Global properties, settings and flags"), 3, actor); + var modSys = suite.AddModule(Code.Create("SYS"), Name.Create("Configuración del Sistema"), Description.Create("Propiedades globales, ajustes y banderas"), 3, actor, IconoDeModulo("SYS")); if (modSys.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "SYS"); suite.ActivateModule(module.Props.Id, actor); - module.AddMenu(Code.Create("FEATURE_FLAGS"), Name.Create("Feature Flags"), Description.Create("Manage Flags"), 1, actor); - var menuFlags = module.Menus.First(m => m.Code.GetValue() == "FEATURE_FLAGS"); - menuFlags.AddSubMenu(Code.Create("FLAGS_LIST"), Name.Create("Flags List"), Description.Create("Flags List"), 1, actor); - var subMenuFlags = menuFlags.SubMenus.First(); - subMenuFlags.AddOption(Code.Create("VIEW_FLAGS"), Name.Create("View Flags"), Description.Create("View Flags"), ActionCode.Create("VIEW"), 1, actor); - subMenuFlags.AddOption(Code.Create("MANAGE_FLAGS"), Name.Create("Manage Flags"), Description.Create("Manage Flags"), ActionCode.Create("MANAGE"), 2, actor); + SeedNavMenu(suite, module, 1, "FEATURE_FLAGS", "Banderas de Función", "FLAGS_LIST", "Lista de Banderas", + new[] { ("VIEW_FLAGS", "Ver Banderas", "VIEW"), ("MANAGE_FLAGS", "Gestionar Banderas", "MANAGE") }, actor, "/feature-flags"); + SeedNavMenu(suite, module, 2, "APP_CONFIG", "Configuraciones", "CONFIG_LIST", "Lista de Configuraciones", + new[] { ("VIEW_CONFIG", "Ver Configuraciones", "VIEW"), ("MANAGE_CONFIG", "Gestionar Configuraciones", "MANAGE") }, actor, "/app-configurations"); + SeedNavMenu(suite, module, 3, "PARAM_CATALOG", "Catálogo de Parámetros", "PARAM_LIST", "Lista de Parámetros", + new[] { ("VIEW_PARAMS", "Ver Parámetros", "VIEW"), ("MANAGE_PARAMS", "Gestionar Parámetros", "MANAGE") }, actor, "/parameter-catalog"); + } + + // Add domain resources linked to modules + var idmMod = suite.Modules.First(m => m.Code.GetValue() == "IDM"); + var authMod = suite.Modules.First(m => m.Code.GetValue() == "AUTH"); + var sysMod = suite.Modules.First(m => m.Code.GetValue() == "SYS"); + + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("TENANT"), Name.Create("Agregado de Empresa"), Description.Create("Raíz del agregado de empresa"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("USER"), Name.Create("Agregado de Usuario"), Description.Create("Raíz del agregado de usuario"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("DELEGATION"), Name.Create("Agregado de Delegación"), Description.Create("Raíz del agregado de delegación"), actor); + + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("SYSTEM_SUITE"), Name.Create("Agregado de Sistema"), Description.Create("Raíz del agregado de sistema"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PERMISSION_TEMPLATE"), Name.Create("Agregado de Plantilla"), Description.Create("Raíz del agregado de plantilla"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PROFILE"), Name.Create("Agregado de Perfil"), Description.Create("Raíz del agregado de perfil"), actor); + + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("FEATURE_FLAG"), Name.Create("Agregado de Bandera de Función"), Description.Create("Raíz del agregado de bandera de función"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("APP_CONFIG"), Name.Create("Agregado de Configuración"), Description.Create("Raíz del agregado de configuración"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PARAMETER"), Name.Create("Agregado de Parámetro"), Description.Create("Raíz del agregado de parámetro"), actor); + + suites.Add(suite); + } + return suites.Count > 0 ? suites[0] : null; + } + + + private static IReadOnlyList BuildGenericSystemSuites(TenantId tenantId, ActorId actor) + { + var suites = new List(); + + var coreResult = SystemSuiteAggregate.Create( + tenantId, + Code.Create("UMS"), + Name.Create("Sistema de Gestión de Usuarios"), + Description.Create("Funcionalidad principal del UMS"), + actor); + + if (coreResult.IsSuccess) + { + var suite = coreResult.Value; + + // Register standard actions + suite.RegisterAction(ActionCode.Create("VIEW"), Name.Create("Ver Núcleo Logístico"), actor); + suite.RegisterAction(ActionCode.Create("MANAGE"), Name.Create("Gestionar Núcleo Logístico"), actor); + suite.RegisterAction(ActionCode.Create("APPROVE"), Name.Create("Aprobar Operaciones"), actor); + + // Register standard domain actions + suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Crear Registro"), actor); + suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Leer Registro"), actor); + suite.RegisterAction(ActionCode.Create("UPDATE"), Name.Create("Actualizar Registro"), actor); + suite.RegisterAction(ActionCode.Create("DELETE"), Name.Create("Eliminar Registro"), actor); + suite.RegisterAction(ActionCode.Create("SEARCH"), Name.Create("Buscar Registros"), actor); + + // Add app settings + suite.AddAppSetting( + ConfigurationKey.Create("SessionTimeout"), + ConfigurationValue.Create("30"), + ConfigurationScope.Global, + actor); + suite.AddAppSetting( + ConfigurationKey.Create("MaxRetries"), + ConfigurationValue.Create("5"), + ConfigurationScope.Global, + actor); + + // Add IDM module + var modIdm = suite.AddModule(Code.Create("IDM"), Name.Create("Identidad y Accesos"), Description.Create("Gestión de empresas, usuarios y delegaciones"), 1, actor, IconoDeModulo("IDM")); + if (modIdm.IsSuccess) + { + var module = suite.Modules.First(m => m.Code.GetValue() == "IDM"); + suite.ActivateModule(module.Props.Id, actor); + + SeedNavMenu(suite, module, 1, "TENANTS", "Empresas", "TENANTS_LIST", "Lista de Empresas", + new[] { ("VIEW_TENANTS", "Ver Empresas", "VIEW"), ("MANAGE_TENANTS", "Gestionar Empresas", "MANAGE") }, actor, "/tenants"); + SeedNavMenu(suite, module, 2, "USERS", "Usuarios", "USERS_LIST", "Lista de Usuarios", + new[] { ("VIEW_USERS", "Ver Usuarios", "VIEW"), ("MANAGE_USERS", "Gestionar Usuarios", "MANAGE") }, actor, "/users"); + SeedNavMenu(suite, module, 3, "DELEGATIONS", "Delegaciones", "DELEGATIONS_LIST", "Lista de Delegaciones", + new[] { ("VIEW_DELEGATIONS", "Ver Delegaciones", "VIEW"), ("MANAGE_DELEGATIONS", "Gestionar Delegaciones", "MANAGE") }, actor, "/delegations"); + } - module.AddMenu(Code.Create("APP_CONFIG"), Name.Create("App Configurations"), Description.Create("Manage App Configs"), 2, actor); - var menuConfig = module.Menus.First(m => m.Code.GetValue() == "APP_CONFIG"); - menuConfig.AddSubMenu(Code.Create("CONFIG_LIST"), Name.Create("Config List"), Description.Create("Config List"), 1, actor); - var subMenuConfig = menuConfig.SubMenus.First(); - subMenuConfig.AddOption(Code.Create("VIEW_CONFIG"), Name.Create("View Configs"), Description.Create("View Configs"), ActionCode.Create("VIEW"), 1, actor); - subMenuConfig.AddOption(Code.Create("MANAGE_CONFIG"), Name.Create("Manage Configs"), Description.Create("Manage Configs"), ActionCode.Create("MANAGE"), 2, actor); + // Add AUTH module + var modAuth = suite.AddModule(Code.Create("AUTH"), Name.Create("Autorización"), Description.Create("Perfiles, plantillas y sistemas"), 2, actor, IconoDeModulo("AUTH")); + if (modAuth.IsSuccess) + { + var module = suite.Modules.First(m => m.Code.GetValue() == "AUTH"); + suite.ActivateModule(module.Props.Id, actor); - module.AddMenu(Code.Create("PARAM_CATALOG"), Name.Create("Parameter Catalog"), Description.Create("Manage Parameters"), 3, actor); - var menuParam = module.Menus.First(m => m.Code.GetValue() == "PARAM_CATALOG"); - menuParam.AddSubMenu(Code.Create("PARAM_LIST"), Name.Create("Param List"), Description.Create("Param List"), 1, actor); - var subMenuParam = menuParam.SubMenus.First(); - subMenuParam.AddOption(Code.Create("VIEW_PARAMS"), Name.Create("View Params"), Description.Create("View Params"), ActionCode.Create("VIEW"), 1, actor); - subMenuParam.AddOption(Code.Create("MANAGE_PARAMS"), Name.Create("Manage Params"), Description.Create("Manage Params"), ActionCode.Create("MANAGE"), 2, actor); + SeedNavMenu(suite, module, 1, "SYSTEM_SUITES", "Sistemas", "SUITES_LIST", "Lista de Sistemas", + new[] { ("VIEW_SUITES", "Ver Sistemas", "VIEW"), ("MANAGE_SUITES", "Gestionar Sistemas", "MANAGE") }, actor, "/system-suites"); + SeedNavMenu(suite, module, 2, "PERMISSION_TEMPLATES", "Plantillas de Permisos", "TEMPLATES_LIST", "Lista de Plantillas", + new[] { ("VIEW_TEMPLATES", "Ver Plantillas", "VIEW"), ("MANAGE_TEMPLATES", "Gestionar Plantillas", "MANAGE") }, actor, "/permission-templates"); + SeedNavMenu(suite, module, 3, "PROFILES", "Perfiles", "PROFILES_LIST", "Lista de Perfiles", + new[] { ("VIEW_PROFILES", "Ver Perfiles", "VIEW"), ("MANAGE_PROFILES", "Gestionar Perfiles", "MANAGE") }, actor, "/profiles"); + } + + // Add SYS module + var modSys = suite.AddModule(Code.Create("SYS"), Name.Create("Configuración del Sistema"), Description.Create("Propiedades globales, ajustes y banderas"), 3, actor, IconoDeModulo("SYS")); + if (modSys.IsSuccess) + { + var module = suite.Modules.First(m => m.Code.GetValue() == "SYS"); + suite.ActivateModule(module.Props.Id, actor); + + SeedNavMenu(suite, module, 1, "FEATURE_FLAGS", "Banderas de Función", "FLAGS_LIST", "Lista de Banderas", + new[] { ("VIEW_FLAGS", "Ver Banderas", "VIEW"), ("MANAGE_FLAGS", "Gestionar Banderas", "MANAGE") }, actor, "/feature-flags"); + SeedNavMenu(suite, module, 2, "APP_CONFIG", "Configuraciones", "CONFIG_LIST", "Lista de Configuraciones", + new[] { ("VIEW_CONFIG", "Ver Configuraciones", "VIEW"), ("MANAGE_CONFIG", "Gestionar Configuraciones", "MANAGE") }, actor, "/app-configurations"); + SeedNavMenu(suite, module, 3, "PARAM_CATALOG", "Catálogo de Parámetros", "PARAM_LIST", "Lista de Parámetros", + new[] { ("VIEW_PARAMS", "Ver Parámetros", "VIEW"), ("MANAGE_PARAMS", "Gestionar Parámetros", "MANAGE") }, actor, "/parameter-catalog"); } // Add domain resources linked to modules @@ -327,17 +1166,17 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI var authMod = suite.Modules.First(m => m.Code.GetValue() == "AUTH"); var sysMod = suite.Modules.First(m => m.Code.GetValue() == "SYS"); - suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("TENANT"), Name.Create("Tenant Aggregate"), Description.Create("Tenant aggregate root"), actor); - suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("USER"), Name.Create("User Aggregate"), Description.Create("User aggregate root"), actor); - suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("DELEGATION"), Name.Create("Delegation Aggregate"), Description.Create("Delegation aggregate root"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("TENANT"), Name.Create("Agregado de Empresa"), Description.Create("Raíz del agregado de empresa"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("USER"), Name.Create("Agregado de Usuario"), Description.Create("Raíz del agregado de usuario"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("DELEGATION"), Name.Create("Agregado de Delegación"), Description.Create("Raíz del agregado de delegación"), actor); - suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("SYSTEM_SUITE"), Name.Create("System Suite Aggregate"), Description.Create("System Suite aggregate root"), actor); - suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PERMISSION_TEMPLATE"), Name.Create("Template Aggregate"), Description.Create("Template aggregate root"), actor); - suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PROFILE"), Name.Create("Profile Aggregate"), Description.Create("Profile aggregate root"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("SYSTEM_SUITE"), Name.Create("Agregado de Sistema"), Description.Create("Raíz del agregado de sistema"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PERMISSION_TEMPLATE"), Name.Create("Agregado de Plantilla"), Description.Create("Raíz del agregado de plantilla"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PROFILE"), Name.Create("Agregado de Perfil"), Description.Create("Raíz del agregado de perfil"), actor); - suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("FEATURE_FLAG"), Name.Create("Feature Flag Aggregate"), Description.Create("Feature Flag aggregate root"), actor); - suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("APP_CONFIG"), Name.Create("App Config Aggregate"), Description.Create("App Config aggregate root"), actor); - suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PARAMETER"), Name.Create("Parameter Aggregate"), Description.Create("Parameter aggregate root"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("FEATURE_FLAG"), Name.Create("Agregado de Bandera de Función"), Description.Create("Raíz del agregado de bandera de función"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("APP_CONFIG"), Name.Create("Agregado de Configuración"), Description.Create("Raíz del agregado de configuración"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PARAMETER"), Name.Create("Agregado de Parámetro"), Description.Create("Raíz del agregado de parámetro"), actor); suites.Add(suite); } @@ -345,19 +1184,19 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI var wmsResult = SystemSuiteAggregate.Create( tenantId, Code.Create("WMS"), - Name.Create("Warehouse Management"), - Description.Create("Warehouse inventory management"), + Name.Create("Almacén"), + Description.Create("Gestión de inventario de almacén"), actor); if (wmsResult.IsSuccess) { var suite = wmsResult.Value; - suite.RegisterAction(ActionCode.Create("INVENTORY_VIEW"), Name.Create("View Inventory"), actor); - suite.RegisterAction(ActionCode.Create("INVENTORY_EDIT"), Name.Create("Edit Inventory"), actor); - suite.RegisterAction(ActionCode.Create("GENERATE_REPORT"), Name.Create("Generate Report"), actor); - suite.RegisterAction(ActionCode.Create("EXPORT_DATA"), Name.Create("Export Data"), actor); - suite.RegisterAction(ActionCode.Create("IMPORT_DATA"), Name.Create("Import Data"), actor); + suite.RegisterAction(ActionCode.Create("INVENTORY_VIEW"), Name.Create("Ver Inventario"), actor); + suite.RegisterAction(ActionCode.Create("INVENTORY_EDIT"), Name.Create("Editar Inventario"), actor); + suite.RegisterAction(ActionCode.Create("GENERATE_REPORT"), Name.Create("Generar Reporte"), actor); + suite.RegisterAction(ActionCode.Create("EXPORT_DATA"), Name.Create("Exportar Datos"), actor); + suite.RegisterAction(ActionCode.Create("IMPORT_DATA"), Name.Create("Importar Datos"), actor); suite.AddAppSetting( ConfigurationKey.Create("AllowNegativeStock"), @@ -365,65 +1204,53 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI ConfigurationScope.Global, actor); - var modInv = suite.AddModule(Code.Create("INV"), Name.Create("Inventory Control"), Description.Create("Inventory management and levels"), 1, actor); + var modInv = suite.AddModule(Code.Create("INV"), Name.Create("Inventario"), Description.Create("Gestión y niveles de inventario"), 1, actor, IconoDeModulo("INV")); if (modInv.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "INV"); suite.ActivateModule(module.Props.Id, actor); - // Menu 1: Stock levels - module.AddMenu(Code.Create("STOCK"), Name.Create("Stock Administration"), Description.Create("Stock levels and status"), 1, actor); - var menuStock = module.Menus.First(m => m.Code.GetValue() == "STOCK"); - - menuStock.AddSubMenu(Code.Create("LEVELS"), Name.Create("Real-time Levels"), Description.Create("Current physical stock status"), 1, actor); - var subMenuLevels = menuStock.SubMenus.First(sm => sm.Code.GetValue() == "LEVELS"); - subMenuLevels.AddOption(Code.Create("VIEW_STOCK"), Name.Create("View Stock Levels"), Description.Create("Permission to view real-time inventory counts"), ActionCode.Create("INVENTORY_VIEW"), 1, actor); - subMenuLevels.AddOption(Code.Create("ADJUST_STOCK"), Name.Create("Adjust Inventory Counts"), Description.Create("Permission to perform physical inventory adjustments"), ActionCode.Create("INVENTORY_EDIT"), 2, actor); - - // Menu 2: Operations - module.AddMenu(Code.Create("OPS"), Name.Create("Warehouse Operations"), Description.Create("Stock movements and transfers"), 2, actor); - var menuOps = module.Menus.First(m => m.Code.GetValue() == "OPS"); + // Menú 1: niveles de stock + SeedNavMenu(suite, module, 1, "STOCK", "Administración de Stock", "LEVELS", "Niveles en Tiempo Real", + new[] { ("VIEW_STOCK", "Ver Niveles de Stock", "INVENTORY_VIEW"), ("ADJUST_STOCK", "Ajustar Conteos de Inventario", "INVENTORY_EDIT") }, actor); - menuOps.AddSubMenu(Code.Create("TRANSFERS"), Name.Create("Warehouse Transfers"), Description.Create("Move stock between physical locations"), 1, actor); - var subMenuTransfers = menuOps.SubMenus.First(sm => sm.Code.GetValue() == "TRANSFERS"); - subMenuTransfers.AddOption(Code.Create("INITIATE_TRANSFER"), Name.Create("Initiate Stock Transfer"), Description.Create("Permission to draft and start a transfer request"), ActionCode.Create("INVENTORY_EDIT"), 1, actor); - subMenuTransfers.AddOption(Code.Create("APPROVE_TRANSFER"), Name.Create("Approve Location Transfer"), Description.Create("Permission to authorize inventory relocation"), ActionCode.Create("INVENTORY_EDIT"), 2, actor); + // Menú 2: operaciones + SeedNavMenu(suite, module, 2, "OPS", "Operaciones de Almacén", "TRANSFERS", "Transferencias de Almacén", + new[] { ("INITIATE_TRANSFER", "Iniciar Transferencia de Stock", "INVENTORY_EDIT"), ("APPROVE_TRANSFER", "Aprobar Transferencia de Ubicación", "INVENTORY_EDIT") }, actor); } // GAP-7: Add Reports module to WMS - var modReports = suite.AddModule(Code.Create("REPORTS"), Name.Create("Reports & Analytics"), Description.Create("Warehouse reporting and analytics"), 2, actor); + var modReports = suite.AddModule(Code.Create("REPORTS"), Name.Create("Reportes"), Description.Create("Reportes y analítica de almacén"), 2, actor, IconoDeModulo("REPORTS")); if (modReports.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "REPORTS"); suite.ActivateModule(module.Props.Id, actor); - // Menu 1: Inventory Reports - module.AddMenu(Code.Create("INV_REPORTS"), Name.Create("Inventory Reports"), Description.Create("Stock and inventory reports"), 1, actor); - var menuInvReports = module.Menus.First(m => m.Code.GetValue() == "INV_REPORTS"); + // Menú 1: reportes de inventario (dos submenús — se usa el helper de nodos) + var invReportsMenu = AddNode(suite, module, (MenuNodeEntity?)null, NodeKind.Menu, "INV_REPORTS", "Reportes de Inventario", 1, actor); - menuInvReports.AddSubMenu(Code.Create("STOCK_SUMMARY"), Name.Create("Stock Summary"), Description.Create("Overall stock summary report"), 1, actor); - var subMenuStockSummary = menuInvReports.SubMenus.First(sm => sm.Code.GetValue() == "STOCK_SUMMARY"); - subMenuStockSummary.AddOption(Code.Create("VIEW_STOCK_REPORT"), Name.Create("View Stock Report"), Description.Create("Permission to view stock summary"), ActionCode.Create("GENERATE_REPORT"), 1, actor); - subMenuStockSummary.AddOption(Code.Create("EXPORT_STOCK"), Name.Create("Export Stock Data"), Description.Create("Permission to export stock data"), ActionCode.Create("EXPORT_DATA"), 2, actor); + var stockSummary = AddNode(suite, module, invReportsMenu, NodeKind.SubMenu, "STOCK_SUMMARY", "Resumen de Stock", 1, actor); + var viewStockReport = AddNode(suite, module, stockSummary, NodeKind.Option, "VIEW_STOCK_REPORT", "Ver Reporte de Stock", 1, actor); + suite.LinkModuleNodeAction(module.Props.Id, viewStockReport.GetId(), ActionCode.Create("GENERATE_REPORT"), actor); + var exportStock = AddNode(suite, module, stockSummary, NodeKind.Option, "EXPORT_STOCK", "Exportar Datos de Stock", 2, actor); + suite.LinkModuleNodeAction(module.Props.Id, exportStock.GetId(), ActionCode.Create("EXPORT_DATA"), actor); - menuInvReports.AddSubMenu(Code.Create("MOVEMENT_REPORTS"), Name.Create("Movement Reports"), Description.Create("Stock movement history"), 2, actor); - var subMenuMovement = menuInvReports.SubMenus.First(sm => sm.Code.GetValue() == "MOVEMENT_REPORTS"); - subMenuMovement.AddOption(Code.Create("VIEW_MOVEMENT"), Name.Create("View Movement Report"), Description.Create("Permission to view movement history"), ActionCode.Create("GENERATE_REPORT"), 1, actor); + var movement = AddNode(suite, module, invReportsMenu, NodeKind.SubMenu, "MOVEMENT_REPORTS", "Reportes de Movimiento", 2, actor); + var viewMovement = AddNode(suite, module, movement, NodeKind.Option, "VIEW_MOVEMENT", "Ver Reporte de Movimiento", 1, actor); + suite.LinkModuleNodeAction(module.Props.Id, viewMovement.GetId(), ActionCode.Create("GENERATE_REPORT"), actor); - // Menu 2: Import/Export - module.AddMenu(Code.Create("IO"), Name.Create("Import / Export"), Description.Create("Data import and export operations"), 2, actor); - var menuIO = module.Menus.First(m => m.Code.GetValue() == "IO"); - - menuIO.AddSubMenu(Code.Create("IMPORT"), Name.Create("Data Import"), Description.Create("Import inventory data from external sources"), 1, actor); - var subMenuImport = menuIO.SubMenus.First(sm => sm.Code.GetValue() == "IMPORT"); - subMenuImport.AddOption(Code.Create("RUN_IMPORT"), Name.Create("Run Data Import"), Description.Create("Permission to execute data import"), ActionCode.Create("IMPORT_DATA"), 1, actor); + // Menú 2: import/export + var ioMenu = AddNode(suite, module, (MenuNodeEntity?)null, NodeKind.Menu, "IO", "Importar / Exportar", 2, actor); + var import = AddNode(suite, module, ioMenu, NodeKind.SubMenu, "IMPORT", "Importación de Datos", 1, actor); + var runImport = AddNode(suite, module, import, NodeKind.Option, "RUN_IMPORT", "Ejecutar Importación de Datos", 1, actor); + suite.LinkModuleNodeAction(module.Props.Id, runImport.GetId(), ActionCode.Create("IMPORT_DATA"), actor); } // Add domain resources for WMS var invMod = suite.Modules.First(m => m.Code.GetValue() == "INV"); - suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("INVENTORY_WMS"), Name.Create("WMS Inventory Aggregate"), Description.Create("Warehouse Inventory Management"), actor); - suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Entity, Code.Create("STOCK_MOVEMENT"), Name.Create("Stock Movement Entity"), Description.Create("Stock Movement Tracking"), actor); - suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Entity, Code.Create("TRANSFER_ORDER"), Name.Create("Transfer Order Entity"), Description.Create("Warehouse Transfer Orders"), actor); + suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("INVENTORY_WMS"), Name.Create("Agregado de Inventario de Almacén"), Description.Create("Gestión de inventario de almacén"), actor); + suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Entity, Code.Create("STOCK_MOVEMENT"), Name.Create("Entidad de Movimiento de Stock"), Description.Create("Seguimiento de movimientos de stock"), actor); + suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Entity, Code.Create("TRANSFER_ORDER"), Name.Create("Entidad de Orden de Transferencia"), Description.Create("Órdenes de transferencia de almacén"), actor); suites.Add(suite); } @@ -431,7 +1258,7 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI return suites; } - private static IReadOnlyList BuildSeedPermissionTemplates(TenantId tenantId, IReadOnlyList suites, IReadOnlyList roles, ActorId actor) + private static IReadOnlyList BuildGenericPermissionTemplates(TenantId tenantId, IReadOnlyList suites, IReadOnlyList roles, ActorId actor) { var templates = new List(); if (suites.Count == 0 || roles.Count == 0) return templates; @@ -454,7 +1281,6 @@ private static IReadOnlyList BuildSeedPermissionTem // Helper to find a domain resource by code from the core suite var tenantResource = coreSuite.DomainResources.FirstOrDefault(r => r.Code.GetValue() == "TENANT"); var userResource = coreSuite.DomainResources.FirstOrDefault(r => r.Code.GetValue() == "USER"); - var auditLogResource = coreSuite.DomainResources.FirstOrDefault(r => r.Code.GetValue() == "AUDIT_LOG"); // if still relevant, though we removed it, so we'll ignore it // ── 1. ADMIN V2 — Published, full suite access ──────────────────── if (adminRole != null) @@ -466,24 +1292,21 @@ private static IReadOnlyList BuildSeedPermissionTem foreach (var mod in coreSuite.Modules) { adminV2.AddItem(ExclusiveArcTarget.Module, mod.Props.Id, ActionId.Create(), true, false, actor); - foreach (var menu in mod.Menus) + foreach (var root in mod.Nodes) { - adminV2.AddItem(ExclusiveArcTarget.Submodule, menu.Props.Id, ActionId.Create(), true, false, actor); - foreach (var subMenu in menu.SubMenus) - { - adminV2.AddItem(ExclusiveArcTarget.Option, subMenu.Props.Id, ActionId.Create(), true, false, actor); - foreach (var opt in subMenu.Options) - { - adminV2.AddItem(ExclusiveArcTarget.Option, opt.Props.Id, ActionId.Create(), true, false, actor); - } - } + AddNodeNavigation(adminV2, root, coreSuite, actor); } } - // Domain resources: full access + // Domain resources: full access. El grafo (fail-closed, G-039) evalúa el + // efecto por (resourceId, actionId) sobre TODA acción de la suite, así que + // se concede cada acción real; un único ActionId aleatorio no concedería nada. foreach (var res in coreSuite.DomainResources) { var targetType = res.Type == DomainResourceType.Aggregate ? ExclusiveArcTarget.Aggregate : ExclusiveArcTarget.Entity; - adminV2.AddItem(targetType, res.Id, ActionId.Create(), true, false, actor); + foreach (var action in coreSuite.Actions) + { + adminV2.AddItem(targetType, res.Id, action.GetId(), true, false, actor); + } } adminV2.Publish(actor); templates.Add(adminV2); @@ -516,17 +1339,17 @@ private static IReadOnlyList BuildSeedPermissionTem var idmMod = coreSuite.Modules.FirstOrDefault(m => m.Code.GetValue() == "IDM"); if (idmMod != null) { - var usersMenu = idmMod.Menus.FirstOrDefault(m => m.Code.GetValue() == "USERS"); + var usersMenu = FindNodeByCode(idmMod, "USERS"); if (usersMenu != null) { - var listSubMenu = usersMenu.SubMenus.FirstOrDefault(sm => sm.Code.GetValue() == "USERS_LIST"); + var listSubMenu = FindNodeByCode(idmMod, "USERS_LIST"); if (listSubMenu != null) { - var viewUsersOpt = listSubMenu.Options.FirstOrDefault(o => o.Code.GetValue() == "VIEW_USERS"); + var viewUsersOpt = FindNodeByCode(idmMod, "VIEW_USERS"); // Navigation: view users options only - auditorTpl.AddItem(ExclusiveArcTarget.Submodule, usersMenu.Props.Id, ActionId.Create(), true, false, actor); - auditorTpl.AddItem(ExclusiveArcTarget.Option, listSubMenu.Props.Id, ActionId.Create(), true, false, actor); - if (viewUsersOpt != null) auditorTpl.AddItem(ExclusiveArcTarget.Option, viewUsersOpt.Props.Id, ActionId.Create(), true, false, actor); + auditorTpl.AddItem(ExclusiveArcTarget.Submodule, usersMenu.GetId(), ActionId.Create(), true, false, actor); + auditorTpl.AddItem(ExclusiveArcTarget.Option, listSubMenu.GetId(), ActionId.Create(), true, false, actor); + if (viewUsersOpt != null) AddOptionActionItems(auditorTpl, viewUsersOpt, coreSuite, actor); } } } @@ -544,14 +1367,14 @@ private static IReadOnlyList BuildSeedPermissionTem var idmMod = coreSuite.Modules.FirstOrDefault(m => m.Code.GetValue() == "IDM"); if (idmMod != null) { - var usersMenu = idmMod.Menus.FirstOrDefault(m => m.Code.GetValue() == "USERS"); + var usersMenu = FindNodeByCode(idmMod, "USERS"); if (usersMenu != null) { - var listSubMenu = usersMenu.SubMenus.FirstOrDefault(sm => sm.Code.GetValue() == "USERS_LIST"); + var listSubMenu = FindNodeByCode(idmMod, "USERS_LIST"); if (listSubMenu != null) { - var viewUsersOpt = listSubMenu.Options.FirstOrDefault(o => o.Code.GetValue() == "VIEW_USERS"); - if (viewUsersOpt != null) readonlyTpl.AddItem(ExclusiveArcTarget.Option, viewUsersOpt.Props.Id, ActionId.Create(), true, false, actor); + var viewUsersOpt = FindNodeByCode(idmMod, "VIEW_USERS"); + if (viewUsersOpt != null) AddOptionActionItems(readonlyTpl, viewUsersOpt, coreSuite, actor); } } } @@ -568,15 +1391,15 @@ private static IReadOnlyList BuildSeedPermissionTem var idmMod = coreSuite.Modules.FirstOrDefault(m => m.Code.GetValue() == "IDM"); if (idmMod != null) { - var usersMenu = idmMod.Menus.FirstOrDefault(m => m.Code.GetValue() == "USERS"); + var usersMenu = FindNodeByCode(idmMod, "USERS"); if (usersMenu != null) { - var listSubMenu = usersMenu.SubMenus.FirstOrDefault(sm => sm.Code.GetValue() == "USERS_LIST"); + var listSubMenu = FindNodeByCode(idmMod, "USERS_LIST"); if (listSubMenu != null) { - var editUsersOpt = listSubMenu.Options.FirstOrDefault(o => o.Code.GetValue() == "MANAGE_USERS"); - dataEntryTpl.AddItem(ExclusiveArcTarget.Option, listSubMenu.Props.Id, ActionId.Create(), true, false, actor); - if (editUsersOpt != null) dataEntryTpl.AddItem(ExclusiveArcTarget.Option, editUsersOpt.Props.Id, ActionId.Create(), true, false, actor); + var editUsersOpt = FindNodeByCode(idmMod, "MANAGE_USERS"); + dataEntryTpl.AddItem(ExclusiveArcTarget.Option, listSubMenu.GetId(), ActionId.Create(), true, false, actor); + if (editUsersOpt != null) AddOptionActionItems(dataEntryTpl, editUsersOpt, coreSuite, actor); } } } @@ -591,16 +1414,15 @@ private static IReadOnlyList BuildSeedPermissionTem { var operatorTpl = PermissionTemplateAggregate.Create(tenantId, operatorRole.GetId(), wmsSuite.GetId(), actor).Value; var invMod = wmsSuite.Modules.First(m => m.Code.GetValue() == "INV"); - var stockMenu = invMod.Menus.First(m => m.Code.GetValue() == "STOCK"); - var levelsSubMenu = stockMenu.SubMenus.First(sm => sm.Code.GetValue() == "LEVELS"); - var viewStockOpt = levelsSubMenu.Options.First(o => o.Code.GetValue() == "VIEW_STOCK"); - var adjustStockOpt = levelsSubMenu.Options.First(o => o.Code.GetValue() == "ADJUST_STOCK"); + var levelsSubMenu = FindNodeByCode(invMod, "LEVELS")!; + var viewStockOpt = FindNodeByCode(invMod, "VIEW_STOCK")!; + var adjustStockOpt = FindNodeByCode(invMod, "ADJUST_STOCK")!; var invWms = wmsSuite.DomainResources.First(x => x.Code.GetValue() == "INVENTORY_WMS"); operatorTpl.AddItem(ExclusiveArcTarget.Module, invMod.Props.Id, ActionId.Create(), true, false, actor); - operatorTpl.AddItem(ExclusiveArcTarget.Option, levelsSubMenu.Props.Id, ActionId.Create(), true, false, actor); - operatorTpl.AddItem(ExclusiveArcTarget.Option, viewStockOpt.Props.Id, ActionId.Create(), true, false, actor); - operatorTpl.AddItem(ExclusiveArcTarget.Option, adjustStockOpt.Props.Id, ActionId.Create(), false, true, actor); + operatorTpl.AddItem(ExclusiveArcTarget.Option, levelsSubMenu.GetId(), ActionId.Create(), true, false, actor); + AddOptionActionItems(operatorTpl, viewStockOpt, wmsSuite, actor); + AddOptionActionItems(operatorTpl, adjustStockOpt, wmsSuite, actor, isAllowed: false, isDenied: true); operatorTpl.AddItem(ExclusiveArcTarget.Aggregate, invWms.Id, ActionId.Create(), true, false, actor); operatorTpl.Publish(actor); templates.Add(operatorTpl); @@ -611,13 +1433,12 @@ private static IReadOnlyList BuildSeedPermissionTem { var inspectorTpl = PermissionTemplateAggregate.Create(tenantId, inspectorRole.GetId(), wmsSuite.GetId(), actor).Value; var invMod = wmsSuite.Modules.First(m => m.Code.GetValue() == "INV"); - var stockMenu = invMod.Menus.First(m => m.Code.GetValue() == "STOCK"); - var levelsSubMenu = stockMenu.SubMenus.First(sm => sm.Code.GetValue() == "LEVELS"); - var viewStockOpt = levelsSubMenu.Options.First(o => o.Code.GetValue() == "VIEW_STOCK"); + var levelsSubMenu = FindNodeByCode(invMod, "LEVELS")!; + var viewStockOpt = FindNodeByCode(invMod, "VIEW_STOCK")!; inspectorTpl.AddItem(ExclusiveArcTarget.Module, invMod.Props.Id, ActionId.Create(), true, false, actor); - inspectorTpl.AddItem(ExclusiveArcTarget.Option, levelsSubMenu.Props.Id, ActionId.Create(), true, false, actor); - inspectorTpl.AddItem(ExclusiveArcTarget.Option, viewStockOpt.Props.Id, ActionId.Create(), true, false, actor); + inspectorTpl.AddItem(ExclusiveArcTarget.Option, levelsSubMenu.GetId(), ActionId.Create(), true, false, actor); + AddOptionActionItems(inspectorTpl, viewStockOpt, wmsSuite, actor); inspectorTpl.Publish(actor); templates.Add(inspectorTpl); } @@ -630,17 +1451,9 @@ private static IReadOnlyList BuildSeedPermissionTem foreach (var mod in wmsSuite.Modules) { managerTpl.AddItem(ExclusiveArcTarget.Module, mod.Props.Id, ActionId.Create(), true, false, actor); - foreach (var menu in mod.Menus) + foreach (var root in mod.Nodes) { - managerTpl.AddItem(ExclusiveArcTarget.Submodule, menu.Props.Id, ActionId.Create(), true, false, actor); - foreach (var subMenu in menu.SubMenus) - { - managerTpl.AddItem(ExclusiveArcTarget.Option, subMenu.Props.Id, ActionId.Create(), true, false, actor); - foreach (var opt in subMenu.Options) - { - managerTpl.AddItem(ExclusiveArcTarget.Option, opt.Props.Id, ActionId.Create(), true, false, actor); - } - } + AddNodeNavigation(managerTpl, root, wmsSuite, actor); } } managerTpl.Publish(actor); @@ -652,16 +1465,16 @@ private static IReadOnlyList BuildSeedPermissionTem { var dispatcherTpl = PermissionTemplateAggregate.Create(tenantId, dispatcherRole.GetId(), wmsSuite.GetId(), actor).Value; var invMod = wmsSuite.Modules.First(m => m.Code.GetValue() == "INV"); - var opsMenu = invMod.Menus.First(m => m.Code.GetValue() == "OPS"); - var transfersSubMenu = opsMenu.SubMenus.First(sm => sm.Code.GetValue() == "TRANSFERS"); - var initiateOpt = transfersSubMenu.Options.First(o => o.Code.GetValue() == "INITIATE_TRANSFER"); - var approveOpt = transfersSubMenu.Options.First(o => o.Code.GetValue() == "APPROVE_TRANSFER"); + var opsMenu = FindNodeByCode(invMod, "OPS")!; + var transfersSubMenu = FindNodeByCode(invMod, "TRANSFERS")!; + var initiateOpt = FindNodeByCode(invMod, "INITIATE_TRANSFER")!; + var approveOpt = FindNodeByCode(invMod, "APPROVE_TRANSFER")!; var transferOrder = wmsSuite.DomainResources.First(x => x.Code.GetValue() == "TRANSFER_ORDER"); - dispatcherTpl.AddItem(ExclusiveArcTarget.Submodule, opsMenu.Props.Id, ActionId.Create(), true, false, actor); - dispatcherTpl.AddItem(ExclusiveArcTarget.Option, transfersSubMenu.Props.Id, ActionId.Create(), true, false, actor); - dispatcherTpl.AddItem(ExclusiveArcTarget.Option, initiateOpt.Props.Id, ActionId.Create(), true, false, actor); - dispatcherTpl.AddItem(ExclusiveArcTarget.Option, approveOpt.Props.Id, ActionId.Create(), true, false, actor); + dispatcherTpl.AddItem(ExclusiveArcTarget.Submodule, opsMenu.GetId(), ActionId.Create(), true, false, actor); + dispatcherTpl.AddItem(ExclusiveArcTarget.Option, transfersSubMenu.GetId(), ActionId.Create(), true, false, actor); + AddOptionActionItems(dispatcherTpl, initiateOpt, wmsSuite, actor); + AddOptionActionItems(dispatcherTpl, approveOpt, wmsSuite, actor); dispatcherTpl.AddItem(ExclusiveArcTarget.Entity, transferOrder.Id, ActionId.Create(), true, false, actor); dispatcherTpl.Publish(actor); templates.Add(dispatcherTpl); @@ -672,15 +1485,14 @@ private static IReadOnlyList BuildSeedPermissionTem { var reporterTpl = PermissionTemplateAggregate.Create(tenantId, reporterRole.GetId(), wmsSuite.GetId(), actor).Value; var reportsMod = wmsSuite.Modules.First(m => m.Code.GetValue() == "REPORTS"); - var invReportsMenu = reportsMod.Menus.First(m => m.Code.GetValue() == "INV_REPORTS"); - var stockSummarySubMenu = invReportsMenu.SubMenus.First(sm => sm.Code.GetValue() == "STOCK_SUMMARY"); - var viewReportOpt = stockSummarySubMenu.Options.First(o => o.Code.GetValue() == "VIEW_STOCK_REPORT"); - var exportOpt = stockSummarySubMenu.Options.First(o => o.Code.GetValue() == "EXPORT_STOCK"); + var stockSummarySubMenu = FindNodeByCode(reportsMod, "STOCK_SUMMARY")!; + var viewReportOpt = FindNodeByCode(reportsMod, "VIEW_STOCK_REPORT")!; + var exportOpt = FindNodeByCode(reportsMod, "EXPORT_STOCK")!; reporterTpl.AddItem(ExclusiveArcTarget.Module, reportsMod.Props.Id, ActionId.Create(), true, false, actor); - reporterTpl.AddItem(ExclusiveArcTarget.Option, stockSummarySubMenu.Props.Id, ActionId.Create(), true, false, actor); - reporterTpl.AddItem(ExclusiveArcTarget.Option, viewReportOpt.Props.Id, ActionId.Create(), true, false, actor); - reporterTpl.AddItem(ExclusiveArcTarget.Option, exportOpt.Props.Id, ActionId.Create(), true, false, actor); + reporterTpl.AddItem(ExclusiveArcTarget.Option, stockSummarySubMenu.GetId(), ActionId.Create(), true, false, actor); + AddOptionActionItems(reporterTpl, viewReportOpt, wmsSuite, actor); + AddOptionActionItems(reporterTpl, exportOpt, wmsSuite, actor); reporterTpl.Publish(actor); templates.Add(reporterTpl); } @@ -688,7 +1500,7 @@ private static IReadOnlyList BuildSeedPermissionTem return templates; } - private static IReadOnlyList BuildSeedProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) + private static IReadOnlyList BuildGenericProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) { var profiles = new List(); @@ -821,7 +1633,6 @@ private static async Task EnsureInternalAdminProfileAsync( private static async Task EnsureDomainResourcesAsync( IReadOnlyList existingSuites, - TenantId tenantId, ActorId actor, ISystemSuiteRepository repository, CancellationToken cancellationToken) @@ -875,3 +1686,5 @@ private static async Task EnsureDomainResourcesAsync( }; } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs index ba5a03cd..92e95896 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs @@ -14,18 +14,25 @@ namespace Ums.Infrastructure.Persistence.Seeders; using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; using FeatureFlagAggregate = Ums.Domain.Configuration.FeatureFlag.FeatureFlag; using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; using TenantParameterAggregate = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; public static class ConfigurationDevDataSeeder { private const string TestIdpSystemSuiteId = "11111111-1111-1111-1111-111111111111"; + private const string PaitaAgroexportFlagCode = "PAITA_AGROEXPORT"; + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. La siembra determinista con " + + "IDs bien conocidos exige fijar identidades y estados que el dominio no permite por vías " + + "públicas. No se ejecuta en producción (SeedDevData && !IsProduction).")] private static readonly BindingFlags PrivateInstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic; private static readonly Guid DemoSystemSuiteGuid = Guid.Parse(CoreDevDataSeeder.DemoSystemSuiteId); private static readonly Guid RansaTenantGuid = Guid.Parse(CoreDevDataSeeder.RansaTenantId); private static readonly Guid InternalAdminTenantGuid = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId); private static readonly Guid ApmTenantGuid = Guid.Parse("A3F5B9D2-7C3D-4C8E-A9B0-123456789ABC"); private static readonly Guid NeptuniaTenantGuid = Guid.Parse("C9B736B4-6A84-48F8-B34D-176BC5A6D542"); - private static readonly Guid UnimarTenantGuid = Guid.Parse("5F4E3D2C-1B0A-9F8E-7D6C-543210987654"); + private static readonly Guid BeyondNetTenantGuid = Guid.Parse("5F4E3D2C-1B0A-9F8E-7D6C-543210987654"); private static readonly Guid PaitaTenantGuid = Guid.Parse("9E8D7C6B-5A4F-3E2D-1C0B-9876543210FE"); private static readonly Guid IntradevcoTenantGuid = Guid.Parse("F3E2D1C0-B9A8-7F6E-5D4C-321098765432"); @@ -57,6 +64,11 @@ private static async Task SeedAppConfigurationsAsync( var needsSave = false; + // NOTA: `GetByScopeAndCodeAsync` solo ve las configuraciones VIVAS desde que la ranura se + // libera al borrar. Consecuencia deliberada: si en un entorno de desarrollo alguien elimina + // una configuración sembrada, el siguiente arranque la vuelve a crear —con id nuevo, junto a + // la lápida—. Es el contrato de un sembrado: garantizar la línea base del entorno. No afecta + // a producción, donde este sembrador no corre (SeedDevData && !IsProduction). foreach (var config in desiredConfigs) { var existing = await repository.GetByScopeAndCodeAsync( @@ -101,6 +113,14 @@ private static IReadOnlyList BuildSeedAppConfiguratio Description.Create("Maximum login attempts before lockout"), actor); + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos (nivel Global). + AddPublishedConfiguration(results, + null, + Code.Create(AppConfigurationCodes.AccountLockoutDurationMinutes), + ConfigurationValue.Create(AppConfigurationDefaults.AccountLockoutDurationMinutes.ToString()), + Description.Create("Temporary account lockout duration in minutes after reaching the max login attempts"), + actor); + AddPublishedConfiguration(results, null, Code.Create(AppConfigurationCodes.AccessTokenDurationMs), @@ -133,7 +153,7 @@ private static IReadOnlyList BuildSeedAppConfiguratio null, Code.Create(AppConfigurationCodes.FrontendConfigTransport), ConfigurationValue.Create(AppConfigurationDefaults.FrontendConfigTransport), - Description.Create("Transport mode for frontend config queries: 'graphql' or 'rest'"), + Description.Create("Transport mode for frontend config queries (REST only)"), actor); AddPublishedConfiguration(results, @@ -189,6 +209,14 @@ private static IReadOnlyList BuildTenantSpecificConfi Description.Create($"Login attempt limit for {profile.Name}"), actor); + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos (nivel Tenant). + AddPublishedConfiguration(results, + tenantId, + Code.Create(AppConfigurationCodes.AccountLockoutDurationMinutes), + ConfigurationValue.Create(profile.AccountLockoutDurationMinutes), + Description.Create($"Temporary account lockout duration in minutes for {profile.Name}"), + actor); + AddPublishedConfiguration(results, tenantId, Code.Create(AppConfigurationCodes.MinPasswordLength), @@ -348,12 +376,73 @@ private static async Task SeedFeatureFlagsAsync( } } + // FS-25 §4.5 / criterio 12: bandera de agroexportación acotada al tenant BEYONDNET (sucursal Paita). + needsSave = await SeedBeyondNetAgroexportFlagAsync(repository, suites, existingByKey, actor, cancellationToken) || needsSave; + if (needsSave) { await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } + private static async Task SeedBeyondNetAgroexportFlagAsync( + IFeatureFlagRepository repository, + IReadOnlyList suites, + IDictionary<(Guid SystemSuiteId, string FlagCode), FeatureFlagAggregate> existingByKey, + ActorId actor, + CancellationToken cancellationToken) + { + var anchorSuiteId = ResolveBeyondNetAnchorSuiteId(suites); + if (anchorSuiteId is null || existingByKey.ContainsKey((anchorSuiteId.Value, PaitaAgroexportFlagCode))) + { + return false; + } + + var paitaFlag = BuildBeyondNetAgroexportFlag(anchorSuiteId.Value, actor); + if (paitaFlag is null) + { + return false; + } + + await repository.AddAsync(paitaFlag, cancellationToken); + existingByKey[(anchorSuiteId.Value, PaitaAgroexportFlagCode)] = paitaFlag; + return true; + } + + private static Guid? ResolveBeyondNetAnchorSuiteId(IReadOnlyList suites) + { + var beyondNetSuites = suites.Where(suite => suite.TenantId.GetValue() == BeyondNetTenantGuid).ToList(); + var anchor = beyondNetSuites.FirstOrDefault(suite => suite.Code.GetValue() == "WMS") + ?? beyondNetSuites.FirstOrDefault(); + return anchor?.GetId().GetValue(); + } + + private static FeatureFlagAggregate? BuildBeyondNetAgroexportFlag(Guid anchorSuiteId, ActorId actor) + { + var flagResult = FeatureFlagAggregate.Create( + IdValueObject.Load(anchorSuiteId), + IdValueObject.Load(BeyondNetTenantGuid), + PaitaAgroexportFlagCode, + FlagType.Boolean, + "*", + null, + null, + null, + actor); + + if (flagResult.IsFailure) + { + return null; + } + + var flag = flagResult.Value; + flag.AddCriteria("TenantId", "Equals", CoreDevDataSeeder.BeyondNetTenantId, actor); + flag.DomainEvents.MarkChangesAsCommitted(); + flag.Activate(actor); + flag.DomainEvents.MarkChangesAsCommitted(); + return flag; + } + private static void ApplyFeatureFlagDemoRules( FeatureFlagAggregate flag, FeatureFlagSeedDefinition definition, @@ -423,6 +512,10 @@ private static IReadOnlyList GetFeatureFlagDefinition }; } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. Fija el TenantId del feature " + + "flag semilla sobre props no públicos; no se ejecuta en producción (SeedDevData && !IsProduction).")] private static void SetFeatureFlagTenantId(FeatureFlagAggregate featureFlag, Guid tenantId) { var propsField = typeof(FeatureFlagAggregate).GetField("_props", PrivateInstanceFlags); @@ -810,8 +903,8 @@ private static IReadOnlyList GetTenantSeedProfiles() "keyvault://ums/neptunia-entra-secret", 1), new TenantSeedProfile( - UnimarTenantGuid, - "UNIMAR", + BeyondNetTenantGuid, + "BEYONDNET", AppConfigurationDefaults.SessionTimeoutMinutes.ToString(), AppConfigurationDefaults.MaxLoginAttempts.ToString(), AppConfigurationDefaults.MinPasswordLength.ToString(), @@ -883,7 +976,10 @@ private sealed record TenantSeedProfile( string[] ExternalIdpDomainHints, string ExternalIdpPayload, string ExternalIdpSecretRef, - int ExternalIdpResolutionPriority); + int ExternalIdpResolutionPriority, + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos. Default seguro (15 min), + // alineado con AppConfigurationDefaults.AccountLockoutDurationMinutes; parametrizable por perfil. + string AccountLockoutDurationMinutes = "15"); private sealed record FeatureFlagSeedDefinition( string Code, diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs index d9e8dfea..7752512a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs @@ -1,6 +1,9 @@ namespace Ums.Infrastructure.Persistence.Seeders; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Ums.Infrastructure.Persistence.Options; +using Ums.Domain.Identity; public static class CoreDevDataSeeder { @@ -14,6 +17,10 @@ public static class CoreDevDataSeeder // ── SuperAdmin User (global admin) ───────────────────────────────────────── public const string SuperAdminUserId = "22222222-2222-2222-2222-222222222222"; public const string SuperAdminUsername = "admin"; + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", "S2068:Hard-coded credentials are security-sensitive", + Justification = "Contraseña de datos SEMILLA de desarrollo, nunca de producción: solo se aplica bajo " + + "SeedDevData && !IsProduction. No es un secreto real (RB-06, stage UAT determinista).")] public const string SuperAdminPassword = "root"; // Default password for INTERNAL admin (change in production) public const string InternalAdminPendingUserId = "11111103-1111-1111-1111-111111111111"; @@ -36,16 +43,117 @@ public static class CoreDevDataSeeder public const string DemoSystemSuiteId = "dddd0001-0000-0000-0000-000000000001"; public const string InternalAdminInboxWorkflowId = "88888888-3333-3333-3333-333333333333"; + // ── BEYONDNET operator (SUPPLIER) — FS-25 seed dataset ─────────────────────── + // Existing stable tenant GUID; reconciled by upsert (RUC + branches), never recreated. + public const string BeyondNetTenantId = "5f4e3d2c-1b0a-9f8e-7d6c-543210987654"; + public const string BeyondNetRuc = "RUC-20100412447"; + // Uniform dev password (min. 12 chars) for BEYONDNET internal and client users (FS-25 §4.4). + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", "S2068:Hard-coded credentials are security-sensitive", + Justification = "Contraseña de datos SEMILLA de desarrollo (FS-25 §4.4), nunca de producción: solo bajo " + + "SeedDevData && !IsProduction. No es un secreto real (RB-06, stage UAT determinista).")] + public const string BeyondNetDevPassword = "BeyondNet.Dev.2026"; + + // BEYONDNET client tenants (importer/exporter) added by FS-25 §4.1. + public const string ComexAndinaTenantId = "c0e1a000-1111-4c0e-a000-000000000001"; // COMEX_ANDINA (impo Lima) + public const string AgronorteTenantId = "a9701e00-2222-4a97-b000-000000000002"; // AGRONORTE (expo Paita) + public const string FrupiuraTenantId = "f3401a00-3333-4f34-c000-000000000003"; // FRUPIURA (expo Paita, opcional) + + // "Cliente de mi cliente": a CLIENT tenant that hangs off COMEX_ANDINA (ParentTenantId), + // to exercise the tenant hierarchy. Gets the same scoped treatment as the external clients + // (PORTAL_CLIENTE-only + CLIENTE_EXTERNO). GUID is deterministic and distinct from its parent. + public const string ImpoAndinaSubTenantId = "c0e1b000-1111-4c0e-b000-000000000011"; // IMPO_ANDINA_SUB (hijo de COMEX_ANDINA) + public const string ImpoAndinaSubTenantCode = "IMPO_ANDINA_SUB"; + public const string ImpoAndinaSubTenantName = "Importadora Sub-Cliente de Comex Andina S.A.C."; + public const string ImpoAndinaSubBranchCode = "IASUB_LIMA"; + public const string ImpoAndinaSubBranchName = "Almacén Lima Sub-Cliente"; + public const string ImpoAndinaSubUserEmail = "usuario@impo-subcliente.com.pe"; + + // BEYONDNET internal user deterministic GUIDs are derived from the BEYONDNET tenant GUID + // by replacing byte[0] with the index below (same little-endian scheme used by the + // Identity and Authorization seeders). Profiles link to users through these indices. + // 1 admin.callao · 2 agente.aduanas.callao · 3 despachador.callao · 4 jefe.almacen.callao + // 5 coordinador.transporte.callao · 6 ejecutivo.cuenta.callao · 7 analista.doc.callao + // 8 auditor.callao · 9 jefe.almacen.paita · 10 operario.almacen.paita + // 11 agente.aduanas.paita · 12 ejecutivo.cuenta.paita · 20 admin (root BEYONDNET, sin sucursal) + public const byte BeyondNetJefeAlmacenPaitaUserIndex = 9; // FS-25 criterio 7 + + // BEYONDNET ROOT ADMIN: transversal operator admin (no branch), distinct from the per-branch + // admin (index 1) and from the platform super-admin (admin@ums.local). Same derived-guid scheme. + public const byte BeyondNetRootAdminUserIndex = 20; + public const string BeyondNetRootAdminEmail = "admin@beyondnet.com.pe"; + + // Tenant ancla usado como marca de "ya sembrado" por la guarda de idempotencia. + public const string SeedAnchorTenantCode = "RANSA_PERU"; + public static async Task SeedAllAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { + // G-127: guarda de idempotencia a nivel de orquestador. Si el dataset ya está sembrado + // (tenant ancla RANSA_PERU presente), se omite TODA la siembra → los reinicios de pod no + // re-ejecutan los 7 seeders ni ensucian los logs con conflictos de PK esperados. Los + // seeders siguen siendo idempotentes por sí mismos (GUID fijos + reconciliación por clave + // natural), así que un `reset` —que dropea el esquema— siempre re-siembra limpio. Solo + // aplica al backend persistente (PostgreSQL); en modo in-memory ITenantRepository no está + // registrado y siempre se siembra desde cero. Recuperación de una siembra inicial parcial + // (un seeder falló en el primer arranque): ejecutar `reset` (ver runbook UAT). + // `Persistence:ForceReseed` salta la guarda. Sin esa escotilla, el conjunto sembrado queda + // congelado en lo que hubiera el primer día: añadir después un sistema al catálogo no lo + // hace llegar a ninguna base existente, y la única salida era un `reset` que dropea el + // esquema —y con él lo que NO siembra el código, como el Tablero SDLC, que se carga por + // API—. Los sembradores reconcilian por clave natural, así que volver a pasarlos añade lo + // que falta sin duplicar lo que ya está. + var forzar = serviceProvider.GetService>()?.Value.ForceReseed ?? false; + + if (!forzar && await IsAlreadySeededAsync(serviceProvider, cancellationToken)) + { + Console.WriteLine( + $"Seed skipped: anchor tenant '{SeedAnchorTenantCode}' already present (idempotent restart)."); + return; + } + + if (forzar) + { + Console.WriteLine( + "Seed forced: Persistence:ForceReseed=true — se ejecutan los sembradores aunque el " + + "dataset ya exista. Añaden lo que falta por clave natural; apágalo tras comprobar."); + } + await RunSeederAsync(serviceProvider, "Identity", IdentityDevDataSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Authorization", AuthorizationDevDataSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Configuration", ConfigurationDevDataSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Parameter catalog", ParameterCatalogSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Approvals", ApprovalsDevDataSeeder.SeedAsync, cancellationToken); + await RunSeederAsync(serviceProvider, "IGA", IgaDevDataSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Audit", AuditDevDataSeeder.SeedAsync, cancellationToken); } + private static async Task IsAlreadySeededAsync( + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var scopeFactory = serviceProvider.GetRequiredService(); + using var scope = scopeFactory.CreateScope(); + + // En modo in-memory el repositorio persistente no está registrado → nunca se omite. + var tenantRepository = scope.ServiceProvider.GetService(); + if (tenantRepository is null) + { + return false; + } + + try + { + var anchor = await tenantRepository.GetByCodeAsync(SeedAnchorTenantCode, cancellationToken); + return anchor is not null; + } + catch + { + // Ante cualquier fallo del chequeo (p. ej. esquema aún migrándose) se cae a sembrar: + // los seeders son idempotentes, de modo que sembrar de más es seguro y no duplica. + return false; + } + } + private static async Task RunSeederAsync( IServiceProvider serviceProvider, string seederName, diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs index 85a35ad6..7745a89e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs @@ -1,10 +1,10 @@ namespace Ums.Infrastructure.Persistence.Seeders; +using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Ums.Application.Common.Interfaces; using Ums.Domain.Enums; using Ums.Domain.Identity.Tenant; -using Ums.Domain.Identity.Tenant.Branding; using Ums.Domain.Identity.UserAccount; using Ums.Domain.Identity.UserManagementDelegation; using Ums.Domain.Kernel.ValueObjects; @@ -57,9 +57,22 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } } + // FS-25 Flujo B: reconcile a legacy BEYONDNET snapshot (wrong RUC/branches) in place, + // by its stable GUID — never recreate the tenant nor break existing references. + if (inMemoryTenantRepository is null && tenantRepository is not null) + { + await ReconcileBeyondNetTenantAsync(tenantRepository, actor, cancellationToken); + } + + // Branch GUIDs are generated inside the aggregate (Branch.Create), so we read them back + // from the freshly built tenants by code and pass them to the users we associate (FS-25). + var beyondNetCallaoBranchId = FindBranchId(tenants, "BEYONDNET", "BN_CALLAO"); + var beyondNetPaitaBranchId = FindBranchId(tenants, "BEYONDNET", "BN_PAITA"); + var impoAndinaSubBranchId = FindBranchId(tenants, CoreDevDataSeeder.ImpoAndinaSubTenantCode, CoreDevDataSeeder.ImpoAndinaSubBranchCode); + // Seed / sync User Accounts (including SuperAdmin) so local password logins stay valid // even when the DB already contains an older dev snapshot. - var userAccounts = BuildSeedUserAccounts(actor, passwordHasher); + var userAccounts = BuildSeedUserAccounts(actor, passwordHasher, beyondNetCallaoBranchId, beyondNetPaitaBranchId, impoAndinaSubBranchId); if (inMemoryUserAccountRepository is null && userAccountRepository is not null) { foreach (var userAccount in userAccounts) @@ -124,7 +137,10 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio private static IReadOnlyList BuildSeedTenants(ActorId actor) { - // ── 0. Internal Admin Tenant (global administration) ──────────────────── + // ── 0. Internal Admin Tenant (break-glass) ────────────────────────────── + // ADR-0071 / FS-26: el Admin Root (propietario de gestión) es BEYONDNET, no este + // tenant sintético. INTERNAL_ADMIN queda como cuenta de emergencia degradada: + // NO es propietario de gestión (isManagementOwner: false). var internalAdminTenantResult = TenantAggregate.Create( Code.Create(CoreDevDataSeeder.InternalAdminTenantCode), Name.Create(CoreDevDataSeeder.InternalAdminTenantName), @@ -134,7 +150,7 @@ private static IReadOnlyList BuildSeedTenants(ActorId actor) null, null, TenantId.Load(Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)), - isManagementOwner: true); + isManagementOwner: false); if (internalAdminTenantResult.IsFailure) { @@ -161,13 +177,32 @@ private static IReadOnlyList BuildSeedTenants(ActorId actor) var paita = BuildTenant(Guid.Parse("9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe"), "PAITA_PORT", "Terminal Portuario de Paita S.A.", "RUC-20512180098", OrganizationType.CLIENT, null, false, actor, [("PAITA_MUELLE", "Muelle de Transferencia — Puerto Paita"), ("PAITA_ALMACEN", "Almacén General Paita")]); - var unimar = BuildTenant(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654"), "UNIMAR", "Unimar S.A. — Lima", "RUC-20101523381", OrganizationType.SUPPLIER, null, false, actor, - [("UNI_MIRAFLORES", "Oficina Miraflores — Av. Larco"), ("UNI_CALLAO_OP", "Operaciones Callao — Jr. Colón")]); + // FS-25: BEYONDNET operator reconciled to its real RUC (20100412447) and its two + // real branches (Operaciones Callao, Sucursal Paita). Same stable tenant GUID. + // ADR-0071 / FS-26: BEYONDNET es el Tenant Raíz / Admin Root del ecosistema — + // OrganizationType.INTERNAL + único propietario de gestión (isManagementOwner: true). + // De ese flag deriva el login (AuthEndpoints) el privilegio transversal is_internal_admin. + var beyondnet = BuildTenant(Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId), "BEYONDNET", "BeyondNet S.A.C.", CoreDevDataSeeder.BeyondNetRuc, OrganizationType.INTERNAL, null, true, actor, + [("BN_CALLAO", "Operaciones Callao"), ("BN_PAITA", "Sucursal Paita")]); var intradevco = BuildTenant(Guid.Parse("f3e2d1c0-b9a8-7f6e-5d4c-321098765432"), "INTRADEVCO", "Intradevco Industrial S.A.", "RUC-20101041268", OrganizationType.SUPPLIER, null, false, actor, [("INTRA_SJL", "Planta San Juan de Lurigancho"), ("INTRA_ATE", "Almacén Ate Vitarte — Carretera Central")]); - return [internalAdminTenant, ransa, neptunia, apm, paita, unimar, intradevco]; + // FS-25 §4.1: BEYONDNET client companies (importer/exporter), one branch each. + var comexAndina = BuildTenant(Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId), "COMEX_ANDINA", "Comercializadora Andina S.A.C.", "RUC-20512345671", OrganizationType.CLIENT, null, false, actor, + [("CAND_LIMA", "Almacén Lima")]); + + var agronorte = BuildTenant(Guid.Parse(CoreDevDataSeeder.AgronorteTenantId), "AGRONORTE", "Agroexportadora del Norte S.A.C.", "RUC-20484123456", OrganizationType.CLIENT, null, false, actor, + [("AGRN_PAITA", "Planta Paita")]); + + var frupiura = BuildTenant(Guid.Parse(CoreDevDataSeeder.FrupiuraTenantId), "FRUPIURA", "Frutícola Piura S.A.C.", "RUC-20526098765", OrganizationType.CLIENT, null, false, actor, + [("FRPI_PAITA", "Packing Paita")]); + + // "Cliente de mi cliente": CLIENT tenant HIJO de COMEX_ANDINA (ParentTenantId), una sucursal. + var impoAndinaSub = BuildTenant(Guid.Parse(CoreDevDataSeeder.ImpoAndinaSubTenantId), CoreDevDataSeeder.ImpoAndinaSubTenantCode, CoreDevDataSeeder.ImpoAndinaSubTenantName, "RUC-20609988771", OrganizationType.CLIENT, Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId), false, actor, + [(CoreDevDataSeeder.ImpoAndinaSubBranchCode, CoreDevDataSeeder.ImpoAndinaSubBranchName)]); + + return [internalAdminTenant, ransa, neptunia, apm, paita, beyondnet, intradevco, comexAndina, agronorte, frupiura, impoAndinaSub]; } private static TenantAggregate BuildTenant( @@ -215,28 +250,10 @@ private static TenantAggregate BuildTenant( // IDP registered but NOT activated — dev mode uses InternalBcrypt (local password login). // Activate in production/staging when Azure AD SSO is configured. tenant.RegisterIdentityProvider(Code.Create("ENTRA_ID"), Name.Create("Azure AD Corporativo"), Description.Create("Directorio principal Ransa"), IdpStrategy.AzureAd, actor); - - var branding = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("base64_ransa_logo_data"), LogoFormat.Png) - .WithTheme(HexColor.Create("#006400"), BackgroundStyle.SolidColor) - .WithTexts(LoginText.Create("Bienvenido a Ransa"), LoginText.Create("Ingresa tus credenciales"), LoginText.Create("Iniciar sesión"), LoginText.Create("© 2026 Ransa Comercial")) - .WithCustomDomain(CustomDomain.Create("login.ransa.pe")) - .WithMagicLinkFallback(true) - .Build(); - tenant.SetBranding(branding, actor); } else if (code == "NEPTUNIA") { tenant.RegisterIdentityProvider(Code.Create("OKTA_CORP"), Name.Create("Okta Neptunia"), Description.Create("Directorio subsidiarias"), IdpStrategy.Okta, actor); - - var branding = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("base64_neptunia_logo_data"), LogoFormat.Png) - .WithTheme(HexColor.Create("#00008B"), BackgroundStyle.Gradient) - .WithTexts(LoginText.Create("Portal Neptunia"), LoginText.Create("Accesos a operaciones portuarias"), LoginText.Create("Entrar"), LoginText.Create("© 2026 Neptunia")) - .WithCustomDomain(CustomDomain.Create("acceso.neptunia.pe")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(branding, actor); } else if (code == "PAITA_PORT") { @@ -250,14 +267,26 @@ private static TenantAggregate BuildTenant( return tenant; } - private static IReadOnlyList BuildSeedUserAccounts(ActorId actor, IPasswordHashingService? passwordHasher) + private static BranchId? FindBranchId(IReadOnlyList tenants, string tenantCode, string branchCode) + { + var tenant = tenants.FirstOrDefault(t => t.Code.GetValue() == tenantCode); + var branch = tenant?.Branches.FirstOrDefault(b => b.Code.GetValue() == branchCode); + return branch?.GetId(); + } + + private static IReadOnlyList BuildSeedUserAccounts( + ActorId actor, + IPasswordHashingService? passwordHasher, + BranchId? beyondNetCallaoBranchId = null, + BranchId? beyondNetPaitaBranchId = null, + BranchId? impoAndinaSubBranchId = null) { var internalAdminTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)); var ransaTenantId = TenantId.Load(Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6")); var neptuniaTenantId = TenantId.Load(Guid.Parse("c9b736b4-6a84-48f8-b34d-176bc5a6d542")); var apmTenantId = TenantId.Load(Guid.Parse("a3f5b9d2-7c3d-4c8e-a9b0-123456789abc")); var paitaTenantId = TenantId.Load(Guid.Parse("9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe")); - var unimarTenantId = TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")); + var beyondNetTenantId = TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")); var intradevcoTenantId = TenantId.Load(Guid.Parse("f3e2d1c0-b9a8-7f6e-5d4c-321098765432")); var result = new List(); @@ -305,12 +334,92 @@ private static IReadOnlyList BuildSeedUserAccounts(ActorId result.AddRange(BuildSeedUserAccountsForTenant(neptuniaTenantId, actor, passwordHasher)); result.AddRange(BuildSeedUserAccountsForTenant(apmTenantId, actor, passwordHasher)); result.AddRange(BuildSeedUserAccountsForTenant(paitaTenantId, actor, passwordHasher)); - result.AddRange(BuildSeedUserAccountsForTenant(unimarTenantId, actor, passwordHasher)); result.AddRange(BuildSeedUserAccountsForTenant(intradevcoTenantId, actor, passwordHasher)); + // ── FS-25: BEYONDNET operator internal users (by branch) and client users ── + result.AddRange(BuildBeyondNetUserAccounts(beyondNetTenantId, actor, passwordHasher, beyondNetCallaoBranchId, beyondNetPaitaBranchId)); + + // BEYONDNET ROOT ADMIN — transversal operator admin, no branch (distinct from admin.callao). + result.Add(BuildActiveLocalUser( + beyondNetTenantId, CoreDevDataSeeder.BeyondNetRootAdminUserIndex, + CoreDevDataSeeder.BeyondNetRootAdminEmail, actor, passwordHasher)); + + result.Add(BuildActiveLocalUser( + TenantId.Load(Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId)), 1, + "usuario.impo@comexandina.com.pe", actor, passwordHasher)); + result.Add(BuildActiveLocalUser( + TenantId.Load(Guid.Parse(CoreDevDataSeeder.AgronorteTenantId)), 1, + "usuario.expo@agronorte.com.pe", actor, passwordHasher)); + + // "Cliente de mi cliente": single external user of the sub-client tenant, on its branch. + result.Add(BuildActiveLocalUser( + TenantId.Load(Guid.Parse(CoreDevDataSeeder.ImpoAndinaSubTenantId)), 1, + CoreDevDataSeeder.ImpoAndinaSubUserEmail, actor, passwordHasher, impoAndinaSubBranchId)); + return result; } + // FS-25 §4.4: the 12 BEYONDNET internal users (Callao + Paita). Indices match the + // profile→user mapping in AuthorizationDevDataSeeder (byte[0] = index). Each user is + // associated to its branch (Callao 1-8, Paita 9-12) via BranchId. + private static IReadOnlyList BuildBeyondNetUserAccounts( + TenantId beyondNetTenantId, + ActorId actor, + IPasswordHashingService? passwordHasher, + BranchId? callaoBranchId, + BranchId? paitaBranchId) + { + var users = new (byte Index, string Email, BranchId? BranchId)[] + { + (1, "admin.callao@beyondnet.com.pe", callaoBranchId), + (2, "agente.aduanas.callao@beyondnet.com.pe", callaoBranchId), + (3, "despachador.callao@beyondnet.com.pe", callaoBranchId), + (4, "jefe.almacen.callao@beyondnet.com.pe", callaoBranchId), + (5, "coordinador.transporte.callao@beyondnet.com.pe", callaoBranchId), + (6, "ejecutivo.cuenta.callao@beyondnet.com.pe", callaoBranchId), + (7, "analista.doc.callao@beyondnet.com.pe", callaoBranchId), + (8, "auditor.callao@beyondnet.com.pe", callaoBranchId), + (9, "jefe.almacen.paita@beyondnet.com.pe", paitaBranchId), + (10, "operario.almacen.paita@beyondnet.com.pe", paitaBranchId), + (11, "agente.aduanas.paita@beyondnet.com.pe", paitaBranchId), + (12, "ejecutivo.cuenta.paita@beyondnet.com.pe", paitaBranchId), + }; + + return users + .Select(u => BuildActiveLocalUser(beyondNetTenantId, u.Index, u.Email, actor, passwordHasher, u.BranchId)) + .ToList(); + } + + // Creates an Active user with the uniform dev password. No IdentityReference so the + // federated-user invariant does not block the local password login (FS-25 §4.4). + private static UserAccountAggregate BuildActiveLocalUser(TenantId tenantId, byte index, string email, ActorId actor, IPasswordHashingService? passwordHasher, BranchId? branchId = null) + { + var baseBytes = tenantId.GetValue().ToByteArray(); + baseBytes[0] = index; + var userId = UserAccountId.Load(new Guid(baseBytes)); + + var result = UserAccountAggregate.Create( + tenantId, Email.Create(email), + UserCategory.Internal, null, null, actor, + branchId: branchId, + userAccountId: userId); + + if (result.IsFailure) + { + throw new InvalidOperationException($"Unable to build BEYONDNET dev user seed {email}: {result.Error}"); + } + + var user = result.Value; + user.Activate(actor); + if (passwordHasher != null) + { + var hash = PasswordHash.Create(passwordHasher.Hash(CoreDevDataSeeder.BeyondNetDevPassword)); + user.AddPassword(hash, actor); + } + + return user; + } + private static IReadOnlyList BuildSeedUserAccountsForTenant(TenantId tenantId, ActorId actor, IPasswordHashingService? passwordHasher = null) { var baseGuidBytes = tenantId.GetValue().ToByteArray(); @@ -321,13 +430,17 @@ Guid DeriveGuid(byte index) return new Guid(bytes); } - var domain = tenantId.GetValue().ToString().StartsWith("3fa8") ? "ransa.pe" : - tenantId.GetValue().ToString().StartsWith("c9b7") ? "neptunia.pe" : - tenantId.GetValue().ToString().StartsWith("a3f5") ? "apmterminals.com" : - tenantId.GetValue().ToString().StartsWith("9e8d") ? "tpp-paita.com.pe" : - tenantId.GetValue().ToString().StartsWith("5f4e") ? "unimar.com.pe" : - tenantId.GetValue().ToString().StartsWith("f3e2") ? "intradevco.com.pe" : - "logistics.pe"; + var tenantStr = tenantId.GetValue().ToString(); + var domain = tenantStr switch + { + _ when tenantStr.StartsWith("3fa8") => "ransa.pe", + _ when tenantStr.StartsWith("c9b7") => "neptunia.pe", + _ when tenantStr.StartsWith("a3f5") => "apmterminals.com", + _ when tenantStr.StartsWith("9e8d") => "tpp-paita.com.pe", + _ when tenantStr.StartsWith("5f4e") => "evolith.com.pe", + _ when tenantStr.StartsWith("f3e2") => "intradevco.com.pe", + _ => "logistics.pe" + }; // Admin uses local password — no IdentityReference so the federated-user invariant doesn't block AddPassword var adminResult = UserAccountAggregate.Create( @@ -408,31 +521,105 @@ private static UserAccountAggregate BuildUserAccount( return result.Value; } + private static async Task ReconcileBeyondNetTenantAsync(ITenantRepository tenantRepository, ActorId actor, CancellationToken cancellationToken) + { + var beyondnet = await tenantRepository.GetByIdAsync(Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId), cancellationToken); + if (beyondnet is null) + { + return; + } + + var changed = false; + + // Correct the RUC (CompanyReference) if a legacy snapshot carries a wrong value. + if (beyondnet.CompanyReference?.GetValue() != CoreDevDataSeeder.BeyondNetRuc) + { + SetCompanyReference(beyondnet, CompanyReference.Create(CoreDevDataSeeder.BeyondNetRuc)); + changed = true; + } + + // Ensure the two real branches exist (Callao + Paita). + var required = new[] { ("BN_CALLAO", "Operaciones Callao"), ("BN_PAITA", "Sucursal Paita") }; + foreach (var (code, name) in required) + { + if (beyondnet.Branches.All(b => b.Code.GetValue() != code)) + { + beyondnet.AddBranch(Code.Create(code), Name.Create(name), actor); + changed = true; + } + } + + // Cierra cualquier sucursal ajena al par real (Flujo B) — incluida la instantánea heredada + // UNI_LIMA, hoy renombrada a BN_CALLAO. + // + // ADR-0164: CIERRE, no borrado. La fila se queda y su código queda ocupado, que es lo que se + // quiere: si UNI_LIMA vuelve a aparecer en un volcado antiguo, el alta chocará en vez de + // crear una segunda sucursal con el mismo código. Se excluyen las ya cerradas para que el + // sembrado siga siendo idempotente: sin ese filtro, cada ejecución reintentaría cerrarlas y + // marcaría un cambio que no existe. + var stale = beyondnet.Branches + .Where(b => !b.IsClosed && b.Code.GetValue() != "BN_CALLAO" && b.Code.GetValue() != "BN_PAITA") + .ToList(); + foreach (var branch in stale) + { + // Recuentos en cero: el sembrado corre sobre datos de desarrollo/UAT que él mismo + // controla y no hay usuarios ni perfiles colgando de estas sucursales heredadas. La + // guarda real vive en CloseBranchCommandHandler, que sí consulta la base. + beyondnet.CloseBranch(branch.Props.Id, actor, reason: "Sucursal heredada fuera del par operativo real (siembra)."); + changed = true; + } + + if (changed) + { + await tenantRepository.UpdateAsync(beyondnet, cancellationToken); + await tenantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. Fija la referencia de empresa " + + "del tenant semilla sobre props no públicos; no se ejecuta en producción " + + "(SeedDevData && !IsProduction).")] + private static void SetCompanyReference(TenantAggregate tenant, CompanyReference companyReference) + { + const BindingFlags privateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + var propsField = typeof(TenantAggregate).GetField("_props", privateInstance); + var props = propsField?.GetValue(tenant) as TenantProps; + if (props is null) + { + return; + } + + var companyReferenceProperty = typeof(TenantProps).GetProperty(nameof(TenantProps.CompanyReference)); + companyReferenceProperty?.SetValue(props, companyReference); + } + private static IReadOnlyList BuildSeedDelegations(ActorId actor) { - var unimarTenantId = TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")); - var unimarBaseBytes = unimarTenantId.GetValue().ToByteArray(); - Guid DeriveUnimarGuid(byte index) + var beyondNetTenantId = TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")); + var beyondNetBaseBytes = beyondNetTenantId.GetValue().ToByteArray(); + Guid DeriveBeyondNetGuid(byte index) { - var bytes = (byte[])unimarBaseBytes.Clone(); + var bytes = (byte[])beyondNetBaseBytes.Clone(); bytes[0] = index; return new Guid(bytes); } - var adminId = UserAccountId.Load(DeriveUnimarGuid(1)); - var analystId = UserAccountId.Load(DeriveUnimarGuid(2)); - var partnerId = UserAccountId.Load(DeriveUnimarGuid(5)); + var adminId = UserAccountId.Load(DeriveBeyondNetGuid(1)); + var analystId = UserAccountId.Load(DeriveBeyondNetGuid(2)); + var partnerId = UserAccountId.Load(DeriveBeyondNetGuid(5)); // 1. Active Delegation var activeDel = UserManagementDelegationAggregate.Create( - unimarTenantId, adminId, analystId, DelegationScopeType.Tenant, null, + beyondNetTenantId, adminId, analystId, DelegationScopeType.Tenant, null, new[] { DelegatedAction.CreateUser, DelegatedAction.BlockUser }, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30), 90, false, actor).Value; activeDel.Activate(actor); // 2. Expired Delegation var expiredDel = UserManagementDelegationAggregate.Create( - unimarTenantId, adminId, partnerId, DelegationScopeType.Tenant, null, + beyondNetTenantId, adminId, partnerId, DelegationScopeType.Tenant, null, new[] { DelegatedAction.CreateUser }, DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-1), 90, false, actor).Value; expiredDel.Activate(actor); @@ -440,7 +627,7 @@ Guid DeriveUnimarGuid(byte index) // 3. Revoked Delegation var revokedDel = UserManagementDelegationAggregate.Create( - unimarTenantId, analystId, adminId, DelegationScopeType.Tenant, null, + beyondNetTenantId, analystId, adminId, DelegationScopeType.Tenant, null, new[] { DelegatedAction.BlockUser }, DateTimeOffset.UtcNow.AddDays(-10), DateTimeOffset.UtcNow.AddDays(20), 90, false, actor).Value; revokedDel.Activate(actor); @@ -448,7 +635,7 @@ Guid DeriveUnimarGuid(byte index) // 4. Draft / Pending Approval var draftDel = UserManagementDelegationAggregate.Create( - unimarTenantId, partnerId, analystId, DelegationScopeType.Tenant, null, + beyondNetTenantId, partnerId, analystId, DelegationScopeType.Tenant, null, new[] { DelegatedAction.CreateUser }, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(15), 90, true, actor).Value; // Not activated diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IgaDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IgaDevDataSeeder.cs new file mode 100644 index 00000000..e3548232 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IgaDevDataSeeder.cs @@ -0,0 +1,128 @@ +namespace Ums.Infrastructure.Persistence.Seeders; + +using Microsoft.Extensions.DependencyInjection; +using Ums.Domain.Enums; +using Ums.Domain.IGA; +using Ums.Domain.Kernel.ValueObjects; +using Ums.Infrastructure.Persistence; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; + +/// +/// Datos de desarrollo del contexto acotado IGA (ADR-UMS-093, G-052). Siembra estados de madurez +/// () para el inquilino RANSA, de modo que el happy-path de +/// promoción se pueda ejercer de punta a punta: la confirmación de elegibilidad es fail-closed +/// y, sin un RoleMaturityStatus sembrado, siempre RECHAZA (INV-RPR4). Aquí se siembra: +/// +/// • un estado ELEGIBLE para RansaAdminUserId en su rol actual (DemoAdminRoleId): +/// nivel Junior, ingreso al nivel > 2 años atrás (supera el mínimo de 6 meses del salto Junior), +/// desempeño 4.5 (≥ 3.0) y sin incidencias de cumplimiento ⇒ EvaluateEligibility aprueba; +/// +/// • un estado NO ELEGIBLE (borde) para RansaAnalystUserId en DemoOperatorRoleId: +/// ingreso reciente (10 días) ⇒ no supera el tiempo mínimo en nivel ⇒ RECHAZA, ejercitando el +/// corte fail-closed sin depender de la ausencia del dato. +/// +/// Respeta el acotamiento por inquilino (todo se siembra bajo RansaTenantId) y es idempotente +/// bajo PostgreSQL (comprueba existencia por usuario+rol antes de insertar). Sigue el patrón de +/// : usa la variante en memoria cuando está registrada (dev/tests) +/// y la variante PostgreSQL en el entorno desplegado. +/// +public static class IgaDevDataSeeder +{ + // Nivel de madurez inicial del estado ELEGIBLE (Junior exige 6 meses en nivel, INV-RMS3). + private const decimal EligiblePerformanceScore = 4.5m; + private const decimal BorderlinePerformanceScore = 3.5m; + + public static async Task SeedAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default) + { + var maturityRepository = serviceProvider.GetService(); + var inMemoryMaturityRepository = serviceProvider.GetService(); + + var actor = ActorId.Create(CoreDevDataSeeder.SystemActorId); + var tenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.RansaTenantId)); + var nowUtc = DateTime.UtcNow; + + // Estado ELEGIBLE: objetivo = gerente de operaciones RANSA en su rol actual ADMIN. + var eligible = BuildMaturityStatus( + tenantId, + userId: UserId.Load(Guid.Parse(CoreDevDataSeeder.RansaAdminUserId)), + roleId: RoleId.Load(Guid.Parse(CoreDevDataSeeder.DemoAdminRoleId)), + level: RoleMaturityLevel.Junior, + assignedAtUtc: nowUtc.AddYears(-2), // supera con holgura los 6 meses mínimos del salto Junior + performanceScore: EligiblePerformanceScore, + actor); + + // Estado NO ELEGIBLE (borde): objetivo = analista de inventario RANSA en rol OPERATOR, + // recién ingresado al nivel ⇒ no supera el tiempo mínimo ⇒ elegibilidad rechazada (fail-closed). + var borderline = BuildMaturityStatus( + tenantId, + userId: UserId.Load(Guid.Parse(CoreDevDataSeeder.RansaAnalystUserId)), + roleId: RoleId.Load(Guid.Parse(CoreDevDataSeeder.DemoOperatorRoleId)), + level: RoleMaturityLevel.Junior, + assignedAtUtc: nowUtc.AddDays(-10), // insuficiente tiempo en nivel + performanceScore: BorderlinePerformanceScore, + actor); + + var seedItems = new[] { eligible, borderline }.Where(x => x is not null).Select(x => x!).ToList(); + if (seedItems.Count == 0) + { + return; + } + + if (inMemoryMaturityRepository is not null) + { + foreach (var item in seedItems) + { + inMemoryMaturityRepository.Seed(item); + } + + return; + } + + if (maturityRepository is null) + { + return; + } + + // PostgreSQL: idempotente por usuario+rol (no recrea si ya existe). + var added = false; + foreach (var item in seedItems) + { + var existing = await maturityRepository.GetByUserAndRoleAsync( + item.TenantId.GetValue(), + item.UserId.GetValue(), + item.RoleId.GetValue(), + cancellationToken); + + if (existing is null) + { + await maturityRepository.AddAsync(item, cancellationToken); + added = true; + } + } + + if (added) + { + await maturityRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + } + + private static RoleMaturityStatusAggregate? BuildMaturityStatus( + TenantId tenantId, + UserId userId, + RoleId roleId, + RoleMaturityLevel level, + DateTime assignedAtUtc, + decimal performanceScore, + ActorId actor) + { + var result = RoleMaturityStatusAggregate.Create(tenantId, userId, roleId, level, assignedAtUtc, actor); + if (result.IsFailure) + { + return null; + } + + var status = result.Value; + status.UpdatePerformanceScore(performanceScore, actor); + return status; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs index af63462c..96428890 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs @@ -20,17 +20,30 @@ public static class ParameterCatalogSeeder Guid.Parse("F3E2D1C0-B9A8-7F6E-5D4C-321098765432"), ]; + // FS-25 §4.5 — parámetros de operación impo/expo acotados al tenant BEYONDNET. + private static readonly Guid BeyondNetTenantId = Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId); + private const string IncotermCode = "INCOTERM"; + private const string MonedaCode = "MONEDA"; + private const string TipoDuaCode = "TIPO_DUA"; + public static async Task SeedAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { var dbContext = serviceProvider.GetRequiredService(); await SeedDefinitionsAsync(dbContext, cancellationToken); await SeedGlobalValuesAsync(dbContext, cancellationToken); await SeedTenantValuesAsync(dbContext, cancellationToken); + await SeedBeyondNetOperationValuesAsync(dbContext, cancellationToken); } private static async Task SeedDefinitionsAsync(UmsPlatformDbContext dbContext, CancellationToken cancellationToken = default) { + // IgnoreQueryFilters, y ahora por una razón distinta a la original: el índice único de `Code` + // pasó a ser parcial, así que reinsertar el código de una definición eliminada YA NO chocaría + // contra el índice. Se mantiene mirando también las lápidas para no RESUCITAR lo que alguien + // retiró a propósito: el sembrado garantiza que el catálogo base existe, no que reaparezca + // cada arranque lo que se decidió quitar. Volver a declararla es un acto deliberado del alta. var existingCodes = (await dbContext.ParameterDefinitions + .IgnoreQueryFilters() .Select(definition => definition.Code) .ToListAsync(cancellationToken)) .ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -50,6 +63,8 @@ private static async Task SeedDefinitionsAsync(UmsPlatformDbContext dbContext, C public static async Task SeedGlobalValuesAsync(UmsPlatformDbContext dbContext, CancellationToken cancellationToken = default) { + // Cuenta también los valores eliminados lógicamente, a propósito: liberar la ranura permite + // volver a fijar el valor global, pero eso es un acto del operador, no del sembrado. var existingDefinitionIds = await dbContext.ParameterGlobalValues .Select(value => value.ParameterDefinitionId) .ToHashSetAsync(cancellationToken); @@ -127,6 +142,67 @@ public static async Task SeedTenantValuesAsync(UmsPlatformDbContext dbContext, C await dbContext.SaveChangesAsync(cancellationToken); } + // FS-25 §4.5 / criterio 11: valores de operación impo/expo acotados al tenant BEYONDNET. + private static async Task SeedBeyondNetOperationValuesAsync(UmsPlatformDbContext dbContext, CancellationToken cancellationToken = default) + { + var definitionsByCode = (await dbContext.ParameterDefinitions.ToListAsync(cancellationToken)) + .ToDictionary(definition => definition.Code, StringComparer.OrdinalIgnoreCase); + + var existingDefinitionIds = (await dbContext.ParameterTenantValues + .Where(value => value.TenantId == BeyondNetTenantId) + .Select(value => value.ParameterDefinitionId) + .ToListAsync(cancellationToken)) + .ToHashSet(); + + var now = DateTime.UtcNow; + var systemActorId = "SYSTEM"; + var tenantValues = new List(); + + foreach (var seedValue in BuildBeyondNetOperationValues()) + { + if (!definitionsByCode.TryGetValue(seedValue.Code, out var definition)) + { + continue; + } + + if (existingDefinitionIds.Contains(definition.Id)) + { + continue; + } + + tenantValues.Add(new ParameterTenantValueRecord + { + Id = Guid.NewGuid(), + TenantId = BeyondNetTenantId, + ParameterDefinitionId = definition.Id, + OverrideValue = seedValue.Value, + StatusId = 2, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") + }); + } + + if (tenantValues.Count == 0) + { + return; + } + + await dbContext.ParameterTenantValues.AddRangeAsync(tenantValues, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static IReadOnlyList<(string Code, string Value)> BuildBeyondNetOperationValues() + { + return + [ + (IncotermCode, "FOB,CIF,EXW,FCA,CFR,CPT"), + (MonedaCode, "USD,PEN"), + (TipoDuaCode, "Importación Definitiva,Exportación Definitiva,Admisión Temporal"), + ]; + } + private static IReadOnlyList BuildDefinitions() { var now = DateTime.UtcNow; @@ -169,6 +245,24 @@ private static IReadOnlyList BuildDefinitions() AuditTimeSpan = now.ToString("O") }, new ParameterDefinitionRecord + { + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos (junto a MAX_LOGIN_ATTEMPTS). + Id = Guid.Parse("11111111-1111-1111-1111-111111111117"), + Code = AppConfigurationCodes.AccountLockoutDurationMinutes, + Name = "Account Lockout Duration Minutes", + Description = "Temporary account lockout duration in minutes after reaching the max login attempts", + DataTypeId = 2, + DefaultValue = AppConfigurationDefaults.AccountLockoutDurationMinutes.ToString(), + ScopeId = 3, + IsActive = true, + IsMandatory = false, + DisplayOrder = 15, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") + }, + new ParameterDefinitionRecord { Id = Guid.Parse("11111111-1111-1111-1111-111111111103"), Code = AppConfigurationCodes.AccessTokenDurationMs, @@ -275,7 +369,7 @@ private static IReadOnlyList BuildDefinitions() Id = Guid.Parse("11111111-1111-1111-1111-111111111108"), Code = AppConfigurationCodes.FrontendConfigTransport, Name = "Frontend Config Transport", - Description = "Transport mode for frontend config: graphql or rest", + Description = "Transport mode for frontend config queries (REST only)", DataTypeId = 1, DefaultValue = AppConfigurationDefaults.FrontendConfigTransport, ScopeId = 1, @@ -320,6 +414,57 @@ private static IReadOnlyList BuildDefinitions() CreatedBy = systemActorId, CreatedAtUtc = now, AuditTimeSpan = now.ToString("O") + }, + new ParameterDefinitionRecord + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111120"), + Code = IncotermCode, + Name = "Incoterm", + Description = "Términos de comercio internacional (Incoterms) aplicables a las operaciones de impo/expo", + DataTypeId = 1, + DefaultValue = "FOB,CIF,EXW,FCA,CFR,CPT", + ScopeId = 2, + IsActive = true, + IsMandatory = false, + DisplayOrder = 12, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") + }, + new ParameterDefinitionRecord + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111121"), + Code = MonedaCode, + Name = "Moneda", + Description = "Monedas admitidas en las operaciones de impo/expo", + DataTypeId = 1, + DefaultValue = "USD,PEN", + ScopeId = 2, + IsActive = true, + IsMandatory = false, + DisplayOrder = 13, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") + }, + new ParameterDefinitionRecord + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111122"), + Code = TipoDuaCode, + Name = "Tipo de DUA", + Description = "Tipos de declaración aduanera (DUA) para las operaciones de impo/expo", + DataTypeId = 1, + DefaultValue = "Importación Definitiva,Exportación Definitiva,Admisión Temporal", + ScopeId = 2, + IsActive = true, + IsMandatory = false, + DisplayOrder = 14, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") } ]; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/SqliteSchemaBootstrapper.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/SqliteSchemaBootstrapper.cs deleted file mode 100644 index ae5f5032..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/SqliteSchemaBootstrapper.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Ums.Infrastructure.Persistence; - -using Ums.Infrastructure.Persistence.Seeders; - -public static class SqliteSchemaBootstrapper -{ - public static async Task InitializeAsync(UmsPlatformDbContext dbContext, CancellationToken cancellationToken = default) - { - await dbContext.Database.EnsureCreatedAsync(cancellationToken); - await EnsureTenantManagementOwnerColumnAsync(dbContext, cancellationToken); - await EnsureInternalAdminTenantManagementOwnerAsync(dbContext, cancellationToken); - - await dbContext.Database.ExecuteSqlRawAsync( - """ - CREATE TABLE IF NOT EXISTS "Roles" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_Roles" PRIMARY KEY, - "TenantId" TEXT NOT NULL, - "SystemSuiteId" TEXT NOT NULL, - "ParentRoleId" TEXT NULL, - "Code" TEXT NOT NULL, - "Value" TEXT NOT NULL, - "Description" TEXT NOT NULL, - "HierarchyLevel" INTEGER NOT NULL, - "PromotionOrder" INTEGER NOT NULL, - "IsActive" INTEGER NOT NULL, - "CreatedBy" TEXT NOT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "UpdatedBy" TEXT NULL, - "UpdatedAtUtc" TEXT NULL, - "AuditTimeSpan" TEXT NOT NULL, - "RowVersion" BLOB NOT NULL, - CONSTRAINT "FK_Roles_SystemSuites_SystemSuiteId" - FOREIGN KEY ("SystemSuiteId") REFERENCES "SystemSuites" ("Id") ON DELETE RESTRICT, - CONSTRAINT "FK_Roles_Roles_ParentRoleId" - FOREIGN KEY ("ParentRoleId") REFERENCES "Roles" ("Id") ON DELETE RESTRICT - ); - """, - cancellationToken); - - await dbContext.Database.ExecuteSqlRawAsync( - """CREATE INDEX IF NOT EXISTS "IX_Roles_TenantId" ON "Roles" ("TenantId");""", - cancellationToken); - await dbContext.Database.ExecuteSqlRawAsync( - """CREATE INDEX IF NOT EXISTS "IX_Roles_ParentRoleId" ON "Roles" ("ParentRoleId");""", - cancellationToken); - await dbContext.Database.ExecuteSqlRawAsync( - """CREATE INDEX IF NOT EXISTS "IX_Roles_SystemSuiteId" ON "Roles" ("SystemSuiteId");""", - cancellationToken); - await dbContext.Database.ExecuteSqlRawAsync( - """CREATE UNIQUE INDEX IF NOT EXISTS "IX_Roles_SystemSuiteId_Code" ON "Roles" ("SystemSuiteId", "Code");""", - cancellationToken); - } - - private static async Task EnsureTenantManagementOwnerColumnAsync( - UmsPlatformDbContext dbContext, - CancellationToken cancellationToken) - { - var connection = dbContext.Database.GetDbConnection(); - var shouldClose = connection.State != System.Data.ConnectionState.Open; - - if (shouldClose) - { - await connection.OpenAsync(cancellationToken); - } - - try - { - await using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT 1 - FROM pragma_table_info('Tenants') - WHERE name = 'IsManagementOwner' - LIMIT 1; - """; - - var exists = await command.ExecuteScalarAsync(cancellationToken); - if (exists is not null) - { - return; - } - - await dbContext.Database.ExecuteSqlRawAsync( - """ - ALTER TABLE "Tenants" - ADD COLUMN "IsManagementOwner" INTEGER NOT NULL DEFAULT 0; - """, - cancellationToken); - } - finally - { - if (shouldClose) - { - await connection.CloseAsync(); - } - } - } - - private static async Task EnsureInternalAdminTenantManagementOwnerAsync( - UmsPlatformDbContext dbContext, - CancellationToken cancellationToken) - { - if (!dbContext.Database.IsSqlite()) - { - return; - } - - await dbContext.Database.ExecuteSqlRawAsync( - $""" - UPDATE "Tenants" - SET "IsManagementOwner" = 1 - WHERE upper("Code") = '{CoreDevDataSeeder.InternalAdminTenantCode}' - AND COALESCE("IsManagementOwner", 0) = 0; - """, - cancellationToken); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs index c5fefa88..d25d3427 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; using MassTransit; +using MediatR; +using Microsoft.Extensions.Logging; using Ums.Infrastructure.Persistence.Audit.Configurations; using Ums.Infrastructure.Persistence.Audit.Entities; using Ums.Infrastructure.Persistence.Authorization.Configurations; @@ -12,6 +14,9 @@ using Ums.Infrastructure.Persistence.Approvals.Configurations; using Ums.Infrastructure.Persistence.Approvals.Entities; +using Ums.Infrastructure.Persistence.Iga.Configurations; +using Ums.Infrastructure.Persistence.Iga.Entities; + namespace Ums.Infrastructure.Persistence; /// @@ -28,31 +33,115 @@ namespace Ums.Infrastructure.Persistence; /// - OrganizationId.HasValue → strict per-tenant rows only. /// - is nullable-TenantId → also includes global /// records (TenantId IS NULL) so system-level config is always visible. -/// -/// The SQL Server RLS predicates set via -/// remain as the database-level failsafe. +/// +/// Tenant isolation is enforced entirely by these application-layer query filters under the +/// PostgreSQL-only persistence model. /// public sealed class UmsPlatformDbContext( DbContextOptions options, ITenantContext tenantContext, - IPublishEndpoint publishEndpoint) : DbContext(options) + IPublisher domainEventDispatcher, + ILogger logger) : DbContext(options) { public const string DefaultSchema = "ums_platform"; - public IPublishEndpoint PublishEndpoint { get; } = publishEndpoint; + // G-246: identificador del estado terminal `SystemStatus.Deleted`, para el filtro global que + // oculta los sistemas eliminados lógicamente. Se lee del propio enumerado del dominio —no se + // escribe el 4 a mano— para que no puedan divergir; se materializa en un campo porque la + // expresión del filtro debe quedar traducible a SQL, no invocar una cadena de propiedades. + private static readonly int DeletedSystemSuiteStatusId = Ums.Domain.Enums.SystemStatus.Deleted.Id; + + /// Nombre del filtro que oculta los sistemas eliminados lógicamente (G-246). + public const string SystemSuiteSoftDeleteFilter = "SystemSuiteSoftDelete"; + + /// Nombre del filtro de aislamiento por inquilino de los sistemas. + public const string SystemSuiteTenantFilter = "SystemSuiteTenant"; + + // G-066 / ADR-0098 D4/D7 + KB-TXN-001: los eventos de DOMINIO se manejan EN PROCESO. Se + // recolectan aquí durante SaveEntitiesAsync y se despachan por MediatR DESPUÉS del commit del + // agregado (en el override de SaveChangesAsync). NO se publican crudos al bróker inter-sistema + // (D7/D9.6): eso convertiría el modelo interno en contrato de transporte. Solo los eventos de + // INTEGRACIÓN explícitos (IIntegrationEvent, contrato aparte) salen al bróker por el outbox + // (IIntegrationEventPublisher). + private readonly List _pendingDomainEvents = []; + private bool _isDispatchingDomainEvents; + + /// + /// Recolecta los eventos de dominio no confirmados de un agregado para despacharlos EN PROCESO + /// tras el commit (ADR-0098 D4.2). No publica al bróker (D7/D9.6). Los repositorios llaman a + /// este método ANTES de ; el despacho + /// real ocurre en el override de SaveChangesAsync, una vez confirmada la transacción del + /// agregado. + /// + public Task PublishDomainEventsAsync(IEnumerable domainEvents, CancellationToken cancellationToken = default) + { + _pendingDomainEvents.AddRange(domainEvents); + return Task.CompletedTask; + } + + public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false); + await DispatchDomainEventsAsync(cancellationToken).ConfigureAwait(false); + return result; + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + var result = base.SaveChanges(acceptAllChangesOnSuccess); + DispatchDomainEventsAsync(CancellationToken.None).GetAwaiter().GetResult(); + return result; + } - public async Task PublishDomainEventsAsync(IEnumerable domainEvents, CancellationToken cancellationToken = default) + /// + /// Despacha en proceso, tras el commit, los eventos de dominio recolectados (ADR-0098 D4). Un + /// fallo de un manejador en proceso NO revierte el agregado ya confirmado ni tumba el caso de + /// uso: es un handoff post-commit reconstruible/best-effort (D4/D6), así que se registra como + /// advertencia. Los efectos durables e irreparables (auditoría, no-repudiación) NO viajan por + /// esta vía: usan el outbox (G-040). + /// + /// TODO(D-016): para los casos de uso multi-agregado con [TransactionAspect] (excepciones de + /// consistencia inmediata gobernadas por D-016), este despacho ocurre en el SaveChanges interno, + /// antes del commit de la transacción externa. Revisar al separar esos agregados (ADR-0098 D2). + /// + private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken) { - foreach (var domainEvent in domainEvents) + // Guarda de reentrada: un manejador en proceso puede provocar otro SaveChanges sobre este + // mismo contexto; no re-despachar dentro de un despacho en curso. + if (_isDispatchingDomainEvents || _pendingDomainEvents.Count == 0) + return; + + _isDispatchingDomainEvents = true; + try { - await PublishEndpoint.Publish(domainEvent, domainEvent.GetType(), cancellationToken); + var toDispatch = _pendingDomainEvents.ToArray(); + _pendingDomainEvents.Clear(); + + foreach (var domainEvent in toDispatch) + { + try + { + await domainEventDispatcher.Publish(domainEvent, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Handoff en proceso fallido para el evento de dominio {DomainEvent} (post-commit, ADR-0098 D4). El agregado ya está confirmado; el efecto es reconstruible.", + domainEvent.GetType().Name); + } + } + } + finally + { + _isDispatchingDomainEvents = false; } } public DbSet Tenants => Set(); public DbSet TenantBranches => Set(); + /// ADR-0164: bitácora de episodios de sucursal (apertura, baja, reapertura, cierre). + public DbSet TenantBranchLifecycleEntries => Set(); public DbSet TenantIdentityProviders => Set(); - public DbSet TenantBrandings => Set(); public DbSet TenantParameters => Set(); public DbSet TenantSignupRequests => Set(); public DbSet UserAccounts => Set(); @@ -62,9 +151,8 @@ public async Task PublishDomainEventsAsync(IEnumerable ProfilePermissions => Set(); public DbSet SystemSuites => Set(); public DbSet SystemSuiteModules => Set(); - public DbSet SystemSuiteMenus => Set(); - public DbSet SystemSuiteSubMenus => Set(); - public DbSet SystemSuiteOptions => Set(); + public DbSet SystemSuiteNodes => Set(); + public DbSet SystemSuiteNodeActions => Set(); public DbSet SystemSuiteAppSettings => Set(); public DbSet SystemSuiteActions => Set(); public DbSet SystemSuiteDomainResources => Set(); @@ -90,6 +178,10 @@ public async Task PublishDomainEventsAsync(IEnumerable UserDocuments => Set(); public DbSet UserDocumentNotifications => Set(); public DbSet AccessEnforcementPolicies => Set(); + public DbSet RefreshTokens => Set(); + public DbSet PasswordResetTokens => Set(); + public DbSet RoleMaturityStatuses => Set(); + public DbSet RolePromotionRequests => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -109,20 +201,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new TenantRecordConfiguration()); modelBuilder.ApplyConfiguration(new TenantBranchRecordConfiguration()); + modelBuilder.ApplyConfiguration(new TenantBranchLifecycleEntryRecordConfiguration()); modelBuilder.ApplyConfiguration(new TenantIdentityProviderRecordConfiguration()); - modelBuilder.ApplyConfiguration(new TenantBrandingRecordConfiguration()); modelBuilder.ApplyConfiguration(new TenantParameterRecordConfiguration()); modelBuilder.ApplyConfiguration(new TenantSignupRequestRecordConfiguration()); modelBuilder.ApplyConfiguration(new UserAccountRecordConfiguration()); modelBuilder.ApplyConfiguration(new UserAccountMfaEnrollmentRecordConfiguration()); modelBuilder.ApplyConfiguration(new UserAccountPasswordCredentialRecordConfiguration()); + modelBuilder.ApplyConfiguration(new RefreshTokenRecordConfiguration()); + modelBuilder.ApplyConfiguration(new PasswordResetTokenRecordConfiguration()); modelBuilder.ApplyConfiguration(new ProfileRecordConfiguration()); modelBuilder.ApplyConfiguration(new ProfilePermissionRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteModuleRecordConfiguration()); - modelBuilder.ApplyConfiguration(new SystemSuiteMenuRecordConfiguration()); - modelBuilder.ApplyConfiguration(new SystemSuiteSubMenuRecordConfiguration()); - modelBuilder.ApplyConfiguration(new SystemSuiteOptionRecordConfiguration()); + modelBuilder.ApplyConfiguration(new SystemSuiteNodeRecordConfiguration()); + modelBuilder.ApplyConfiguration(new SystemSuiteNodeActionRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteAppSettingRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteActionRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteDomainResourceRecordConfiguration()); @@ -147,6 +240,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new UserDocumentRecordConfiguration()); modelBuilder.ApplyConfiguration(new AccessNotificationRecordConfiguration()); modelBuilder.ApplyConfiguration(new AccessEnforcementPolicyRecordConfiguration()); + modelBuilder.ApplyConfiguration(new RoleMaturityStatusRecordConfiguration()); + modelBuilder.ApplyConfiguration(new RolePromotionRequestRecordConfiguration()); + + // G-066 / ADR-0098 D4: MassTransit transactional bus-outbox for the WRITE context. + // Domain events are staged into the outbox tables and committed atomically with the + // aggregate change set; delivery to the broker happens AFTER commit (no «phantom + // messages»). Mirrors the pattern already used by TenantProjectionDbContext. + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); // ------------------------------------------------------------------------- // FIX-05: Global query filters — primary tenant isolation mechanism. // @@ -161,20 +264,30 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasQueryFilter(x => !x.IsDeleted); + // ADR-0164: la sucursal CERRADA no lleva filtro global de ocultación, y es deliberado. La + // resolución por id tiene que seguir devolviéndola —el perfil de un usuario apunta a la + // sucursal desde la que operó, y el grafo de autorización la resuelve por ese id— y la vía + // de escritura necesita verla para que su código siga ocupado (§2.3). Quien LISTA para un + // humano filtra en su consulta; ver `GetBranchesByTenantIdQueryHandler`. Un filtro global + // aquí habría convertido el borrado lógico en una desaparición igual de opaca que el físico. modelBuilder.Entity() .HasQueryFilter(x => !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); - modelBuilder.Entity() + modelBuilder.Entity() .HasQueryFilter(x => !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); + // El borrado de parámetros es LÓGICO: el filtro de soft-delete se combina con el de inquilino + // para que ninguna lectura —repositorio, provider o resolución de configuración— vea una fila + // eliminada. Quien necesite el histórico debe pedirlo explícitamente con IgnoreQueryFilters(). modelBuilder.Entity() .HasQueryFilter(x => - !tenantContext.OrganizationId.HasValue || - x.TenantId == tenantContext.OrganizationId); + !x.IsDeleted && + (!tenantContext.OrganizationId.HasValue || + x.TenantId == tenantContext.OrganizationId)); modelBuilder.Entity() .HasQueryFilter(x => @@ -197,8 +310,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); + // G-246: un sistema eliminado lógicamente (StatusId = SystemStatus.Deleted) sigue en la tabla + // —el catálogo se consulta sobre datos antiguos— pero no debe aparecer en ninguna lectura: el + // GET devuelve 404 y el listado no lo trae. El filtro va aquí, y no repartido por consulta, + // para que ninguna consulta futura pueda olvidarse de él; misma decisión que ya se tomó con + // `UserAccountRecord.IsDeleted`. La escritura no se ve afectada: los filtros solo aplican a + // consultas, y el UPDATE que marca el borrado sale de la entidad ya rastreada. + // + // Van como filtros CON NOMBRE (EF Core 10) y no como una sola expresión porque hay una + // consulta —y solo una— que necesita apagar el de borrado sin apagar el de inquilino: la + // comprobación de código duplicado del alta, que debe ver también las lápidas porque el + // índice único de la base las sigue viendo. Con un filtro único, `IgnoreQueryFilters()` los + // apagaría los dos y abriría el catálogo de todos los inquilinos. modelBuilder.Entity() - .HasQueryFilter(x => + .HasQueryFilter(SystemSuiteSoftDeleteFilter, x => x.StatusId != DeletedSystemSuiteStatusId) + .HasQueryFilter(SystemSuiteTenantFilter, x => !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); @@ -226,6 +352,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) x.TenantId == null || x.TenantId == tenantContext.OrganizationId); + // ParameterDefinition: catálogo global (no acotado por inquilino) con borrado LÓGICO. + // El filtro es el único punto donde se garantiza que una definición eliminada desaparece de + // TODAS las lecturas —incluidos los endpoints de consulta que van directos al DbContext—. + // Los caminos de escritura que necesitan ver la fila física (unicidad de `Code`) usan + // IgnoreQueryFilters() de forma explícita. + modelBuilder.Entity() + .HasQueryFilter(x => !x.IsDeleted); + // ParameterTenantValue: tenant-specific parameter overrides modelBuilder.Entity() .HasQueryFilter(x => @@ -254,20 +388,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); - if (Database.IsSqlite()) - { - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) - { - var rowVersionProperty = entityType.FindProperty("RowVersion"); - if (rowVersionProperty != null && rowVersionProperty.ClrType == typeof(byte[])) - { - rowVersionProperty.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; - rowVersionProperty.IsConcurrencyToken = false; - rowVersionProperty.SetBeforeSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Save); - rowVersionProperty.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Save); - } - } - } + // IGA (ADR-UMS-093): tablas acotadas por inquilino — la elegibilidad y las promociones nunca + // cruzan fronteras de inquilino. + modelBuilder.Entity() + .HasQueryFilter(x => + !tenantContext.OrganizationId.HasValue || + x.TenantId == tenantContext.OrganizationId); + + modelBuilder.Entity() + .HasQueryFilter(x => + !tenantContext.OrganizationId.HasValue || + x.TenantId == tenantContext.OrganizationId); if (Database.IsNpgsql()) { @@ -290,7 +421,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } // ADR-0076 D1: Force DateTimeKind.Utc on all DateTime properties read from the - // database. EF Core / SQLite does not preserve timezone info, so values are stored + // database. EF Core does not preserve timezone info, so values are stored // as ISO-8601 strings. Without this converter, DateTime.Kind == Unspecified after // materialisation, which breaks serialisation and comparisons with DateTime.UtcNow. var utcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContextFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContextFactory.cs index 54e9bcfd..26f5f4f9 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContextFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContextFactory.cs @@ -1,7 +1,9 @@ +#pragma warning disable S1144 using System; -using MassTransit; +using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Logging.Abstractions; using BeyondNetCode.Shell.Ddd.Interfaces; using Ums.Domain.Identity; @@ -12,14 +14,24 @@ public class UmsPlatformDbContextFactory : IDesignTimeDbContextFactory(); - + // This is strictly used for EF Core tool generation at design time. +#pragma warning disable S2068 // Cadena de conexión de DISEÑO (EF Core tooling), solo local; el runtime resuelve por configuración. No es un secreto de producción. optionsBuilder.UseNpgsql("Host=localhost;Database=ums_test;Username=postgres;Password=postgres"); +#pragma warning restore S2068 - return new UmsPlatformDbContext(optionsBuilder.Options, new DesignTimeTenantContext(), new DesignTimePublishEndpoint()); + return new UmsPlatformDbContext( + optionsBuilder.Options, + new DesignTimeTenantContext(), + new DesignTimePublisher(), + NullLogger.Instance); } - private class DesignTimeTenantContext : ITenantContext + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", + Justification = "Stub de solo diseño (EF Core tooling). Sus miembros replican la forma de " + + "ITenantContext y deben permanecer de instancia para sustituir al contrato en runtime.")] + private sealed class DesignTimeTenantContext : ITenantContext { public Guid? OrganizationId => null; public Guid? UserId => null; @@ -32,25 +44,18 @@ private class DesignTimeTenantContext : ITenantContext public Guid? OriginalTenantId => null; public bool IsInternalAdmin => false; - public void SetOrganizationId(Guid tenantId) { } + public void SetOrganizationId(Guid organizationId) { } public void EnableCrossTenantAccess() { } public void DisableCrossTenantAccess() { } - public void Initialize(Guid tenantId, bool isInternalAdmin) { } + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } } - private class DesignTimePublishEndpoint : IPublishEndpoint + private sealed class DesignTimePublisher : IPublisher { - public Task Publish(T message, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(T message, IPipe> publishPipe, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(T message, IPipe publishPipe, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(object message, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task Publish(object message, Type messageType, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task Publish(object message, IPipe publishPipe, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task Publish(object message, Type messageType, IPipe publishPipe, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task Publish(object values, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(object values, IPipe> publishPipe, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(object values, IPipe publishPipe, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - - public ConnectHandle ConnectPublishObserver(IPublishObserver observer) => throw new NotImplementedException(); + public Task Publish(object notification, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification => Task.CompletedTask; } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/UnitOfWorkScope.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/UnitOfWorkScope.cs index 12498b3e..05ba3222 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/UnitOfWorkScope.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/UnitOfWorkScope.cs @@ -15,6 +15,20 @@ public async Task BeginAsync(CancellationToken cancellationTo return new EfTransactionScope(transaction); } + // G-117: begin+commit dentro de la ExecutionStrategy del proveedor. Con EnableRetryOnFailure una + // transacción iniciada a mano rompe ('does not support user-initiated transactions'); aquí la + // estrategia posee el ciclo y reintenta todo el bloque (rollback previo) ante fallos transitorios. + public async Task ExecuteInTransactionAsync(Func operation, CancellationToken cancellationToken = default) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await operation(cancellationToken); + await transaction.CommitAsync(cancellationToken); + }); + } + private sealed class EfTransactionScope(IDbContextTransaction transaction) : ITransactionScope { private bool _completed; @@ -52,6 +66,10 @@ public sealed class NoOpUnitOfWorkScope : IUnitOfWorkScope public Task BeginAsync(CancellationToken cancellationToken = default) => Task.FromResult(new NoOpTransactionScope()); + // InMemory: sin transacciones ni estrategia de reintentos; solo ejecuta la operación. + public Task ExecuteInTransactionAsync(Func operation, CancellationToken cancellationToken = default) + => operation(cancellationToken); + private sealed class NoOpTransactionScope : ITransactionScope { public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesDistribuido.cs b/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesDistribuido.cs new file mode 100644 index 00000000..104b2cec --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesDistribuido.cs @@ -0,0 +1,64 @@ +using StackExchange.Redis; + +namespace Ums.Infrastructure.Services; + +/// +/// G-248 — el cupo se cuenta en Redis, así que lo comparten todas las réplicas. +/// +/// El contador es un INCR sobre una clave que incluye el número de ventana, de modo +/// que cambiar de ventana es empezar a escribir en otra clave: no hace falta reiniciar nada ni +/// borrar nada, y dos réplicas que sirvan la misma petición coinciden en qué ventana es sin +/// hablarse. La clave anterior caduca sola. +/// +/// INCR primero y EXPIRE después, solo cuando el contador vale 1: es la +/// secuencia estándar, y su único riesgo —que el proceso muera entre las dos y deje una clave +/// eterna— está acotado porque la clave lleva el número de ventana y nunca se vuelve a usar. Aun +/// así se pide el vencimiento en el mismo viaje (batch), que reduce la ventana a prácticamente +/// nada. +/// +/// Si Redis no responde, la petición se PERMITE. Es una decisión deliberada: un limitador es +/// una protección contra abuso, no un control de acceso, y convertir una caída de Redis en una +/// caída de UMS cambiaría un problema de capacidad por uno de disponibilidad. Queda registrado en +/// el log para que la degradación no sea invisible. +/// +public sealed class LimitadorDePeticionesDistribuido( + IConnectionMultiplexer redis, + ILogger logger) : ILimitadorDePeticiones +{ + public async Task RegistrarAsync( + string clave, int cupo, TimeSpan ventana, CancellationToken ct = default) + { + var ahora = DateTimeOffset.UtcNow; + var ticksDeVentana = Math.Max(1, ventana.Ticks); + var numeroDeVentana = ahora.UtcTicks / ticksDeVentana; + var restante = TimeSpan.FromTicks(((numeroDeVentana + 1) * ticksDeVentana) - ahora.UtcTicks); + + RedisKey redisKey = $"limite:{clave}:{numeroDeVentana}"; + + try + { + var db = redis.GetDatabase(); + + // Un solo viaje: incrementar y, si es la primera de la ventana, fijarle el vencimiento. + var batch = db.CreateBatch(); + var incremento = batch.StringIncrementAsync(redisKey); + // El margen extra evita que la clave caduque justo antes de que termine su ventana por + // una diferencia de reloj entre el proceso y Redis. + var vencimiento = batch.KeyExpireAsync(redisKey, ventana + TimeSpan.FromSeconds(5), ExpireWhen.HasNoExpiry); + batch.Execute(); + + var consumidas = await incremento.ConfigureAwait(false); + await vencimiento.ConfigureAwait(false); + + return new ResultadoDelLimite(consumidas <= cupo, consumidas, restante); + } + catch (RedisException ex) + { + logger.LogWarning(ex, + "El límite de peticiones no pudo consultarse en Redis; la petición se permite. " + + "El cupo queda sin aplicar mientras dure la incidencia (G-248)."); + + return new ResultadoDelLimite(Permitida: true, Consumidas: 0, EsperaSugerida: restante); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesEnProceso.cs b/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesEnProceso.cs new file mode 100644 index 00000000..5250a483 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesEnProceso.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; + +namespace Ums.Infrastructure.Services; + +/// +/// G-248 — respaldo cuando no hay Redis: cuenta dentro del proceso. +/// +/// Es el comportamiento anterior, y solo es correcto con UNA réplica. Existe para que el +/// desarrollo local y las pruebas no exijan un Redis, no como modo de despliegue: el arranque ya +/// declara en el log si el estado compartido quedó distribuido o en memoria, y esta clase cae del +/// lado «en memoria». +/// +/// Las claves de ventanas pasadas se retiran al vuelo, cuando se toca esa misma clave, y no +/// con una tarea de limpieza: sin Redis no hay vencimiento automático, y un diccionario que solo +/// crece es una fuga con nombre de caché. +/// +public sealed class LimitadorDePeticionesEnProceso : ILimitadorDePeticiones +{ + private readonly ConcurrentDictionary _conteos = new(); + + private sealed record Conteo(long Ventana, long Consumidas); + + public Task RegistrarAsync( + string clave, int cupo, TimeSpan ventana, CancellationToken ct = default) + { + var ahora = DateTimeOffset.UtcNow; + var ticksDeVentana = Math.Max(1, ventana.Ticks); + var numeroDeVentana = ahora.UtcTicks / ticksDeVentana; + var restante = TimeSpan.FromTicks(((numeroDeVentana + 1) * ticksDeVentana) - ahora.UtcTicks); + + var actualizado = _conteos.AddOrUpdate( + clave, + _ => new Conteo(numeroDeVentana, 1), + // Ventana distinta ⇒ el conteo anterior ya no cuenta: se reemplaza en vez de sumarse. + (_, previo) => previo.Ventana == numeroDeVentana + ? previo with { Consumidas = previo.Consumidas + 1 } + : new Conteo(numeroDeVentana, 1)); + + return Task.FromResult(new ResultadoDelLimite( + Permitida: actualizado.Consumidas <= cupo, + Consumidas: actualizado.Consumidas, + EsperaSugerida: restante)); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/RequestContextAccessor.cs b/src/apps/ums.api/Ums.Infrastructure/Services/RequestContextAccessor.cs deleted file mode 100644 index 926f0fa0..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Services/RequestContextAccessor.cs +++ /dev/null @@ -1,30 +0,0 @@ - -namespace Ums.Infrastructure.Services; - -/// -/// Scoped execution context for the current request. It is populated by middleware and can -/// be reused by loggers, handlers, background dispatch handoff, and observability adapters. -/// -public sealed class RequestContextAccessor : IRequestContext, IExecutionContextAccessor -{ - private ExecutionContextSnapshot _current = ExecutionContextSnapshot.Empty; - - public string? SessionTrackingId => string.IsNullOrWhiteSpace(_current.SessionTrackingId) ? null : _current.SessionTrackingId; - - public string? CorrelationId => string.IsNullOrWhiteSpace(_current.CorrelationId) ? null : _current.CorrelationId; - - public string? TraceId => string.IsNullOrWhiteSpace(_current.TraceId) ? null : _current.TraceId; - - public string? SpanId => string.IsNullOrWhiteSpace(_current.SpanId) ? null : _current.SpanId; - - private string? _clientTimezone; - public string? ClientTimezone => _clientTimezone; - public void SetClientTimezone(string? timezone) => _clientTimezone = timezone; - - public ExecutionContextSnapshot Current => _current; - - public void Set(ExecutionContextSnapshot snapshot) - { - _current = snapshot ?? ExecutionContextSnapshot.Empty; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/SessionRevocationStore.cs b/src/apps/ums.api/Ums.Infrastructure/Services/SessionRevocationStore.cs new file mode 100644 index 00000000..8e772cb2 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Services/SessionRevocationStore.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Caching.Distributed; + +namespace Ums.Infrastructure.Services; + +/// +/// G-247 — sesiones cerradas, sobre IDistributedCache. +/// +/// Una sola implementación, y no un par Redis/InMemory como en +/// : IDistributedCache ya es Redis cuando hay +/// Redis configurado y memoria del proceso cuando no lo hay, así que duplicar la clase solo +/// duplicaría el sitio donde equivocarse. Lo que cambia entre los dos modos no es el código, es el +/// alcance, y el arranque ya lo declara en el log. +/// +/// Clave: sesion:cerrada:{sid}. El valor guarda el instante de cierre para que quien +/// depure vea CUÁNDO se cerró, no solo que está cerrada. El TTL lo pone el propio almacén, así que +/// no hace falta ninguna tarea de limpieza: la entrada desaparece sola cuando el portador que +/// bloqueaba ya no valdría de todos modos. +/// +public sealed class SessionRevocationStore(IDistributedCache cache) : ISessionRevocationStore +{ + private static string Clave(string sessionId) => $"sesion:cerrada:{sessionId}"; + + /// + public async Task RevocarAsync(string sessionId, DateTime cerrarHastaUtc, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(sessionId)) return; + + // Ya pasó: el portador no puede seguir siendo válido, así que recordarlo no aporta nada. + if (cerrarHastaUtc <= DateTime.UtcNow) return; + + await cache.SetStringAsync( + Clave(sessionId), + DateTime.UtcNow.ToString("O"), + new DistributedCacheEntryOptions { AbsoluteExpiration = cerrarHastaUtc }, + ct); + } + + /// + public async Task EstaRevocadaAsync(string sessionId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(sessionId)) return false; + + return await cache.GetStringAsync(Clave(sessionId), ct) is not null; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/UserContext.cs b/src/apps/ums.api/Ums.Infrastructure/Services/UserContext.cs index f5387f20..ea729cf1 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Services/UserContext.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Services/UserContext.cs @@ -27,13 +27,22 @@ public UserContext(IHttpContextAccessor httpContextAccessor) public bool HasPermission(string permission) { + var user = _httpContextAccessor.HttpContext?.User; + + // ADR-0071 / FS-26: el Admin Root (propietario de gestión, is_internal_admin=true) + // tiene acceso total al sistema, sin límites de permisos. Concede cualquier permiso. + if (string.Equals(user?.FindFirstValue("is_internal_admin"), "true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + // JWT uses 'scope' claim and format 'resource.action' (lowercase) var normalizedPerm = permission.Replace(":", ".").ToLowerInvariant(); - var scopeClaims = _httpContextAccessor.HttpContext?.User?.FindAll("scope") ?? Enumerable.Empty(); - + var scopeClaims = user?.FindAll("scope") ?? Enumerable.Empty(); + // Split space-separated scopes if they are returned as a single string var scopes = scopeClaims.SelectMany(c => c.Value.Split(' ')); - + return scopes.Any(s => s.Equals(normalizedPerm, StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj b/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj index 7fc72289..b82e1786 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj +++ b/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj @@ -10,7 +10,7 @@ - + @@ -35,7 +35,6 @@ - diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Approvals/NotificationRuleRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Approvals/NotificationRuleRestEndpointTests.cs index 040be11f..d7e689e8 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Approvals/NotificationRuleRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Approvals/NotificationRuleRestEndpointTests.cs @@ -26,7 +26,7 @@ public async Task CreateNotificationRule_WithEmailRecipient_ShouldNormalizeRecip { tenantId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId), channel = "Email", - recipient = " Alerts@BeyondNet.Com " + recipient = " Alerts@beyondnet.com.pe " }, TestContext.Current.CancellationToken); createResponse.StatusCode.Should().Be(HttpStatusCode.Created); @@ -38,7 +38,11 @@ public async Task CreateNotificationRule_WithEmailRecipient_ShouldNormalizeRecip getResponse.StatusCode.Should().Be(HttpStatusCode.OK); using var payload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - payload.RootElement.GetProperty("recipient").GetString().Should().Be("alerts@beyondnet.com"); + // El normalizador de email (EmailNotificationRecipientStrategy.Normalize = Trim().ToLowerInvariant()) + // solo recorta y pasa a minúsculas; no reescribe el dominio. La entrada « Alerts@beyondnet.com.pe » + // se canonicaliza a «alerts@beyondnet.com.pe». La expectativa anterior («beyondnet.com») quedó obsoleta + // por el rebranding beyondnet→BeyondNet. + payload.RootElement.GetProperty("recipient").GetString().Should().Be("alerts@beyondnet.com.pe"); payload.RootElement.GetProperty("channel").GetString().Should().Be("Email"); } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailOutboxSinkSanitizationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailOutboxSinkSanitizationTests.cs new file mode 100644 index 00000000..675c066d --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailOutboxSinkSanitizationTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MassTransit; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Ums.Application.Common.Aop; +using Ums.Infrastructure.Aop; + +namespace Ums.Presentation.IntegrationTest.Audit; + +/// +/// G-040 (residual #5, FR-072): el sink de la vía AUTOMÁTICA (AuditTrailOutboxSink) es el único punto +/// por el que pasan todas las emisiones automáticas de la traza (AuditTrailAspect y +/// ConfigurationAuditService). Debe desinfectar la metadata ANTES de encolarla por el outbox, de modo +/// que un secreto no llegue ni a la tabla de salida ni a la traza inmutable (G-081). Prueba unitaria +/// pura (sin Docker). +/// +public sealed class AuditTrailOutboxSinkSanitizationTests +{ + [Fact] + public async Task PublishAsync_WithSecretInMetadata_PublishesSanitizedMetadata() + { + AuditTrailEntry? published = null; + + var publishEndpoint = new Mock(); + publishEndpoint + .Setup(p => p.Publish(It.IsAny(), It.IsAny())) + .Callback((entry, _) => published = entry) + .Returns(Task.CompletedTask); + + // Sin UmsPlatformDbContext registrado → el flush del bus-outbox se omite (modo en memoria). + var serviceProvider = new Mock(); + serviceProvider.Setup(s => s.GetService(It.IsAny())).Returns((object?)null); + + var functionalTransaction = new Mock(); + + var sink = new AuditTrailOutboxSink( + publishEndpoint.Object, + serviceProvider.Object, + NullLogger.Instance, + functionalTransaction.Object); + + var entry = new AuditTrailEntry( + WhoActed: Guid.NewGuid(), + SubjectType: "AppConfiguration", + WhatChanged: "OVERRIDE", + EventType: "OVERRIDE", + AuditResult: "MODIFIED", + AffectedEntityId: Guid.Empty, + AffectedEntityType: "AppConfiguration", + RootTenantId: Guid.NewGuid(), + Metadata: "{\"parameterCode\":\"smtp\",\"secret\":\"p@ssw0rd\",\"apiKey\":\"sk-live-xyz\"}"); + + await sink.PublishAsync(entry, CancellationToken.None); + + published.Should().NotBeNull("el sink debe publicar la traza"); + published!.Metadata.Should().NotContain("p@ssw0rd").And.NotContain("sk-live-xyz"); + published.Metadata.Should().Contain("[REDACTED]"); + // La clave no sensible se conserva; los campos no-metadata no se tocan. + published.Metadata.Should().Contain("smtp"); + published.EventType.Should().Be("OVERRIDE"); + published.AffectedEntityType.Should().Be("AppConfiguration"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailPersistenceAntiTamperTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailPersistenceAntiTamperTests.cs new file mode 100644 index 00000000..0601aef7 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailPersistenceAntiTamperTests.cs @@ -0,0 +1,375 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Ums.Infrastructure.Persistence.Audit; +using Ums.Infrastructure.Persistence.Audit.Entities; +using Ums.Infrastructure.Persistence.Configuration.Entities; +using Ums.Infrastructure.Persistence.Interceptors; +using Ums.Presentation.IntegrationTest.Infrastructure; +using AuditRecordAggregate = Ums.Domain.Audit.AuditRecord.AuditRecord; + +namespace Ums.Presentation.IntegrationTest.Audit; + +/// +/// G-081 — Anti-tamper de la traza de auditoría a NIVEL DE PERSISTENCIA, contra un PostgreSQL real +/// (Testcontainers). Es el punto del gap: la inmutabilidad ya se probaba estructuralmente en el +/// dominio (AuditRecordTests.Record_HasNoMutationMethods_AppendOnly) y la EMISIÓN en +/// AuditTrailCommandTests; faltaba garantizar que la capa de persistencia RECHAZA mutar o +/// borrar una traza ya escrita, y verificar el aislamiento por inquilino (RootTenantId). +/// +/// +/// El host re-registra el +/// sin interceptores (para las pruebas de query-filter), de modo que el guard NO estaría +/// activo por esa vía. Por eso estos tests construyen el contexto directamente contra el contenedor +/// —igual que hace para migrar—, cableando el interceptor +/// real (tal y como lo cablea producción en +/// DependencyInjection). Así se ejercita PostgreSQL real + mapeo EF real + guard real. +/// +/// +[Collection("PostgreSql")] +public sealed class AuditTrailPersistenceAntiTamperTests +{ + private readonly PostgreSqlContainerFixture _fixture; + + public AuditTrailPersistenceAntiTamperTests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + } + + // ── Append OK: una traza se persiste por el flujo real (repo → SaveChanges con el guard) ── + [Fact] + public async Task AppendAuditRecord_ViaRepository_Persists() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenant = Guid.NewGuid(); + var eventType = $"AppendOk_{Guid.NewGuid():N}"; + + var id = await AppendAuditAsync(tenant, eventType, "traza legítima", ct); + + await using var verify = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(verify, new NoTenantContext()); + var persisted = await repo.GetByIdAsync(id, ct); + + persisted.Should().NotBeNull("una traza en estado Added debe persistirse: el guard solo bloquea UPDATE/DELETE"); + persisted!.EventType.Should().Be(eventType); + persisted.RootTenantId.Should().Be(tenant); + persisted.WhatChanged.Should().Be("traza legítima"); + } + + // ── Anti-tamper UPDATE: mutar una traza persistida y guardar → rechazado por el guard ── + [Fact] + public async Task UpdateAuditRecord_ViaEf_IsRejected() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenant = Guid.NewGuid(); + var eventType = $"AntiTamperUpdate_{Guid.NewGuid():N}"; + var id = await AppendAuditAsync(tenant, eventType, "original", ct); + + await using var ctx = CreateGuardedContext(); + var record = await ctx.Set().FirstAsync(x => x.Id == id, ct); + record.WhatChanged = "MANIPULADO"; + + var act = async () => await ctx.SaveChangesAsync(ct); + + await act.Should().ThrowAsync() + .WithMessage("*No repudio (G-081)*") + .WithMessage("*append-only*"); + + // La fila permanece intacta: el SaveChanges se abortó antes de tocar la BD. + await using var verify = CreateGuardedContext(); + var reread = await verify.Set().AsNoTracking().FirstAsync(x => x.Id == id, ct); + reread.WhatChanged.Should().Be("original", "un UPDATE rechazado no debe persistir cambio alguno"); + } + + // ── Anti-tamper DELETE: intentar borrar una traza vía EF → rechazado por el guard ── + [Fact] + public async Task DeleteAuditRecord_ViaEf_IsRejected() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenant = Guid.NewGuid(); + var eventType = $"AntiTamperDelete_{Guid.NewGuid():N}"; + var id = await AppendAuditAsync(tenant, eventType, "no borrable", ct); + + await using var ctx = CreateGuardedContext(); + var record = await ctx.Set().FirstAsync(x => x.Id == id, ct); + ctx.Set().Remove(record); + + var act = async () => await ctx.SaveChangesAsync(ct); + + await act.Should().ThrowAsync() + .WithMessage("*No repudio (G-081)*"); + + // La traza sigue presente: el DELETE fue rechazado. + await using var verify = CreateGuardedContext(); + var stillThere = await verify.Set().AsNoTracking().AnyAsync(x => x.Id == id, ct); + stillThere.Should().BeTrue("un DELETE rechazado no debe borrar la traza"); + } + + // ── Aislamiento por inquilino: consultar como inquilino A no revela las trazas de B ── + // La tabla de auditoría NO está cubierta por RLS (no figura en la migración EnableRowLevelSecurity) + // ni por un global query filter de EF; el aislamiento se apoya en el filtro de aplicación del + // repositorio (parámetro RootTenantId en QueryBy*). Se prueba ahí, donde vive. + [Fact] + public async Task QueryByTenant_DoesNotLeakOtherTenantsAuditRecords() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + var eventType = $"Isolation_{Guid.NewGuid():N}"; // mismo tipo de evento en ambos: filtra el tenant + + var idA = await AppendAuditAsync(tenantA, eventType, "traza-A", ct); + var idB = await AppendAuditAsync(tenantB, eventType, "traza-B", ct); + + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new NoTenantContext()); + var from = DateTime.UtcNow.AddMinutes(-5); + var to = DateTime.UtcNow.AddMinutes(5); + + var forA = await repo.QueryByEventTypeAsync(eventType, tenantA, from, to, ct); + forA.Should().OnlyContain(r => r.RootTenantId == tenantA, "el inquilino A solo debe ver sus propias trazas"); + forA.Select(r => r.GetId().GetValue()).Should().Contain(idA).And.NotContain(idB); + + var forB = await repo.QueryByEventTypeAsync(eventType, tenantB, from, to, ct); + forB.Should().OnlyContain(r => r.RootTenantId == tenantB, "el inquilino B solo debe ver sus propias trazas"); + forB.Select(r => r.GetId().GetValue()).Should().Contain(idB).And.NotContain(idA); + } + + // ── G-103: aislamiento por inquilino en la LECTURA POR ID. GetByIdAsync filtraba SOLO por Id, + // así que un actor podía leer la traza de otro inquilino conociendo su Id (fuga cross-tenant). + // La tabla de auditoría no lleva query filter global (rompería la lectura cross-tenant legítima + // del admin de plataforma y las QueryBy* con inquilino explícito), así que el aislamiento vive + // en el repositorio, igual que las QueryBy*. Aquí se prueba con PostgreSQL real. ── + [Fact] + public async Task GetById_OtherTenantRecord_AsRegularUser_ReturnsNull() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + var eventType = $"CrossTenantRead_{Guid.NewGuid():N}"; + + var idB = await AppendAuditAsync(tenantB, eventType, "traza-de-B", ct); + + // Actor REGULAR del inquilino A intentando leer la traza de B por un Id conocido. + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new FixedTenantContext(tenantA, isInternalAdmin: false)); + + var leaked = await repo.GetByIdAsync(idB, ct); + + leaked.Should().BeNull( + "un actor del inquilino A no debe poder leer la traza de auditoría del inquilino B conociendo su Id (G-103)"); + } + + // ── G-103: la lectura por Id de la PROPIA traza sigue funcionando para un usuario regular. ── + [Fact] + public async Task GetById_OwnTenantRecord_AsRegularUser_ReturnsRecord() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantA = Guid.NewGuid(); + var eventType = $"OwnTenantRead_{Guid.NewGuid():N}"; + + var idA = await AppendAuditAsync(tenantA, eventType, "traza-de-A", ct); + + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new FixedTenantContext(tenantA, isInternalAdmin: false)); + + var own = await repo.GetByIdAsync(idA, ct); + + own.Should().NotBeNull("un actor debe poder leer la traza de su propio inquilino por Id"); + own!.RootTenantId.Should().Be(tenantA); + own.EventType.Should().Be(eventType); + } + + // ── G-103: el admin de plataforma (IsInternalAdmin) conserva la vista cross-tenant de la traza. + // OrganizationId es su propio inquilino (≠ B), pero el bypass legítimo de admin interno —el mismo + // patrón que usa GetAllAuditRecordsQueryHandler— le permite leer la traza de otro inquilino. ── + [Fact] + public async Task GetById_OtherTenantRecord_AsInternalAdmin_ReturnsRecord() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantB = Guid.NewGuid(); + var adminOwnTenant = Guid.NewGuid(); + var eventType = $"AdminCrossTenantRead_{Guid.NewGuid():N}"; + + var idB = await AppendAuditAsync(tenantB, eventType, "traza-de-B", ct); + + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new FixedTenantContext(adminOwnTenant, isInternalAdmin: true)); + + var seen = await repo.GetByIdAsync(idB, ct); + + seen.Should().NotBeNull( + "el admin interno conserva la vista cross-tenant de la traza: no se rompe la consulta de auditoría del admin de plataforma"); + seen!.RootTenantId.Should().Be(tenantB); + } + + // ── No-regresión: el guard es específico de la traza; NO bloquea la modificación (ni el + // estampado) de una entidad normal que SÍ se modifica. Con el guard activo, insertar y luego + // MODIFICAR una entidad no-auditoría debe funcionar y persistir. ── + [Fact] + public async Task ModifyNonAuditEntity_ViaEf_IsAllowedByTheGuard() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var id = Guid.NewGuid(); + var code = $"pd_{Guid.NewGuid():N}"; + + await using (var insert = CreateGuardedContext()) + { + insert.Set().Add(new ParameterDefinitionRecord + { + Id = id, + Code = code, + Name = "original", + Description = "def de no-regresión", + DataTypeId = 1, + DefaultValue = "x", + ScopeId = 1, + IsActive = true, + IsMandatory = false, + DisplayOrder = 0, + Version = "1.0.0", + CreatedBy = "tester", + CreatedAtUtc = DateTime.UtcNow, + AuditTimeSpan = "0", + }); + await insert.SaveChangesAsync(ct); + } + + await using (var update = CreateGuardedContext()) + { + var pd = await update.Set().FirstAsync(x => x.Id == id, ct); + pd.Name = "modificado"; + + var act = async () => await update.SaveChangesAsync(ct); + + await act.Should().NotThrowAsync( + "el guard anti-tamper solo protege la traza de auditoría (AuditRecordRecord); las entidades normales se modifican con normalidad"); + } + + await using var verify = CreateGuardedContext(); + var reread = await verify.Set().AsNoTracking().FirstAsync(x => x.Id == id, ct); + reread.Name.Should().Be("modificado", "una modificación legítima de una entidad no-auditoría debe persistir"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + // Construye un UmsPlatformDbContext contra el contenedor con el guard anti-tamper cableado, + // espejo de cómo AddInfrastructure lo registra en producción (options.AddInterceptors(...)). + private UmsPlatformDbContext CreateGuardedContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_fixture.ConnectionString, sql => sql.EnableRetryOnFailure(3)) + .AddInterceptors(new AuditAppendOnlyGuardInterceptor()) + .Options; + + return new UmsPlatformDbContext( + options, + new NoTenantContext(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + // Persiste una traza por el repositorio real (AppendAsync → SaveChanges con el guard activo). + private async Task AppendAuditAsync(Guid rootTenantId, string eventType, string whatChanged, CancellationToken ct) + { + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new NoTenantContext()); + + var record = AuditRecordAggregate.Record( + whoActed: Guid.NewGuid(), + subjectType: SubjectType.User, + whatChanged: whatChanged, + eventType: eventType, + auditResult: AuditResult.Success, + affectedEntityId: Guid.NewGuid(), + affectedEntityType: "UserAccount", + rootTenantId: rootTenantId, + metadata: null).Value; + + await repo.AppendAsync(record, ct); + await repo.SaveChangesAsync(ct); + + return record.GetId().GetValue(); + } + + // Contexto de inquilino nulo → sin restricción (vista sistema/admin); la tabla de auditoría no + // lleva query filter, así que el aislamiento se prueba por el parámetro RootTenantId del repo. + private sealed class NoTenantContext : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } + + // Contexto de inquilino fijo para las pruebas de aislamiento de GetByIdAsync (G-103): un actor + // ligado a un OrganizationId concreto, admin interno o no. Espeja la semántica de TenantContext. + private sealed class FixedTenantContext : ITenantContext + { + public FixedTenantContext(Guid? organizationId, bool isInternalAdmin) + { + OrganizationId = organizationId; + OriginalTenantId = organizationId; + IsInternalAdmin = isInternalAdmin; + } + + public Guid? OrganizationId { get; } + public Guid? OriginalTenantId { get; } + public bool IsInternalAdmin { get; } + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateItemRetirementTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateItemRetirementTests.cs new file mode 100644 index 00000000..cb9f76ad --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateItemRetirementTests.cs @@ -0,0 +1,278 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Authorization; + +/// +/// ADR-0164 aplicado a los ÍTEMS de una plantilla de permisos. Un ítem es una concesión —«este rol +/// puede hacer esta acción sobre este recurso»—, es decir una DECISIÓN en el sentido del ADR-0162, y +/// por tanto no se borra: se retira. +/// +/// Antes de este cambio PermissionTemplate.RemoveItem hacía _items.Remove(...) y +/// EfChildCollectionReconciler traducía esa ausencia en un DELETE físico. La fila desaparecía +/// y con ella la prueba de que alguien concedió ese permiso. +/// +/// Estas pruebas fijan sobre PostgreSQL real (Testcontainers) las cuatro afirmaciones del ADR: +/// +/// a. El contrato HTTP no cambia: DELETE del ítem sigue devolviendo 204. +/// b. La FILA SIGUE EN LA BASE, marcada como retirada. Se lee con un contexto propio, SIN los +/// filtros de lectura: preguntar a la API no distingue «oculto» de «borrado» (ADR-0164 §5). +/// c. El ciclo retirar/reactivar sigue funcionando y opera sobre la MISMA fila. +/// d. La clave natural del ítem no se libera: el alta con la misma terna responde 409 de dominio +/// legible, no una violación de índice. +/// +[Collection("PostgreSql")] +public sealed class PermissionTemplateItemRetirementTests : IntegrationTestBase +{ + public PermissionTemplateItemRetirementTests(PostgreSqlContainerFixture fixture) : base(fixture) { } + + [Fact] + public async Task RetirarItem_Responde204_YLaFilaSigueEnLaBaseMarcadaComoRetirada() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaAsync(contexto, ct); + var actionId = Guid.NewGuid(); + var itemId = await AnadirItemAsync(templateId, contexto.SystemSuiteId, actionId, ct); + + // (a) El contrato no cambia: 204 No Content, igual que con el borrado físico. + var retirada = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}/items/{itemId}", ct); + retirada.StatusCode.Should().Be(HttpStatusCode.NoContent, await retirada.Content.ReadAsStringAsync(ct)); + + // (b) LA FILA SIGUE EN LA BASE. Es LA prueba: la evidencia viene del almacenamiento, no del + // API, porque el API es precisamente lo que ahora la presenta como retirada. + await using var db = CrearContextoDirecto(); + var fila = await db.PermissionTemplateItems.SingleOrDefaultAsync(x => x.Id == itemId, ct); + + fila.Should().NotBeNull("la retirada es LÓGICA: la fila del ítem no puede desaparecer de la tabla"); + fila!.IsActive.Should().BeFalse("la retirada se registra marcando el ítem, no quitándolo"); + fila.TargetId.Should().Be(contexto.SystemSuiteId, "el destino de la concesión sobrevive para poder auditarla"); + fila.ActionId.Should().Be(actionId, "la acción concedida sobrevive: es la mitad de la respuesta a «quién pudo hacer qué»"); + fila.UpdatedBy.Should().NotBeNullOrWhiteSpace("la auditoría debe registrar quién retiró la concesión"); + + // La plantilla lo sigue mostrando en su detalle, apagado: el operador necesita verlo para + // poder reactivarlo, y ocultarlo lo dejaría sin forma de recuperar una retirada por error. + var detalle = await LeerItemsDelDetalleAsync(templateId, ct); + detalle.Should().ContainSingle(i => i.ItemId == itemId && !i.IsActive); + } + + [Fact] + public async Task RetirarYReactivar_OperaSobreLaMismaFila_SinCrearOtra() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaAsync(contexto, ct); + var itemId = await AnadirItemAsync(templateId, contexto.SystemSuiteId, Guid.NewGuid(), ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}/items/{itemId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var reactivada = await Client.PostAsync( + $"/api/v1/permission-templates/{templateId}/items/{itemId}/activate", null, ct); + reactivada.StatusCode.Should().Be(HttpStatusCode.NoContent, await reactivada.Content.ReadAsStringAsync(ct)); + + // (c) Misma fila, ahora vigente. El conteo importa tanto como el estado: si la reactivación + // insertase una fila nueva, el ítem volvería con otra identidad y las referencias al anterior + // apuntarían a una concesión fantasma. + await using var db = CrearContextoDirecto(); + var filas = await db.PermissionTemplateItems.Where(x => x.TemplateId == templateId).ToListAsync(ct); + + filas.Should().ContainSingle("retirar y reactivar mueven un estado; no crean ni destruyen filas"); + filas[0].Id.Should().Be(itemId); + filas[0].IsActive.Should().BeTrue(); + } + + [Fact] + public async Task AltaConLaClaveDeUnItemRetirado_Responde409_LaClaveNoSeLibera() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaAsync(contexto, ct); + var actionId = Guid.NewGuid(); + var itemId = await AnadirItemAsync(templateId, contexto.SystemSuiteId, actionId, ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}/items/{itemId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (d) ADR-0164 §2.3: la clave natural (plantilla, tipo, destino, acción) queda ocupada para + // siempre. El rechazo llega del DOMINIO —conflicto legible— y no del índice único: si la + // guarda no comparase por valor, esto caería a 23505 y saldría como un 409 opaco de + // concurrencia, indistinguible de una carrera. + var repetida = await Client.PostAsJsonAsync($"/api/v1/permission-templates/{templateId}/items", new + { + targetType = "SystemSuite", + targetId = contexto.SystemSuiteId, + actionId, + isAllowed = true, + isDenied = false, + }, ct); + + repetida.StatusCode.Should().Be(HttpStatusCode.Conflict, await repetida.Content.ReadAsStringAsync(ct)); + (await repetida.Content.ReadAsStringAsync(ct)) + .Should().Contain("template_item_target_retired", + "el conflicto nombra la retirada para que el cliente proponga reactivar, no repetir el alta"); + + // Y no se coló una segunda fila con la misma clave. + await using var db = CrearContextoDirecto(); + var filas = await db.PermissionTemplateItems + .Where(x => x.TemplateId == templateId && x.TargetId == contexto.SystemSuiteId && x.ActionId == actionId) + .ToListAsync(ct); + filas.Should().ContainSingle(); + } + + [Fact] + public async Task PublicarConTodasLasConcesionesRetiradas_SeRechaza() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // Puerta que abre el borrado lógico: con el borrado físico, retirar el último ítem dejaba la + // plantilla sin filas y `Publish` la rechazaba. Ahora la fila sobrevive, así que sin una + // guarda sobre lo VIGENTE se publicaría un contrato que no concede nada — y sería asignable. + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaAsync(contexto, ct); + var itemId = await AnadirItemAsync(templateId, contexto.SystemSuiteId, Guid.NewGuid(), ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}/items/{itemId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var publicacion = await Client.PostAsync($"/api/v1/permission-templates/{templateId}/publish", null, ct); + + publicacion.IsSuccessStatusCode.Should().BeFalse( + "una plantilla cuyas concesiones están todas retiradas no concede nada: publicarla sería publicar un contrato vacío"); + (await publicacion.Content.ReadAsStringAsync(ct)).Should().Contain("template_items_required"); + } + + // ── Utilidades de aprovisionamiento ───────────────────────────────────── + + private sealed record ContextoDePrueba(Guid TenantId, Guid SystemSuiteId, Guid RoleId); + + private sealed record ItemDelDetalle(Guid ItemId, bool IsActive); + + private async Task> LeerItemsDelDetalleAsync(Guid templateId, CancellationToken ct) + { + var response = await Client.GetAsync($"/api/v1/permission-templates/{templateId}", ct); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("items").EnumerateArray() + .Select(i => new ItemDelDetalle( + i.GetProperty("itemId").GetGuid(), + i.GetProperty("isActive").GetBoolean())) + .ToList(); + } + + private async Task ProvisionarSuiteYRolAsync(CancellationToken ct) + { + // El host PostgreSQL corre con SeedDevData=false: no hay ningún inquilino sembrado, así que + // cada prueba levanta el suyo (mismo patrón que PermissionTemplateSoftDeleteTests). + var code = $"PTIR{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var tenantResponse = await Client.PostAsJsonAsync("/api/v1/tenants", new + { + code, + name = $"Inquilino de retirada de ítems {code}", + type = "CLIENT", + isManagementOwner = false, + }, ct); + tenantResponse.StatusCode.Should().Be(HttpStatusCode.Created, await tenantResponse.Content.ReadAsStringAsync(ct)); + var tenantId = Guid.Parse(tenantResponse.Headers.Location!.ToString().Split('/')[^1]); + + // ADR-0077: aprovisionar recursos de un inquilino CLIENT es una operación ON-BEHALF que solo + // ejerce el internal-admin; sin la cabecera, TenantScopePolicy devuelve AUTH_015 → 400. + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + Client.DefaultRequestHeaders.Remove("X-Is-Internal-Admin"); + Client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + + var suiteResponse = await Client.PostAsJsonAsync("/api/v1/system-suites", new + { + tenantId, + code = $"SS{Guid.NewGuid():N}"[..10], + name = "Consola de despacho", + description = "Suite para las pruebas de retirada de ítems de plantilla.", + }, ct); + suiteResponse.StatusCode.Should().Be(HttpStatusCode.Created, await suiteResponse.Content.ReadAsStringAsync(ct)); + using var suitePayload = JsonDocument.Parse(await suiteResponse.Content.ReadAsStringAsync(ct)); + var systemSuiteId = suitePayload.RootElement.GetProperty("systemSuiteId").GetGuid(); + + var roleResponse = await Client.PostAsJsonAsync($"/api/v1/system-suites/{systemSuiteId}/roles", new + { + code = $"ROLE{Guid.NewGuid():N}"[..12].ToUpperInvariant(), + value = "Analista de despacho aduanero", + description = "Rol para las pruebas de retirada de ítems de plantilla.", + parentRoleId = (Guid?)null, + hierarchyLevel = 0, + promotionOrder = 0, + }, ct); + roleResponse.StatusCode.Should().Be(HttpStatusCode.Created, await roleResponse.Content.ReadAsStringAsync(ct)); + using var rolePayload = JsonDocument.Parse(await roleResponse.Content.ReadAsStringAsync(ct)); + var roleId = rolePayload.RootElement.GetProperty("roleId").GetGuid(); + + return new ContextoDePrueba(tenantId, systemSuiteId, roleId); + } + + private async Task CrearPlantillaAsync(ContextoDePrueba contexto, CancellationToken ct) + { + var response = await Client.PostAsJsonAsync("/api/v1/permission-templates", new + { + tenantId = contexto.TenantId, + roleId = contexto.RoleId, + systemSuiteId = contexto.SystemSuiteId, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("templateId").GetGuid(); + } + + private async Task AnadirItemAsync(Guid templateId, Guid targetId, Guid actionId, CancellationToken ct) + { + var response = await Client.PostAsJsonAsync($"/api/v1/permission-templates/{templateId}/items", new + { + targetType = "SystemSuite", + targetId, + actionId, + isAllowed = true, + isDenied = false, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("itemId").GetGuid(); + } + + /// + /// Contexto EF conectado al MISMO contenedor que el API, con inquilino nulo para que ningún filtro + /// global recorte la vista. Es la ventana al almacenamiento real: lo que el API presenta como + /// retirado, aquí se ve como la fila que sigue existiendo. + /// + private UmsPlatformDbContext CrearContextoDirecto() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(Fixture.ConnectionString) + .Options; + + return new UmsPlatformDbContext( + options, + new ContextoDeInquilinoDelSistema(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private sealed class ContextoDeInquilinoDelSistema : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateSoftDeleteTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateSoftDeleteTests.cs new file mode 100644 index 00000000..6c3221a4 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateSoftDeleteTests.cs @@ -0,0 +1,356 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Authorization; + +/// +/// Política del propietario: SOLO existe borrado lógico. Estas pruebas fijan sobre PostgreSQL real +/// (Testcontainers) las cuatro afirmaciones que la sostienen para PermissionTemplate: +/// +/// a. El contrato HTTP no cambia: DELETE sigue devolviendo 204. +/// b. La FILA SIGUE EN LA BASE tras el borrado —con el estado terminal Deleted y sus ítems—. Es la +/// prueba que fija la política: sin ella el cambio no está demostrado. +/// c. El GET posterior devuelve 404 y la plantilla no aparece en el listado. +/// d. Con una referencia VIVA (un perfil activo que la usa) el DELETE devuelve 409 estructurado. +/// e. Con esa referencia ya eliminada lógicamente (perfil desactivado), el borrado sí procede. +/// +/// Antes de este cambio el repositorio hacía dbContext.PermissionTemplates.Remove(record), que +/// además arrastraba por cascada los ítems de la plantilla: se perdía sin remedio el rastro de qué +/// concesiones había otorgado, justo lo que el negocio consulta hacia atrás. +/// +[Collection("PostgreSql")] +public sealed class PermissionTemplateSoftDeleteTests : IntegrationTestBase +{ + /// Id de TemplateStatus.Deleted tal y como se persiste en la columna StatusId. + private const int DeletedStatusId = 4; + + public PermissionTemplateSoftDeleteTests(PostgreSqlContainerFixture fixture) : base(fixture) { } + + [Fact] + public async Task Delete_PlantillaSinReferencias_Responde204_DejaLaFilaEnLaBase_YOcultaLasLecturas() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaConItemAsync(contexto, ct); + + // (a) El contrato no cambia: 204 No Content, igual que con el borrado físico. + var deleteResponse = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent, + await deleteResponse.Content.ReadAsStringAsync(ct)); + + // (b) LA FILA SIGUE EN LA BASE. Se consulta con un contexto propio, no por el API, porque el + // API es precisamente lo que ahora la oculta: la evidencia tiene que venir del almacenamiento. + await using var db = CrearContextoDirecto(); + var fila = await db.PermissionTemplates + .Include(x => x.Items) + .SingleOrDefaultAsync(x => x.Id == templateId, ct); + + fila.Should().NotBeNull("el borrado es LÓGICO: la fila no puede desaparecer de la tabla"); + fila!.StatusId.Should().Be(DeletedStatusId, "la eliminación se registra como estado terminal Deleted"); + fila.Items.Should().NotBeEmpty("los ítems son el rastro de las concesiones otorgadas y sobreviven al borrado"); + fila.UpdatedBy.Should().NotBeNullOrWhiteSpace("la auditoría debe registrar quién eliminó la plantilla"); + + // (c) Las lecturas la ocultan: por id da 404 y no aparece en el listado. + var getResponse = await Client.GetAsync($"/api/v1/permission-templates/{templateId}", ct); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var listado = await Client.GetAsync( + $"/api/v1/permission-templates?page=1&pageSize=200&tenantId={contexto.TenantId}&systemSuiteId={contexto.SystemSuiteId}", ct); + listado.StatusCode.Should().Be(HttpStatusCode.OK); + + using var payload = JsonDocument.Parse(await listado.Content.ReadAsStringAsync(ct)); + payload.RootElement.GetProperty("items").EnumerateArray() + .Should().NotContain(i => i.GetProperty("templateId").GetGuid() == templateId, + "una plantilla eliminada lógicamente no puede seguir apareciendo en el catálogo"); + } + + [Fact] + public async Task Delete_SegundoIntento_Responde404_PorqueLasLecturasOcultanLoEliminado() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaConItemAsync(contexto, ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var segundo = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct); + + segundo.StatusCode.Should().Be(HttpStatusCode.NotFound, + "el borrado no es idempotente en silencio: lo ya eliminado simplemente no existe para el API"); + } + + [Fact] + public async Task Delete_ConPerfilVivo_Responde409_YTrasDesactivarlo_Permite() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaConItemAsync(contexto, ct); + + // Solo una plantilla PUBLICADA puede asignarse a un perfil (Profile.AssignTemplate). + (await Client.PostAsync($"/api/v1/permission-templates/{templateId}/publish", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var profileId = await CrearPerfilActivoAsync(contexto, ct); + (await Client.PostAsync($"/api/v1/profiles/{profileId}/templates/{templateId}", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (d) Referencia VIVA → 409 con las dependencias que bloquean, no un 500 ni un borrado a + // traición que dejaría al perfil apuntando a la nada. + var bloqueado = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct); + bloqueado.StatusCode.Should().Be(HttpStatusCode.Conflict, + await bloqueado.Content.ReadAsStringAsync(ct)); + + using var error = JsonDocument.Parse(await bloqueado.Content.ReadAsStringAsync(ct)); + error.RootElement.GetProperty("errorCode").GetString().Should().Be("TEMPLATE_HAS_ACTIVE_PROFILES"); + var deps = error.RootElement.GetProperty("blockingDependencies"); + deps.GetArrayLength().Should().BeGreaterThan(0); + deps[0].GetProperty("entityType").GetString().Should().Be("Profile"); + deps[0].GetProperty("count").GetInt32().Should().BeGreaterThan(0); + + // La plantilla sigue viva: el rechazo es previo a cualquier escritura. + (await Client.GetAsync($"/api/v1/permission-templates/{templateId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + // (e) Se elimina la referencia (perfil desactivado) y ahora el borrado sí procede. La plantilla + // se deprecia primero porque una PUBLICADA está en uso por definición: deprecar es la retirada + // explícita de la oferta y es lo que habilita el borrado. + (await Client.PostAsync($"/api/v1/profiles/{profileId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + (await Client.PostAsync($"/api/v1/permission-templates/{templateId}/deprecate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var permitido = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct); + permitido.StatusCode.Should().Be(HttpStatusCode.NoContent, + await permitido.Content.ReadAsStringAsync(ct)); + + // Y de nuevo: la fila sigue en la base, ahora en estado terminal. + await using var db = CrearContextoDirecto(); + var fila = await db.PermissionTemplates.SingleOrDefaultAsync(x => x.Id == templateId, ct); + fila.Should().NotBeNull(); + fila!.StatusId.Should().Be(DeletedStatusId); + } + + [Fact] + public async Task Delete_NoLiberaLaVersionParaUnAltaPosterior() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // G-140 revisitado bajo borrado lógico: la fila eliminada SIGUE ocupando su versión en el + // índice único IX_PermissionTemplates_TenantId_RoleId_SystemSuiteId_Version. Si el cálculo de + // la versión siguiente ignorase las eliminadas, el alta posterior reutilizaría 0.1.0 y chocaría + // con 23505. Por eso GetByTenantRoleSuiteAsync es la ÚNICA lectura que no filtra. + var contexto = await ProvisionarSuiteYRolAsync(ct); + var primera = await CrearPlantillaConItemAsync(contexto, ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{primera}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var segunda = await Client.PostAsJsonAsync("/api/v1/permission-templates", new + { + tenantId = contexto.TenantId, + roleId = contexto.RoleId, + systemSuiteId = contexto.SystemSuiteId, + }, ct); + + segunda.StatusCode.Should().Be(HttpStatusCode.Created, await segunda.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await segunda.Content.ReadAsStringAsync(ct)); + var segundaId = payload.RootElement.GetProperty("templateId").GetGuid(); + + var get = await Client.GetAsync($"/api/v1/permission-templates/{segundaId}", ct); + get.StatusCode.Should().Be(HttpStatusCode.OK); + using var detalle = JsonDocument.Parse(await get.Content.ReadAsStringAsync(ct)); + detalle.RootElement.GetProperty("version").GetString().Should().Be("0.2.0", + "la versión de la plantilla eliminada sigue ocupada en el índice único"); + } + + [Fact] + public async Task CargadorDelTablero_BorraLasPlantillasDuplicadas_YEnLaReejecucionYaNoLasVe() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // Reproduce la secuencia EXACTA de src/provisioning/sdlc/cargar-en-ums.mjs (paso 7): + // 1. lee el catálogo con GET /permission-templates?...&systemSuiteId=… + // 2. agrupa por rol, se queda con la canónica (menor versión) y borra las sobrantes con + // DELETE /permission-templates/{id}, contando como FALLO cualquier respuesta no-ok + // 3. en la siguiente pasada vuelve a leer el catálogo + // El borrado lógico tiene que bastarle: la plantilla deja de estorbar porque desaparece del + // catálogo. Si siguiera apareciendo, el cargador la trataría otra vez como sobrante, el + // segundo DELETE daría 404 y sumaría un fallo en cada reejecución. + var contexto = await ProvisionarSuiteYRolAsync(ct); + var canonica = await CrearPlantillaConItemAsync(contexto, ct); + var duplicada = await CrearPlantillaConItemAsync(contexto, ct); + + var primeraLectura = await LeerCatalogoDelCargadorAsync(contexto, ct); + primeraLectura.Should().Contain(canonica).And.Contain(duplicada, + "el alta repetida genera una versión más, que es justo el duplicado que el cargador limpia"); + + var borrado = await Client.DeleteAsync($"/api/v1/permission-templates/{duplicada}", ct); + borrado.IsSuccessStatusCode.Should().BeTrue( + "el cargador solo mira `r.ok`; cualquier otra cosa cuenta como fallo de carga"); + + var segundaLectura = await LeerCatalogoDelCargadorAsync(contexto, ct); + segundaLectura.Should().Contain(canonica); + segundaLectura.Should().NotContain(duplicada, + "en la reejecución la duplicada ya no aparece, así que el cargador no vuelve a intentar borrarla"); + } + + /// Misma consulta que hace el cargador del Tablero para construir su catálogo. + private async Task> LeerCatalogoDelCargadorAsync(ContextoDePrueba contexto, CancellationToken ct) + { + var response = await Client.GetAsync( + $"/api/v1/permission-templates?page=1&pageSize=200&tenantId={contexto.TenantId}&systemSuiteId={contexto.SystemSuiteId}", ct); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("templateId").GetGuid()) + .ToList(); + } + + // ── Utilidades de aprovisionamiento ───────────────────────────────────── + + private sealed record ContextoDePrueba(Guid TenantId, Guid SystemSuiteId, Guid RoleId); + + private async Task ProvisionarSuiteYRolAsync(CancellationToken ct) + { + // El host PostgreSQL corre con SeedDevData=false: no hay ningún inquilino sembrado, así que + // cada prueba levanta el suyo (mismo patrón que PostgreSqlAuthorizationPersistenceTests). + var code = $"PTSD{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var tenantResponse = await Client.PostAsJsonAsync("/api/v1/tenants", new + { + code, + name = $"Inquilino de borrado lógico {code}", + type = "CLIENT", + isManagementOwner = false, + }, ct); + tenantResponse.StatusCode.Should().Be(HttpStatusCode.Created, await tenantResponse.Content.ReadAsStringAsync(ct)); + var tenantId = Guid.Parse(tenantResponse.Headers.Location!.ToString().Split('/')[^1]); + + // ADR-0077: aprovisionar recursos de un inquilino CLIENT es una operación ON-BEHALF que solo + // ejerce el internal-admin; sin la cabecera, TenantScopePolicy devuelve AUTH_015 → 400. + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + Client.DefaultRequestHeaders.Remove("X-Is-Internal-Admin"); + Client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + + var suiteResponse = await Client.PostAsJsonAsync("/api/v1/system-suites", new + { + tenantId, + code = $"SS{Guid.NewGuid():N}"[..10], + name = "Consola de operaciones", + description = "Suite para las pruebas de borrado lógico de plantillas.", + }, ct); + suiteResponse.StatusCode.Should().Be(HttpStatusCode.Created, await suiteResponse.Content.ReadAsStringAsync(ct)); + using var suitePayload = JsonDocument.Parse(await suiteResponse.Content.ReadAsStringAsync(ct)); + var systemSuiteId = suitePayload.RootElement.GetProperty("systemSuiteId").GetGuid(); + + var roleResponse = await Client.PostAsJsonAsync($"/api/v1/system-suites/{systemSuiteId}/roles", new + { + code = $"ROLE{Guid.NewGuid():N}"[..12].ToUpperInvariant(), + value = "Coordinador de operaciones", + description = "Rol para las pruebas de borrado lógico de plantillas.", + parentRoleId = (Guid?)null, + hierarchyLevel = 0, + promotionOrder = 0, + }, ct); + roleResponse.StatusCode.Should().Be(HttpStatusCode.Created, await roleResponse.Content.ReadAsStringAsync(ct)); + using var rolePayload = JsonDocument.Parse(await roleResponse.Content.ReadAsStringAsync(ct)); + var roleId = rolePayload.RootElement.GetProperty("roleId").GetGuid(); + + return new ContextoDePrueba(tenantId, systemSuiteId, roleId); + } + + private async Task CrearPlantillaConItemAsync(ContextoDePrueba contexto, CancellationToken ct) + { + var response = await Client.PostAsJsonAsync("/api/v1/permission-templates", new + { + tenantId = contexto.TenantId, + roleId = contexto.RoleId, + systemSuiteId = contexto.SystemSuiteId, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var templateId = payload.RootElement.GetProperty("templateId").GetGuid(); + + // Publicar exige al menos un ítem; además el ítem es la evidencia de que el borrado lógico no + // arrastra por cascada el detalle de las concesiones. + var item = await Client.PostAsJsonAsync($"/api/v1/permission-templates/{templateId}/items", new + { + targetType = "SystemSuite", + targetId = contexto.SystemSuiteId, + actionId = Guid.NewGuid(), + isAllowed = true, + isDenied = false, + }, ct); + item.StatusCode.Should().Be(HttpStatusCode.Created, await item.Content.ReadAsStringAsync(ct)); + + return templateId; + } + + private async Task CrearPerfilActivoAsync(ContextoDePrueba contexto, CancellationToken ct) + { + var userResponse = await Client.PostAsJsonAsync("/api/v1/user-accounts", new + { + tenantId = contexto.TenantId, + branchId = (Guid?)null, + email = $"operador.{Guid.NewGuid():N}"[..24] + "@beyondnet.local", + category = "Internal", + identityReference = $"EMP-{Guid.NewGuid():N}"[..10], + identityReferenceType = "HrId", + }, ct); + userResponse.StatusCode.Should().Be(HttpStatusCode.Created, await userResponse.Content.ReadAsStringAsync(ct)); + using var userPayload = JsonDocument.Parse(await userResponse.Content.ReadAsStringAsync(ct)); + var userId = userPayload.RootElement.GetProperty("userAccountId").GetGuid(); + + var profileResponse = await Client.PostAsJsonAsync("/api/v1/profiles", new + { + tenantId = contexto.TenantId, + userId, + roleId = contexto.RoleId, + branchId = (Guid?)null, + }, ct); + profileResponse.StatusCode.Should().Be(HttpStatusCode.Created, await profileResponse.Content.ReadAsStringAsync(ct)); + using var profilePayload = JsonDocument.Parse(await profileResponse.Content.ReadAsStringAsync(ct)); + return profilePayload.RootElement.GetProperty("profileId").GetGuid(); + } + + /// + /// Contexto EF conectado al MISMO contenedor que el API, con inquilino nulo para que ningún filtro + /// global recorte la vista. Es la ventana al almacenamiento real: lo que el API oculta, aquí se ve. + /// + private UmsPlatformDbContext CrearContextoDirecto() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(Fixture.ConnectionString) + .Options; + + return new UmsPlatformDbContext( + options, + new ContextoDeInquilinoDelSistema(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private sealed class ContextoDeInquilinoDelSistema : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/ProfileRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/ProfileRestEndpointTests.cs index 4fb9479a..45eb5137 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/ProfileRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/ProfileRestEndpointTests.cs @@ -19,6 +19,67 @@ public ProfileRestEndpointTests(UmsApiWebApplicationFactory factory) _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000123"); _client.DefaultRequestHeaders.Add("X-User-Name", "Integration Tester"); _client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.InternalAdminTenantId); + // ADR-0071 / FS-26: INTERNAL_ADMIN ya no es management owner; las escrituras acotadas + // (crear usuario, sucursal, perfil) exigen contexto internal-admin explícito o devuelven + // AUTH_015 → 400. Se declara el rol de operador de gestión de forma explícita. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + } + + /// + /// G-215 — un perfil por (usuario, rol, sucursal) activo. + /// + /// Nada lo impedía: cada llamada con los mismos datos creaba OTRO perfil. El + /// aprovisionamiento del Tablero SDLC, reejecutado, dejó a ocho usuarios con perfiles + /// duplicados —uno con concesiones y otro vacío, porque la plantilla se asigna a uno solo— y el + /// selector de perfil se los ofrecía indistinguibles. Elegir el vacío es entrar sin un solo + /// permiso, con todos los HTTP en 2xx. + /// + [Fact] + public async Task CreateProfile_Duplicado_Del_Mismo_Rol_Y_Sucursal_Se_Rechaza() + { + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId); + var userId = await CreateUserAsync(tenantId, ct); + var systemSuiteId = await GetManagementSystemSuiteIdAsync(tenantId, ct); + var roleId = await CreateRoleAsync(systemSuiteId, ct); + var branchId = await CreateBranchAsync(tenantId, ct); + + var primero = await _client.PostAsJsonAsync("/api/v1/profiles", + new { tenantId, userId, roleId, branchId }, ct); + primero.StatusCode.Should().Be(HttpStatusCode.Created); + + var segundo = await _client.PostAsJsonAsync("/api/v1/profiles", + new { tenantId, userId, roleId, branchId }, ct); + + segundo.StatusCode.Should().NotBe(HttpStatusCode.Created, + because: "el segundo perfil idéntico quedaría sin plantilla y el usuario podría elegirlo"); + var cuerpo = await segundo.Content.ReadAsStringAsync(ct); + cuerpo.Should().Contain("profile_already_exists_for_role"); + } + + /// + /// El mismo rol en OTRA sucursal sí es un perfil distinto: la guarda acota por + /// (usuario, rol, sucursal), no por (usuario, rol). Sin esta prueba, endurecer la guarda de más + /// —y romper el multi-sucursal, que es un caso real del negocio— pasaría inadvertido. + /// + [Fact] + public async Task CreateProfile_Mismo_Rol_En_Otra_Sucursal_Se_Admite() + { + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId); + var userId = await CreateUserAsync(tenantId, ct); + var systemSuiteId = await GetManagementSystemSuiteIdAsync(tenantId, ct); + var roleId = await CreateRoleAsync(systemSuiteId, ct); + var sucursalA = await CreateBranchAsync(tenantId, ct); + var sucursalB = await CreateBranchAsync(tenantId, ct); + + var a = await _client.PostAsJsonAsync("/api/v1/profiles", + new { tenantId, userId, roleId, branchId = sucursalA }, ct); + a.StatusCode.Should().Be(HttpStatusCode.Created); + + var b = await _client.PostAsJsonAsync("/api/v1/profiles", + new { tenantId, userId, roleId, branchId = sucursalB }, ct); + b.StatusCode.Should().Be(HttpStatusCode.Created); } [Fact] diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRanuraLiberadaE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRanuraLiberadaE2ETests.cs new file mode 100644 index 00000000..abf55834 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRanuraLiberadaE2ETests.cs @@ -0,0 +1,172 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Infrastructure.Persistence.Configuration.Entities; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Configuration; + +/// +/// E2E sobre PostgreSQL real (Testcontainers) de la excepción que el propietario del producto acotó +/// sobre ADR-0164 §2.3 el 2026-08-04: el borrado lógico de una configuración LIBERA su ranura. +/// +/// Por qué hace falta el motor real y no basta el host InMemory: lo que aquí se demuestra es que el +/// índice único de `AppConfigurations` es PARCIAL. Con el índice total, volver a crear la misma +/// (ámbito, código) tras borrarla reventaba con 23505 → 500. El almacén en memoria no tiene índices, +/// así que allí la regla se puede cumplir por accidente. +/// +/// El ámbito elegido tiene los tres identificadores presentes (inquilino + sistema + módulo) a +/// propósito. PostgreSQL trata los NULL como distintos dentro de un índice único, de modo que en el +/// ámbito Global —los tres NULL— el índice nunca llegó a arbitrar nada: la unicidad la sostiene solo +/// la comprobación del handler. Probar sobre el ámbito de módulo es lo único que interroga al índice. +/// +[Collection("PostgreSql")] +public sealed class AppConfigurationRanuraLiberadaE2ETests +{ + private static readonly Guid SeededTenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid SeededSystemSuiteId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + public AppConfigurationRanuraLiberadaE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + [Fact] + public async Task ElIndiceUnicoDeConfiguraciones_EsParcial_EnLaBaseReal() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Se pregunta al catálogo del motor, no al modelo de EF: es la diferencia entre creer que la + // migración se aplicó y comprobarlo. + var definiciones = new List(); + var connection = db.Database.GetDbConnection(); + await db.Database.OpenConnectionAsync(ct); + await using (var command = connection.CreateCommand()) + { + command.CommandText = + "SELECT indexdef FROM pg_indexes " + + "WHERE schemaname = 'ums_configuration' AND indexname IN (" + + " 'IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code'," + + " 'IX_ParameterDefinitions_Code'," + + " 'IX_ParameterGlobalValues_ParameterDefinitionId'," + + " 'IX_ParameterTenantValues_TenantId_ParameterDefinitionId')"; + + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + definiciones.Add(reader.GetString(0)); + } + } + + definiciones.Should().HaveCount(4, "los cuatro índices existen en el esquema"); + definiciones.Should().OnlyContain(d => d.Contains("WHERE", StringComparison.Ordinal), + "los cuatro son índices PARCIALES: sin el WHERE, la lápida seguiría ocupando la ranura"); + definiciones.Should().OnlyContain(d => d.Contains("UNIQUE", StringComparison.Ordinal), + "liberar la ranura no relaja la unicidad entre filas vivas"); + } + + /// + /// El ciclo completo contra el índice real: crear, borrar y volver a crear con el MISMO ámbito y + /// código. Cubre las cuatro mitades del cambio (recrear · las dos filas siguen ahí · la lectura + /// resuelve la viva · el duplicado de una viva sigue en conflicto). + /// + [Fact] + public async Task Configuracion_DeModulo_TrasBorrarse_SeVuelveACrear_YLaLecturaResuelveLaViva() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var moduleId = Guid.NewGuid(); + var code = $"DESPACHO_ALERTA_DIAS_{Guid.NewGuid():N}"[..30].ToUpperInvariant(); + + async Task CrearAsync(string value) => + await admin.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = SeededTenantId, + systemSuiteId = SeededSystemSuiteId, + moduleId, + code, + value, + description = "Días de antelación para avisar del vencimiento de un despacho", + isInheritable = false, + isEncrypted = false, + }, ct); + + var primera = await CrearAsync("5"); + primera.StatusCode.Should().Be(HttpStatusCode.Created); + using var primeraPayload = JsonDocument.Parse(await primera.Content.ReadAsStringAsync(ct)); + var primeraId = primeraPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + (await admin.DeleteAsync($"/api/v1/app-configurations/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (a) La ranura quedó libre: el mismo ámbito y código admiten un alta nueva. Contra el índice + // total esto era un 23505 → 500, y por la vía del handler un 409. + var segunda = await CrearAsync("7"); + segunda.StatusCode.Should().Be(HttpStatusCode.Created, + "borrar la configuración de un parámetro no puede impedir volver a configurarlo"); + using var segundaPayload = JsonDocument.Parse(await segunda.Content.ReadAsStringAsync(ct)); + var segundaId = segundaPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // (b) Las DOS filas siguen en la tabla. Se leen sin filtros: la API no distingue «oculto» de + // «borrado», así que preguntarle a ella no probaría nada (ADR-0164 §5). + var filas = await db.Set() + .IgnoreQueryFilters() + .Where(x => x.Code == code) + .ToListAsync(ct); + + filas.Should().HaveCount(2, "esto no es volver al borrado físico"); + filas.Single(x => x.Id == primeraId).StatusId.Should().Be(ConfigStatus.Deleted.Id); + filas.Single(x => x.Id == segundaId).StatusId.Should().NotBe(ConfigStatus.Deleted.Id); + + // (c) La lectura por ámbito+código resuelve LA VIVA, sin ambigüedad, con la lápida presente. + var repositorio = scope.ServiceProvider.GetRequiredService(); + var viva = await repositorio.GetByScopeAndCodeAsync(SeededTenantId, SeededSystemSuiteId, moduleId, code, ct); + viva.Should().NotBeNull(); + viva!.Props.Id.GetValue().Should().Be(segundaId, "el lookup por código nunca devuelve la lápida"); + + (await admin.GetAsync($"/api/v1/app-configurations/{segundaId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + (await admin.GetAsync($"/api/v1/app-configurations/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound, "la eliminada sigue oculta"); + + // (d) Duplicar la VIVA sigue dando conflicto legible del dominio, no un error de índice. + var duplicada = await CrearAsync("9"); + duplicada.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await duplicada.Content.ReadAsStringAsync(ct)) + .Should().Contain("already exists", "el conflicto tiene que decir qué pasa"); + } + + private HttpClient CreateAdminClient() + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + return client; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRestEndpointTests.cs index b4b5a6cd..2a49333f 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRestEndpointTests.cs @@ -11,6 +11,9 @@ public sealed class AppConfigurationRestEndpointTests : IClassFixture(); + _adminClient = factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("https://localhost"), @@ -353,4 +358,166 @@ public async Task PublishAppConfiguration_WhenAlreadyPublished_ShouldReturn422() secondPublishResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); } + + // ----------------------------------------------------------------------- + // Delete (borrado LÓGICO: mismo contrato HTTP, la fila NO se pierde) + // ----------------------------------------------------------------------- + + [Fact] + public async Task DeleteAppConfiguration_WhenExists_Devuelve204_YElRegistroSigueExistiendo() + { + var code = $"FS20-DEL-{Guid.NewGuid():N}"; + + var createResponse = await _adminClient.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, systemSuiteId = (Guid?)null, moduleId = (Guid?)null, + code, value = "to-delete", description = "Borrado lógico", + isInheritable = false, isEncrypted = false, + }, TestContext.Current.CancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + var deleteResponse = await _adminClient.DeleteAsync( + $"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent, "el contrato HTTP no cambia"); + + // La lectura la oculta… + var getResponse = await _adminClient.GetAsync( + $"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // …pero el registro SIGUE en el almacén, en estado terminal Deleted. Esta es la aserción que + // fija la política del propietario: sin ella, el borrado lógico no está demostrado. Se lee + // SIN filtros (`GetAllIncludingDeleted`) y ya no por `GetByScopeAndCodeAsync`: desde que el + // borrado libera la ranura, ese lookup solo devuelve la viva y no vería la lápida. + var stored = _store.GetAllIncludingDeleted() + .SingleOrDefault(item => string.Equals(item.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase)); + stored.Should().NotBeNull("la fila jamás se elimina físicamente"); + stored!.Status.Should().Be(ConfigStatus.Deleted); + + // Y tampoco aparece en el listado. + var listResponse = await _adminClient.GetAsync( + "/api/v1/app-configurations?page=1&pageSize=200", TestContext.Current.CancellationToken); + listResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var listBody = await listResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + listBody.Should().NotContain(code, "lo eliminado no se lista"); + } + + /// + /// La ranura (ámbito, código) se LIBERA al borrar: borrar la configuración de un parámetro no + /// puede impedir volver a configurarlo. Es la excepción que el propietario del producto acotó + /// sobre ADR-0164 §2.3 el 2026-08-04, tras medirse el efecto en vivo: el código de una + /// configuración sale de un catálogo cerrado —`MFA_REQUIRED_FOR_ADMIN` es *el* nombre de ese + /// parámetro— y no identifica una cosa del mundo real, a diferencia del código de una sucursal. + /// + /// La prueba cubre las cuatro mitades del cambio: se puede recrear; las dos filas siguen ahí; + /// la lectura por código resuelve LA VIVA; y duplicar una viva sigue siendo conflicto. + /// + [Fact] + public async Task DeleteAppConfiguration_LiberaLaRanura_YLaLecturaPorCodigoResuelveLaViva() + { + var ct = TestContext.Current.CancellationToken; + var code = $"FS20-RANURA-{Guid.NewGuid():N}"; + + async Task CrearAsync(string value) => + await _adminClient.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, systemSuiteId = (Guid?)null, moduleId = (Guid?)null, + code, value, description = "Ranura de configuración", + isInheritable = false, isEncrypted = false, + }, ct); + + var primera = await CrearAsync("original"); + primera.StatusCode.Should().Be(HttpStatusCode.Created); + using var primeraPayload = JsonDocument.Parse(await primera.Content.ReadAsStringAsync(ct)); + var primeraId = primeraPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + (await _adminClient.DeleteAsync($"/api/v1/app-configurations/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (a) Se puede volver a crear con EL MISMO código. + var segunda = await CrearAsync("reconfigurada"); + segunda.StatusCode.Should().Be(HttpStatusCode.Created, + "una ranura de configuración liberada admite un alta nueva"); + using var segundaPayload = JsonDocument.Parse(await segunda.Content.ReadAsStringAsync(ct)); + var segundaId = segundaPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + // (b) LAS DOS filas siguen en el almacén; la vieja marcada como eliminada. + var todas = _store.GetAllIncludingDeleted() + .Where(item => string.Equals(item.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase)) + .ToList(); + todas.Should().HaveCount(2, "esto NO es volver al borrado físico: la historia se conserva"); + todas.Single(item => item.Props.Id.GetValue() == primeraId).Status.Should().Be(ConfigStatus.Deleted); + todas.Single(item => item.Props.Id.GetValue() == segundaId).Status.Should().Be(ConfigStatus.Draft); + + // (c) La lectura por código resuelve LA VIVA, sin ambigüedad. + var viva = await _store.GetByScopeAndCodeAsync(null, null, null, code, ct); + viva.Should().NotBeNull(); + viva!.Props.Id.GetValue().Should().Be(segundaId, "el lookup por código nunca devuelve la lápida"); + (await _adminClient.GetAsync($"/api/v1/app-configurations/{segundaId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + (await _adminClient.GetAsync($"/api/v1/app-configurations/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound, "la eliminada sigue oculta para las lecturas"); + + // (d) Duplicar una VIVA sigue dando conflicto legible. + var duplicada = await CrearAsync("duplicada"); + duplicada.StatusCode.Should().Be(HttpStatusCode.Conflict, + "liberar la ranura no debilita la unicidad entre las configuraciones vivas"); + (await duplicada.Content.ReadAsStringAsync(ct)) + .Should().Contain("already exists", "el conflicto tiene que decir qué pasa"); + } + + [Fact] + public async Task DeleteAppConfiguration_DosVeces_LaSegundaDevuelve404() + { + var code = $"FS20-DEL2-{Guid.NewGuid():N}"; + + var createResponse = await _adminClient.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, systemSuiteId = (Guid?)null, moduleId = (Guid?)null, + code, value = "v", description = "Doble borrado", + isInheritable = false, isEncrypted = false, + }, TestContext.Current.CancellationToken); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + (await _adminClient.DeleteAsync($"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var second = await _adminClient.DeleteAsync( + $"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken); + + second.StatusCode.Should().Be(HttpStatusCode.NotFound, "para las lecturas ya no existe"); + } + + [Fact] + public async Task DeleteAppConfiguration_WhenNotFound_ShouldReturn404() + { + var deleteResponse = await _adminClient.DeleteAsync( + $"/api/v1/app-configurations/{Guid.NewGuid()}", TestContext.Current.CancellationToken); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteAppConfiguration_GlobalScope_AsTenantAdmin_ShouldReturn403() + { + // Internal admin creates a global config + var code = $"FS20-DEL-403-{Guid.NewGuid():N}"; + var createResponse = await _adminClient.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, systemSuiteId = (Guid?)null, moduleId = (Guid?)null, + code, value = "v", description = "Admin creates, tenant tries to delete", + isInheritable = false, isEncrypted = false, + }, TestContext.Current.CancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + var deleteResponse = await _tenantClient.DeleteAsync( + $"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationRestEndpointTests.cs index 8dbc42e2..ff3b4031 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationRestEndpointTests.cs @@ -72,4 +72,41 @@ public async Task GetAppConfigurations_ShouldReturnSeededConfiguration() .ToList(); codes.Should().Contain("SESSION_TIMEOUT_MINUTES"); } + + /// + /// G-104: eliminar un criterio inexistente de un feature flag EXISTENTE debe resolver 404. + /// El flag existe (se crea antes), así que el handler pasa la guarda de «flag no encontrado» y + /// el dominio devuelve el código estable configuration.criteria_not_found al direccionar el + /// criterio por su id. Antes ese código ni estaba en la lista del DomainErrorStatusMapper ni + /// contenía la frase inglesa «not found», así que colapsaba al 400 por defecto —contradiciendo el + /// contrato del endpoint, que ya declaraba .ProducesProblem(404)—. Recorre endpoint + mapeador + /// reales, verificando la clasificación por código con independencia del idioma del mensaje. + /// + [Fact] + public async Task RemoveFeatureFlagCriteria_WhenCriteriaDoesNotExist_ShouldReturn404() + { + var createResponse = await _client.PostAsJsonAsync("/api/v1/feature-flags", new + { + systemSuiteId = "11111111-1111-1111-1111-111111111111", + tenantId = (string?)null, + flagCode = $"g104_criteria_{Guid.NewGuid():N}", + flagType = "Boolean", + flagTargets = "tenant-console", + linkedResourceType = "Module", + linkedResourceId = "33333333-3333-3333-3333-333333333333", + rolloutPercentage = (int?)null, + }, TestContext.Current.CancellationToken); + + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + using var createdPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var featureFlagId = createdPayload.RootElement.GetProperty("featureFlagId").GetGuid(); + + var missingCriteriaId = Guid.NewGuid(); + var deleteResponse = await _client.DeleteAsync( + $"/api/v1/feature-flags/{featureFlagId}/criteria/{missingCriteriaId}", + TestContext.Current.CancellationToken); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSecretExposureE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSecretExposureE2ETests.cs new file mode 100644 index 00000000..1ad8702d --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSecretExposureE2ETests.cs @@ -0,0 +1,350 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Configuration; + +/// +/// G-088 (contexto de seguridad): E2E sobre un contenedor PostgreSQL real (Testcontainers) +/// que garantiza, extremo a extremo por REST, dos invariantes de Configuration que hasta ahora +/// sólo se probaban a nivel unitario: +/// +/// 1. Los valores marcados como cifrados/secretos NO se devuelven en claro por REST a un usuario +/// NO administrador (se redactan a "***"), y el texto cifrado crudo (prefijo AES256:) +/// NUNCA se serializa en la respuesta —ni siquiera al administrador, que lo recibe descifrado—. +/// Además se verifica el cifrado EN REPOSO leyendo la fila directamente de la base. +/// +/// 2. La evaluación de un feature flag responde correctamente al kill-switch (fail-closed: un flag +/// inactivo evalúa deshabilitado, ADR/G-048) y a los extremos del porcentaje de rollout +/// (0% ⇒ deshabilitado, 100% ⇒ habilitado) de forma determinista. +/// +/// Calca el patrón de +/// (host PostgreSQL de Testcontainers, cliente por actor). Para ejercer la vista de un usuario NO +/// administrador —la única en la que la redacción es observable— se usa el override aditivo y +/// retrocompatible X-Test-Is-Internal-Admin: false de . +/// +[Collection("PostgreSql")] +public sealed class ConfigurationSecretExposureE2ETests +{ + private const string EncryptedPrefix = "AES256:"; // Contrato de wire de AesValueEncryptionService. + + // Tenant sembrado por el host (coincide con el default de TestAuthHandler): el usuario NO + // administrador de este tenant puede resolver configuraciones Global (TenantId IS NULL). + private static readonly Guid SeededTenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid SeededSystemSuiteId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + public ConfigurationSecretExposureE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + // --------------------------------------------------------------------------------------- + // 1. Redacción de secretos cifrados por REST (fuga de secretos) + // --------------------------------------------------------------------------------------- + + [Fact] + public async Task EncryptedConfig_IsStoredCiphered_RedactedForNonAdmin_AndNeverLeaksRawCiphertext() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + const string secretPlaintext = "S3cr3t-Api-Key-p!auth-9f2c-DO-NOT-LEAK"; + + var admin = CreateAdminClient(); + var nonAdmin = CreateNonAdminClient(); + + // 1. El administrador crea una configuración Global marcada como cifrada. + var code = $"G088-SECRET-{Guid.NewGuid():N}"; + var createResponse = await admin.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, + systemSuiteId = (Guid?)null, + moduleId = (Guid?)null, + code, + value = secretPlaintext, + description = "G-088 configuración con valor secreto", + isInheritable = false, + isEncrypted = true, + }, ct); + + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(ct)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + // 2. Cifrado EN REPOSO: la fila persistida guarda el ciphertext (prefijo AES256:), nunca el claro. + string storedValue; + bool storedIsEncrypted; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var record = await db.AppConfigurations + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == configId, ct); + + record.Should().NotBeNull("la configuración debe haberse persistido"); + storedValue = record!.Value; + storedIsEncrypted = record.IsEncrypted; + } + + storedIsEncrypted.Should().BeTrue("la bandera de cifrado debe persistirse"); + storedValue.Should().StartWith(EncryptedPrefix, "un valor marcado como cifrado debe almacenarse cifrado en reposo"); + storedValue.Should().NotContain(secretPlaintext, "el texto en claro nunca debe persistirse"); + + // 3. Vista NO administrador: el valor se redacta a "***" y el cuerpo no filtra ni el claro + // ni el ciphertext crudo. + var nonAdminResponse = await nonAdmin.GetAsync($"/api/v1/app-configurations/{configId}", ct); + nonAdminResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var nonAdminBody = await nonAdminResponse.Content.ReadAsStringAsync(ct); + + using (var nonAdminJson = JsonDocument.Parse(nonAdminBody)) + { + nonAdminJson.RootElement.GetProperty("isEncrypted").GetBoolean().Should().BeTrue(); + nonAdminJson.RootElement.GetProperty("value").GetString() + .Should().Be("***", "un usuario no administrador nunca debe ver el valor de un secreto"); + } + + nonAdminBody.Should().NotContain(secretPlaintext, "FUGA: el secreto en claro apareció en la respuesta a un no administrador"); + nonAdminBody.Should().NotContain(EncryptedPrefix, "el texto cifrado crudo (AES256:) no forma parte del contrato de respuesta"); + + // 4. Vista administrador: recibe el valor DESCIFRADO (round-trip correcto), pero el ciphertext + // crudo tampoco se expone. + var adminResponse = await admin.GetAsync($"/api/v1/app-configurations/{configId}", ct); + adminResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var adminBody = await adminResponse.Content.ReadAsStringAsync(ct); + + using (var adminJson = JsonDocument.Parse(adminBody)) + { + adminJson.RootElement.GetProperty("value").GetString() + .Should().Be(secretPlaintext, "el administrador ve el secreto descifrado (round-trip de AesValueEncryptionService)"); + } + + adminBody.Should().NotContain(EncryptedPrefix, "ni siquiera al administrador se le devuelve el ciphertext crudo: se descifra"); + } + + [Fact] + public async Task PlaintextConfig_IsReturnedAsIs_ForBothAudiences() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + const string plainValue = "7200"; + + var admin = CreateAdminClient(); + var nonAdmin = CreateNonAdminClient(); + + // Configuración Global NO cifrada: control negativo de la redacción — no debe redactarse. + var code = $"G088-PLAIN-{Guid.NewGuid():N}"; + var createResponse = await admin.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, + systemSuiteId = (Guid?)null, + moduleId = (Guid?)null, + code, + value = plainValue, + description = "G-088 configuración no cifrada (control)", + isInheritable = false, + isEncrypted = false, + }, ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(ct)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + // En reposo se guarda tal cual (sin prefijo de cifrado). + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var record = await db.AppConfigurations.IgnoreQueryFilters().FirstAsync(x => x.Id == configId, ct); + record.Value.Should().Be(plainValue); + record.IsEncrypted.Should().BeFalse(); + } + + var nonAdminResponse = await nonAdmin.GetAsync($"/api/v1/app-configurations/{configId}", ct); + using var nonAdminJson = JsonDocument.Parse(await nonAdminResponse.Content.ReadAsStringAsync(ct)); + nonAdminJson.RootElement.GetProperty("value").GetString() + .Should().Be(plainValue, "un valor no cifrado no se redacta para nadie"); + nonAdminJson.RootElement.GetProperty("value").GetString() + .Should().NotBe("***"); + } + + // --------------------------------------------------------------------------------------- + // 2. Kill-switch y porcentaje de rollout (feature flags) extremo a extremo + // --------------------------------------------------------------------------------------- + + [Fact] + public async Task BooleanFlag_KillSwitch_DisablesEvaluationEndToEnd() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + + var flagId = await CreateFlagAsync(admin, ct, flagType: "Boolean", rolloutPercentage: null); + + // Activo ⇒ habilitado (sin criterios: activo para todos). + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var enabled = await EvaluateAsync(admin, flagId, ct); + enabled.IsEnabled.Should().BeTrue("un flag booleano activo sin criterios está habilitado para todos"); + + // Kill-switch: desactivar ⇒ deshabilitado (fail-closed). + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var killed = await EvaluateAsync(admin, flagId, ct); + killed.IsEnabled.Should().BeFalse("el kill-switch (flag inactivo) debe deshabilitar la evaluación"); + killed.Reason.Should().Contain("not active"); + } + + [Fact] + public async Task PercentageFlag_ZeroRollout_EvaluatesDisabled() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + + var flagId = await CreateFlagAsync(admin, ct, flagType: "Percentage", rolloutPercentage: 0); + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var result = await EvaluateAsync(admin, flagId, ct, profileId: Guid.NewGuid()); + result.IsEnabled.Should().BeFalse("un rollout del 0% no habilita a ningún sujeto"); + result.Reason.Should().Contain("0"); + } + + [Fact] + public async Task PercentageFlag_FullRollout_EvaluatesEnabled() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + + var flagId = await CreateFlagAsync(admin, ct, flagType: "Percentage", rolloutPercentage: 100); + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var result = await EvaluateAsync(admin, flagId, ct, profileId: Guid.NewGuid()); + result.IsEnabled.Should().BeTrue("un rollout del 100% habilita a todo sujeto"); + result.Reason.Should().Contain("100"); + } + + [Fact] + public async Task PercentageFlag_MidRollout_IsDeterministicPerSubject() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + + var flagId = await CreateFlagAsync(admin, ct, flagType: "Percentage", rolloutPercentage: 50); + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // El mismo sujeto cae siempre en el mismo bucket ⇒ el resultado es estable entre llamadas + // (bucketing determinista FNV-1a). No acoplamos el test al valor concreto del bucket. + var subject = Guid.NewGuid(); + var first = await EvaluateAsync(admin, flagId, ct, profileId: subject); + var second = await EvaluateAsync(admin, flagId, ct, profileId: subject); + + second.IsEnabled.Should().Be(first.IsEnabled, "el porcentaje de rollout debe ser determinista por sujeto"); + } + + // --------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------- + + private async Task CreateFlagAsync(HttpClient client, CancellationToken ct, string flagType, int? rolloutPercentage) + { + var response = await client.PostAsJsonAsync("/api/v1/feature-flags", new + { + systemSuiteId = SeededSystemSuiteId, + tenantId = (Guid?)null, + flagCode = $"g088_flag_{Guid.NewGuid():N}", + flagType, + flagTargets = "g088-tests", + linkedResourceType = (string?)null, + linkedResourceId = (Guid?)null, + rolloutPercentage, + }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("featureFlagId").GetGuid(); + } + + private static async Task<(bool IsEnabled, string? Reason)> EvaluateAsync( + HttpClient client, Guid flagId, CancellationToken ct, Guid? profileId = null) + { + var response = await client.PostAsJsonAsync($"/api/v1/feature-flags/{flagId}/evaluate", new + { + tenantId = SeededTenantId, + profileId, + }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var root = payload.RootElement; + var reason = root.TryGetProperty("reason", out var r) && r.ValueKind == JsonValueKind.String ? r.GetString() : null; + return (root.GetProperty("isEnabled").GetBoolean(), reason); + } + + // En el host de pruebas (entorno Development) DevAuthMiddleware corre ANTES de la autenticación + // y es quien inicializa el ITenantContext a partir de las cabeceras X-Tenant-Id / X-Is-Internal-Admin + // (la autenticación de TestAuthHandler sólo gobierna el IUserContext). Por eso el privilegio + // transversal se conmuta con la cabecera X-Is-Internal-Admin de DevAuth, no vía claims. + private HttpClient CreateAdminClient() + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + return client; + } + + private HttpClient CreateNonAdminClient() + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "false"); + return client; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSeedCompletenessTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSeedCompletenessTests.cs index 6d1eed3d..352158b2 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSeedCompletenessTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSeedCompletenessTests.cs @@ -39,8 +39,15 @@ public async Task SeededTenants_ShouldHaveCompleteConfigurationCatalogs() appConfigCodes.Should().Contain("UI_LANGUAGE_DEFAULT"); appConfigCodes.Should().Contain("UI_TIMEZONE_DEFAULT"); + // G-014: las feature flags se siembran POR SUITE (ConfigurationDevDataSeeder. + // GetFeatureFlagDefinitions: UMS=7, WMS=3), no un catálogo fijo por inquilino. Un + // inquilino con solo suite WMS obtiene 3 (BEYONDNET: +PAITA_AGROEXPORT = 4). El umbral + // fijo >=10 asumía que todo inquilino tuviera suites UMS+WMS, lo que el seed NO + // garantiza. La completitud real es que cada inquilino sembrado tenga las flags de + // su(s) suite(s): no vacío. var featureFlags = await featureFlagRepository.GetAllAsync(tenantId, ct); - featureFlags.Count.Should().BeGreaterOrEqualTo(10); + featureFlags.Should().NotBeEmpty( + because: "cada inquilino sembrado con suite debe recibir las feature flags de su(s) suite(s)"); var tenantParameters = await tenantParameterRepository.GetByTenantIdAsync(tenantId, ct); tenantParameters.Should().HaveCount(10); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ParameterDefinitionSoftDeleteE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ParameterDefinitionSoftDeleteE2ETests.cs new file mode 100644 index 00000000..56f9b5cb --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ParameterDefinitionSoftDeleteE2ETests.cs @@ -0,0 +1,451 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Infrastructure.Configuration; +using Ums.Infrastructure.Persistence.Configuration.Entities; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Configuration; + +/// +/// E2E sobre PostgreSQL real (Testcontainers) de la política de borrado del catálogo de parámetros: +/// solo existe borrado lógico. Sobre esta configuración se hacen consultas históricas, así que +/// la fila nunca puede desaparecer: una definición retirada hace un año tiene que seguir explicando +/// por qué el sistema se comportó como se comportó. +/// +/// Lo que fija cada prueba: +/// 1. `DELETE /parameter-definitions/{id}` responde 204 —el contrato HTTP no cambió— y la FILA +/// SIGUE EN LA BASE con `IsDeleted = true`. Sin esta segunda mitad, el cambio no está demostrado. +/// 2. Tras el borrado, el GET responde 404 y la definición desaparece del listado. +/// 3. Regla transaccional: con un valor global VIVO, el borrado se rechaza con 409. +/// 4. Con ese mismo valor ya eliminado lógicamente, el borrado sí procede — y el valor también +/// sigue en la base. +/// +/// Usa el host PostgreSQL (no el InMemory) porque solo ahí comandos y consultas del catálogo de +/// parámetros comparten el mismo almacén, que es justo lo que hay que observar. +/// +[Collection("PostgreSql")] +public sealed class ParameterDefinitionSoftDeleteE2ETests +{ + private static readonly Guid SeededTenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + public ParameterDefinitionSoftDeleteE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + [Fact] + public async Task Delete_Devuelve204_YLaFilaSigueEnLaBase() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, code) = await CreateDefinitionAsync(admin, ct); + + var deleteResponse = await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent, "el contrato HTTP no cambia"); + + // LA PRUEBA DE LA POLÍTICA: la fila sigue ahí, marcada, con su sello de quién y cuándo. + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var record = await db.Set() + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == definitionId, ct); + + record.Should().NotBeNull("el borrado es lógico: la fila jamás se elimina"); + record!.IsDeleted.Should().BeTrue(); + record.Code.Should().Be(code, "el dato histórico se conserva íntegro"); + record.DeletedAtUtc.Should().NotBeNull("hay que poder decir cuándo se retiró"); + record.DeletedBy.Should().NotBeNullOrWhiteSpace("hay que poder decir quién la retiró"); + record.IsActive.Should().BeFalse("una definición eliminada tampoco resuelve"); + } + + // …y, aun así, para las lecturas ya no existe. + (await admin.GetAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound); + + var listBody = await (await admin.GetAsync("/api/v1/parameter-definitions", ct)) + .Content.ReadAsStringAsync(ct); + listBody.Should().NotContain(code, "lo eliminado no aparece en los listados"); + } + + [Fact] + public async Task Delete_ConValorGlobalVivo_Devuelve409() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + await CreateGlobalValueAsync(admin, definitionId, ct); + + var deleteResponse = await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.Conflict, + because: "no se elimina lógicamente algo con referencias reales vivas"); + + var body = await deleteResponse.Content.ReadAsStringAsync(ct); + body.Should().Contain("parameter_has_active_values"); + body.Should().Contain("ParameterGlobalValue", "la respuesta dice QUÉ bloquea"); + + // La definición sigue viva: un rechazo no deja rastro. + (await admin.GetAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Delete_ConValorGlobalYaEliminadoLogicamente_SiProcede() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + var valueId = await CreateGlobalValueAsync(admin, definitionId, ct); + + // Con el valor vivo, bloquea… + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + + // …se elimina lógicamente el dependiente… + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}/global-values/{valueId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // …y entonces la definición ya se puede eliminar: la referencia dejó de ser real. + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Ninguna de las dos filas se perdió. + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var definitionRecord = await db.Set() + .IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == definitionId, ct); + definitionRecord.Should().NotBeNull(); + definitionRecord!.IsDeleted.Should().BeTrue(); + + var valueRecord = await db.Set() + .IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == valueId, ct); + valueRecord.Should().NotBeNull("el valor dependiente tampoco se borra físicamente"); + valueRecord!.StatusId.Should().Be(ConfigStatus.Deleted.Id); + } + + [Fact] + public async Task Delete_ConOverrideDeInquilinoVivo_Devuelve409_YTrasEliminarloProcede() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + var createValue = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/tenant-values", + new { definitionId, tenantId = SeededTenantId, value = "48" }, ct); + createValue.StatusCode.Should().Be(HttpStatusCode.Created); + using var valuePayload = JsonDocument.Parse(await createValue.Content.ReadAsStringAsync(ct)); + var valueId = valuePayload.RootElement.GetGuid(); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}/tenant-values/{valueId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var valueRecord = await db.Set() + .IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == valueId, ct); + valueRecord.Should().NotBeNull(); + valueRecord!.StatusId.Should().Be(ConfigStatus.Deleted.Id); + } + + [Fact] + public async Task Delete_DosVeces_LaSegundaDevuelve404() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound, "para las lecturas ya no existe"); + } + + /// + /// La ranura del catálogo se LIBERA al borrar. Contra PostgreSQL real, que es lo único que + /// demuestra que el índice único parcial existe: si siguiera siendo total, el segundo alta no + /// devolvería 201 sino un 23505 convertido en 500. + /// + /// Cubre las cuatro mitades: se recrea, las dos filas siguen ahí, la lectura por código resuelve + /// la viva, y duplicar una viva sigue siendo conflicto legible. + /// + [Fact] + public async Task Definicion_TrasBorrarse_LiberaSuCodigo_YLaLecturaResuelveLaViva() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (primeraId, code) = await CreateDefinitionAsync(admin, ct); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (a) El MISMO código vuelve a poder declararse. + var segundaId = await CreateDefinitionWithCodeAsync(admin, code, ct); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // (b) Las dos filas conviven; la vieja marcada. Se lee SIN filtros: preguntar a la API no + // distinguiría «oculto» de «borrado» (ADR-0164 §5). + var filas = await db.Set() + .IgnoreQueryFilters() + .Where(x => x.Code == code) + .ToListAsync(ct); + + filas.Should().HaveCount(2, "liberar la ranura no borra la fila anterior"); + filas.Single(x => x.Id == primeraId).IsDeleted.Should().BeTrue(); + filas.Single(x => x.Id == segundaId).IsDeleted.Should().BeFalse(); + + // (c) La lectura por código resuelve LA VIVA: el filtro global deja una sola candidata. + var repositorio = scope.ServiceProvider.GetRequiredService(); + var viva = await repositorio.GetByCodeAsync(code, ct); + viva.Should().NotBeNull(); + viva!.Props.Id.GetValue().Should().Be(segundaId, "nunca se resuelve la lápida"); + + (await admin.GetAsync($"/api/v1/parameter-definitions/{segundaId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + (await admin.GetAsync($"/api/v1/parameter-definitions/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound); + + // (d) Duplicar una VIVA sigue siendo conflicto de dominio legible, no un error de índice. + var duplicada = await PostDefinitionAsync(admin, code, ct); + duplicada.IsSuccessStatusCode.Should().BeFalse("dos definiciones vivas no pueden compartir código"); + (await duplicada.Content.ReadAsStringAsync(ct)) + .Should().Contain("parameter_code_not_unique", "el rechazo lo nombra el dominio, no PostgreSQL"); + } + + /// + /// Mismo criterio para los VALORES: global y de inquilino. Borrar el valor de un parámetro no + /// puede impedir volver a fijarlo, y las lápidas no pueden hacer ambigua la resolución. + /// + [Fact] + public async Task ValorGlobal_TrasBorrarse_SePuedeVolverAFijar_YSoloUnoVivo() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + var primerValorId = await CreateGlobalValueAsync(admin, definitionId, ct); + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}/global-values/{primerValorId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (a) Vuelve a admitir un valor global. + var segundoValorId = await CreateGlobalValueAsync(admin, definitionId, ct); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // (b) Las dos filas siguen en la base. + var filas = await db.Set() + .IgnoreQueryFilters() + .Where(x => x.ParameterDefinitionId == definitionId) + .ToListAsync(ct); + filas.Should().HaveCount(2); + filas.Single(x => x.Id == primerValorId).StatusId.Should().Be(ConfigStatus.Deleted.Id); + filas.Single(x => x.Id == segundoValorId).StatusId.Should().NotBe(ConfigStatus.Deleted.Id); + + // (c) La resolución devuelve el VIVO. Se instancia el servicio contra este mismo DbContext + // porque el host no lo registra; lo que importa es su consulta, no cómo se resuelve. + // Sin el filtro de estado, el `ToDictionary` interno reventaría por clave duplicada en + // cuanto conviven la lápida y el valor nuevo — la ambigüedad se paga en tiempo de + // ejecución, no en una aserción. + var resolucion = new ParameterResolutionService(db); + var globales = await resolucion.GetGlobalParametersAsync(ct); + globales.Should().ContainSingle(p => p.DefinitionId == definitionId); + + var repositorio = scope.ServiceProvider.GetRequiredService(); + var vivo = await repositorio.GetByDefinitionIdAsync(definitionId, ct); + vivo.Should().NotBeNull(); + vivo!.Props.Id.GetValue().Should().Be(segundoValorId); + + // (d) Un segundo valor sobre el VIVO sigue rechazándose. + var duplicado = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/global-values", + new { definitionId, value = "99" }, ct); + duplicado.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ValorDeInquilino_TrasBorrarse_SePuedeVolverAFijar_YSoloUnoVivo() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + var primerValorId = await CreateTenantValueAsync(admin, definitionId, "48", ct); + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}/tenant-values/{primerValorId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var segundoValorId = await CreateTenantValueAsync(admin, definitionId, "72", ct); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var filas = await db.Set() + .IgnoreQueryFilters() + .Where(x => x.TenantId == SeededTenantId && x.ParameterDefinitionId == definitionId) + .ToListAsync(ct); + filas.Should().HaveCount(2); + filas.Single(x => x.Id == primerValorId).StatusId.Should().Be(ConfigStatus.Deleted.Id); + filas.Single(x => x.Id == segundoValorId).StatusId.Should().NotBe(ConfigStatus.Deleted.Id); + + var repositorio = scope.ServiceProvider.GetRequiredService(); + var vivo = await repositorio.GetByTenantAndDefinitionAsync(SeededTenantId, definitionId, ct); + vivo.Should().NotBeNull(); + vivo!.Props.Id.GetValue().Should().Be(segundoValorId, "la resolución del inquilino usa el override VIVO"); + + var duplicado = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/tenant-values", + new { definitionId, tenantId = SeededTenantId, value = "96" }, ct); + duplicado.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Delete_ComoNoAdministrador_Devuelve403() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + var nonAdmin = CreateClient(isInternalAdmin: false); + + (await nonAdmin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.Forbidden, "la autorización del borrado no cambia"); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static async Task<(Guid Id, string Code)> CreateDefinitionAsync(HttpClient admin, CancellationToken ct) + { + // Código realista del dominio aduanero + sufijo único; Code lo normaliza a mayúsculas. + var code = $"SESSION_TIMEOUT_{Guid.NewGuid():N}"[..24].ToUpperInvariant(); + return (await CreateDefinitionWithCodeAsync(admin, code, ct), code); + } + + private static async Task CreateDefinitionWithCodeAsync(HttpClient admin, string code, CancellationToken ct) + { + var response = await PostDefinitionAsync(admin, code, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetGuid(); + } + + private static Task PostDefinitionAsync(HttpClient admin, string code, CancellationToken ct) + => admin.PostAsJsonAsync("/api/v1/parameter-definitions", new + { + code, + name = "Tiempo de expiración de sesión", + description = "Minutos de inactividad antes de cerrar la sesión del operador aduanero", + dataTypeId = 2, // Number + defaultValue = "30", + scopeId = 3, // GlobalAndTenant + isMandatory = false, + displayOrder = 10, + }, ct); + + private static async Task CreateTenantValueAsync( + HttpClient admin, Guid definitionId, string value, CancellationToken ct) + { + var response = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/tenant-values", + new { definitionId, tenantId = SeededTenantId, value }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetGuid(); + } + + private static async Task CreateGlobalValueAsync(HttpClient admin, Guid definitionId, CancellationToken ct) + { + var response = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/global-values", + new { definitionId, value = "45" }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetGuid(); + } + + private HttpClient CreateAdminClient() => CreateClient(isInternalAdmin: true); + + private HttpClient CreateClient(bool isInternalAdmin) + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", isInternalAdmin ? "true" : "false"); + return client; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs index 481791e5..f91ea844 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs @@ -37,6 +37,13 @@ public AccessEnforcementPolicyE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077 (evolith-core#18): estas pruebas aprovisionan SystemSuite + Role sobre un + // inquilino CLIENT auxiliar (X-Tenant-Id apunta a él). Eso es una acción ON-BEHALF que + // sólo el operador de gestión (internal-admin) puede ejecutar; sin esta cabecera, + // TenantScopePolicy devuelve AUTH_015 → 400 en el setup. No hay tests de denegación en + // esta clase: el único 4xx afirmado (CreatePolicy_WithoutProfileOrRole → 400) proviene + // de la validación de dominio (falta profile/role), no del scope, y sigue siendo válido. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -201,12 +208,14 @@ private async Task CreateTenantId(CancellationToken ct) type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, - isManagementOwner = true + // G-037: el management owner es único a nivel de sistema; un inquilino CLIENT + // auxiliar nunca debe reclamarlo (colisionaría con el pre-check → 409). + isManagementOwner = false }, ct); response.EnsureSuccessStatusCode(); var location = response.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/IntegridadYResultPatternE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/IntegridadYResultPatternE2ETests.cs new file mode 100644 index 00000000..4bb754f1 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/IntegridadYResultPatternE2ETests.cs @@ -0,0 +1,188 @@ +using Ums.Presentation.IntegrationTest.Infrastructure; +using Ums.Infrastructure.Persistence.Seeders; + +namespace Ums.Presentation.IntegrationTest.E2E; + +/// +/// E2E dedicados de integridad de datos y patrón Result (gaps G-037, G-045, G-046). +/// +/// Verifican que caminos que antes colapsaban a 500 ahora resuelven un +/// Result.Failure mapeado al estado HTTP correcto: +/// - G-037: un segundo management owner (por POST /tenants y por +/// set-management-owner) devuelve 409, no 500. +/// - G-045: un enum inválido en el payload y un listado sin paginación +/// obligatoria nunca colapsan a 500 (400/422, sin excepciones de control de flujo). +/// - G-046: una referencia fallbackToId colgante en una IdpConfiguration +/// se rechaza por integridad referencial (4xx), no colapsa a 500. +/// +/// Usa (dev-seed activo → el inquilino BEYONDNET +/// ya es el único management owner), de modo que las aserciones de unicidad son +/// deterministas con independencia del orden de ejecución. +/// +public sealed class IntegridadYResultPatternE2ETests : IClassFixture +{ + private readonly HttpClient _client; + + public IntegridadYResultPatternE2ETests(UmsApiWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); + _client.DefaultRequestHeaders.Add("X-User-Name", "integridad-e2e"); + _client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.InternalAdminTenantId); + } + + // ───────────────────────────────────────────────────────────────────────── + // G-037 — un segundo management owner devuelve 409, no 500 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task CrearSegundoManagementOwner_Devuelve409() + { + var ct = TestContext.Current.CancellationToken; + + // Garantiza que exista un management owner (lo crea si aún no lo hubiera). + var primero = await _client.PostAsJsonAsync("/api/v1/tenants", NewOwnerPayload(), ct); + primero.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.Conflict); + + // Con un owner ya existente, un segundo intento debe rechazarse con 409 (no 500). + var segundo = await _client.PostAsJsonAsync("/api/v1/tenants", NewOwnerPayload(), ct); + + segundo.StatusCode.Should().Be(HttpStatusCode.Conflict, + because: "el management owner es único a nivel de sistema; el pre-check de dominio debe resolver 409 antes de persistir"); + } + + [Fact] + public async Task SetManagementOwner_SobreSegundoInquilino_Devuelve409() + { + var ct = TestContext.Current.CancellationToken; + + // Asegura que exista un management owner en el sistema. + (await _client.PostAsJsonAsync("/api/v1/tenants", NewOwnerPayload(), ct)) + .StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.Conflict); + + // Crea un inquilino CLIENT que NO es owner. + var createRes = await _client.PostAsJsonAsync("/api/v1/tenants", NewClientPayload(), ct); + createRes.StatusCode.Should().Be(HttpStatusCode.Created); + var tenantId = await ReadGuid(createRes, "tenantId", ct); + + // Intentar otorgarle la propiedad de gestión debe devolver 409, no 500. + var setRes = await _client.PostAsJsonAsync( + $"/api/v1/tenants/{tenantId}/set-management-owner", new { value = true }, ct); + + setRes.StatusCode.Should().Be(HttpStatusCode.Conflict, + because: "ya existe un management owner; el segundo debe rechazarse con 409 vía chequeo explícito de dominio"); + } + + // ───────────────────────────────────────────────────────────────────────── + // G-045 — enum inválido y listado sin paginación no colapsan a 500 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task CrearTenant_TipoEnumInvalido_NoColapsaA500() + { + var ct = TestContext.Current.CancellationToken; + + var payload = new + { + code = $"ENUM{Guid.NewGuid():N}"[..10].ToUpperInvariant(), + name = "Tenant con tipo inválido", + type = "NO_ES_UN_TIPO_VALIDO", + idpStrategy = (string?)null, + companyReference = (string?)null, + isManagementOwner = false, + }; + + var res = await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct); + + ((int)res.StatusCode).Should().BeLessThan(500, + because: "un enum desconocido debe resolver Result.Failure (400/422), nunca una NullReferenceException que colapse a 500"); + res.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity); + } + + [Fact] + public async Task ListarSystemSuites_SinPaginacionObligatoria_NoColapsaA500() + { + var ct = TestContext.Current.CancellationToken; + + // page/pageSize son parámetros de query obligatorios (no anulables). Omitirlos + // producía un 500 disfrazado de «Internal Server Error»; ahora ASP.NET lo resuelve + // como 400 vía el GlobalExceptionHandler (BadHttpRequestException → 400). + var res = await _client.GetAsync("/api/v1/system-suites", ct); + + ((int)res.StatusCode).Should().BeLessThan(500, + because: "un listado sin paginación obligatoria debe devolver 400, no un 500 disfrazado"); + res.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ───────────────────────────────────────────────────────────────────────── + // G-046 — integridad referencial: fallbackToId colgante no colapsa a 500 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task CrearIdpConfiguration_FallbackColgante_NoColapsaA500() + { + var ct = TestContext.Current.CancellationToken; + + var payload = new + { + tenantId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId), + systemSuiteId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId), + providerType = "AZURE_AD", + domainHints = new[] { "corp.local" }, + configPayload = "{\"authority\":\"https://login.microsoftonline.com/tenant-x\"}", + secretRef = "kv/idp/dangling", + resolutionPriority = 10, + // Referencia colgante: ninguna IdpConfiguration con este id existe. + fallbackToId = (Guid?)Guid.NewGuid(), + }; + + var res = await _client.PostAsJsonAsync("/api/v1/idp-configurations", payload, ct); + + ((int)res.StatusCode).Should().BeLessThan(500, + because: "un fallbackToId colgante debe rechazarse por integridad referencial (4xx), nunca colapsar a 500"); + ((int)res.StatusCode).Should().BeGreaterThanOrEqualTo(400, + because: "la creación con una referencia colgante no debe tener éxito"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static object NewOwnerPayload() + { + var uid = Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(); + return new + { + code = $"OWN{uid}", + name = $"Owner {uid}", + type = "INTERNAL", + idpStrategy = (string?)null, + companyReference = (string?)null, + isManagementOwner = true, + }; + } + + private static object NewClientPayload() + { + var uid = Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(); + return new + { + code = $"CLI{uid}", + name = $"Client {uid}", + type = "CLIENT", + idpStrategy = (string?)null, + companyReference = (string?)null, + isManagementOwner = false, + }; + } + + private static async Task ReadGuid(HttpResponseMessage response, string property, CancellationToken ct) + { + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty(property).GetGuid(); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs index 6d5f3c6f..6c0543fa 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs @@ -7,7 +7,7 @@ namespace Ums.Presentation.IntegrationTest.E2E; /// /// E2E tests for the Role bounded context (Authorization). -/// Covers creation, update, lifecycle status, and GraphQL list exposure against +/// Covers creation, update, lifecycle status, and REST list exposure against /// a real SQL Server Testcontainer. /// /// Each test creates its own Tenant + SystemSuite to guarantee isolation. @@ -32,6 +32,11 @@ public RoleE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077 (G-014 residual): tras fijar X-Tenant-Id a un inquilino CLIENT auxiliar, + // el aprovisionamiento (suites/roles) es una operación ON-BEHALF que solo el + // operador management-owner/internal-admin puede ejecutar (TenantScopePolicy → AUTH_015). + // Estos E2E aprovisionan recursos para el CLIENT, modelando al internal-admin de BEYONDNET. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -40,7 +45,7 @@ public RoleE2ETests(PostgreSqlContainerFixture fixture) } [Fact] - public async Task CreateRole_ValidPayload_Returns201AndAppearsInGraphQl() + public async Task CreateRole_ValidPayload_Returns201AndAppearsInRestList() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -63,11 +68,10 @@ public async Task CreateRole_ValidPayload_Returns201AndAppearsInGraphQl() var roleId = await ReadGuid(createResponse, "roleId", ct); - using var doc = await GqlRolesBySystemSuiteAsync(suiteId, ct); - var roles = doc.RootElement.GetProperty("data").GetProperty("rolesBySystemSuite"); + using var doc = await GetRolesBySystemSuiteAsync(suiteId, ct); - roles.EnumerateArray().Any(role => role.GetProperty("roleId").GetGuid() == roleId).Should().BeTrue( - because: "the created role should be visible in the system suite graph"); + doc.RootElement.EnumerateArray().Any(role => role.GetProperty("roleId").GetGuid() == roleId).Should().BeTrue( + because: "the created role should be visible in the system suite roles list"); } [Fact] @@ -91,8 +95,8 @@ public async Task UpdateRole_ValidPayload_Returns204AndPersistsChanges() updateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlRolesBySystemSuiteAsync(suiteId, ct); - var role = doc.RootElement.GetProperty("data").GetProperty("rolesBySystemSuite") + using var doc = await GetRolesBySystemSuiteAsync(suiteId, ct); + var role = doc.RootElement .EnumerateArray() .First(candidate => candidate.GetProperty("roleId").GetGuid() == roleId); @@ -113,9 +117,9 @@ public async Task SetRoleStatus_DeactivateAndReactivate_Returns204AndPersistsSta var deactivateResponse = await _client.PostAsync($"/api/v1/system-suites/{suiteId}/roles/{roleId}/deactivate", null, ct); deactivateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - using (var afterDeactivate = await GqlRolesBySystemSuiteAsync(suiteId, ct)) + using (var afterDeactivate = await GetRolesBySystemSuiteAsync(suiteId, ct)) { - afterDeactivate.RootElement.GetProperty("data").GetProperty("rolesBySystemSuite") + afterDeactivate.RootElement .EnumerateArray() .First(candidate => candidate.GetProperty("roleId").GetGuid() == roleId) .GetProperty("isActive").GetBoolean().Should().BeFalse(); @@ -124,8 +128,8 @@ public async Task SetRoleStatus_DeactivateAndReactivate_Returns204AndPersistsSta var activateResponse = await _client.PostAsync($"/api/v1/system-suites/{suiteId}/roles/{roleId}/activate", null, ct); activateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var afterActivate = await GqlRolesBySystemSuiteAsync(suiteId, ct); - afterActivate.RootElement.GetProperty("data").GetProperty("rolesBySystemSuite") + using var afterActivate = await GetRolesBySystemSuiteAsync(suiteId, ct); + afterActivate.RootElement .EnumerateArray() .First(candidate => candidate.GetProperty("roleId").GetGuid() == roleId) .GetProperty("isActive").GetBoolean().Should().BeTrue(); @@ -158,37 +162,13 @@ public async Task CreateRole_DuplicateCode_Returns409() duplicate.StatusCode.Should().BeOneOf(HttpStatusCode.Conflict, HttpStatusCode.BadRequest); } - private async Task GqlRolesBySystemSuiteAsync(Guid suiteId, CancellationToken ct) + private async Task GetRolesBySystemSuiteAsync(Guid suiteId, CancellationToken ct) { - var query = $$""" - query RolesBySystemSuite($systemSuiteId: UUID!) { - rolesBySystemSuite(systemSuiteId: $systemSuiteId) { - roleId - tenantId - systemSuiteId - parentRoleId - code - value - description - hierarchyLevel - promotionOrder - isActive - } - } - """; + var response = await _client.GetAsync($"/api/v1/system-suites/{suiteId}/roles", ct); - var response = await _client.PostAsJsonAsync("/graphql", new - { - query, - variables = new { systemSuiteId = suiteId }, - }, ct); - - response.EnsureSuccessStatusCode(); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - doc.RootElement.TryGetProperty("errors", out _).Should().BeFalse( - because: "the GraphQL query should not return errors"); - return doc; + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); } private async Task CreateRoleId(Guid suiteId, CancellationToken ct) @@ -235,12 +215,14 @@ private async Task CreateTenantId(CancellationToken ct) type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, - isManagementOwner = true + // G-037: el management owner es único a nivel de sistema; un inquilino CLIENT + // auxiliar nunca debe reclamarlo (colisionaría con el pre-check → 409). + isManagementOwner = false }, ct); response.EnsureSuccessStatusCode(); var location = response.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteE2ETests.cs index 74bb34b7..d559ba2a 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteE2ETests.cs @@ -15,7 +15,7 @@ namespace Ums.Presentation.IntegrationTest.E2E; /// /// Architecture: /// - Commands → REST API (POST / PUT / DELETE) -/// - Queries → GraphQL (POST /graphql) +/// - Queries → REST API (GET) /// /// Each test creates its own Tenant + SystemSuite to guarantee isolation. /// Prerequisites: Docker must be running locally. @@ -40,6 +40,13 @@ public SystemSuiteE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077: aprovisionar recursos (system-suites, módulos, roles, acciones, app-settings) es + // una operación ON-BEHALF que solo el internal-admin/management-owner puede ejecutar. Estos E2E + // fijan X-Tenant-Id a un inquilino CLIENT auxiliar (isManagementOwner:false), por lo que sin + // contexto internal-admin explícito TenantScopePolicy devuelve AUTH_015 → 400. Se modela al + // internal-admin de BEYONDNET aprovisionando en nombre del CLIENT. Esta clase no contiene tests de + // denegación/aislamiento, así que el header global es seguro y no altera ningún 4xx esperado. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -59,7 +66,7 @@ public async Task CreateSystemSuite_ValidPayload_Returns201WithId() var tenantId = await CreateTenantId(ct); var response = await _client.PostAsJsonAsync("/api/v1/system-suites", NewSuitePayload(tenantId), ct); - + response.StatusCode.Should().Be(HttpStatusCode.Created); response.Headers.Location.Should().NotBeNull(); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); @@ -67,7 +74,7 @@ public async Task CreateSystemSuite_ValidPayload_Returns201WithId() } [Fact] - public async Task CreateSystemSuite_MissingName_Returns422() + public async Task CreateSystemSuite_MissingName_Returns400() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -75,7 +82,11 @@ public async Task CreateSystemSuite_MissingName_Returns422() var payload = new { code = UniqueCode("INV"), description = "No Name" }; // Missing Name var res = await _client.PostAsJsonAsync("/api/v1/system-suites", payload, ct); - res.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + // La API devuelve 400 (BadRequest) de forma consistente para errores de validación de + // campos (p. ej. CreateUserAccount_InvalidEmail_Returns400); el 422 se reserva para + // conflicto/duplicado. Se alinea el test al contrato real. (Convención 400 vs 422 global + // = decisión de producto, fuera de alcance de este test.) + res.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] @@ -94,11 +105,11 @@ public async Task CreateSystemSuite_DuplicateCode_SameTenant_Returns409() } // ───────────────────────────────────────────────────────────────────────── - // READ — via GraphQL + // READ — via REST // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetSystemSuiteById_ExistingSuite_GqlReturnsCorrectFields() + public async Task GetSystemSuiteById_ExistingSuite_ReturnsCorrectFields() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -109,10 +120,9 @@ public async Task GetSystemSuiteById_ExistingSuite_GqlReturnsCorrectFields() createRes.StatusCode.Should().Be(HttpStatusCode.Created); var suiteId = await ReadGuid(createRes, "systemSuiteId", ct); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - var suite = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + var suite = doc.RootElement; - suite.ValueKind.Should().NotBe(JsonValueKind.Null, because: "suite should exist"); suite.GetProperty("systemSuiteId").GetGuid().Should().Be(suiteId); suite.GetProperty("tenantId").GetGuid().Should().Be(tenantId); suite.GetProperty("code").GetString().Should().Be(payload.Code); @@ -123,28 +133,26 @@ public async Task GetSystemSuiteById_ExistingSuite_GqlReturnsCorrectFields() } [Fact] - public async Task GetSystemSuiteById_NonExistent_GqlReturnsNull() + public async Task GetSystemSuiteById_NonExistent_Returns404() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - using var doc = await GqlSuiteByIdAsync(Guid.NewGuid(), ct); - var suite = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById"); + var res = await _client.GetAsync($"/api/v1/system-suites/{Guid.NewGuid()}", ct); - suite.ValueKind.Should().Be(JsonValueKind.Null, - because: "querying a non-existent suite ID should return null"); + res.StatusCode.Should().Be(HttpStatusCode.NotFound, + because: "querying a non-existent suite ID should return 404"); } [Fact] - public async Task GetSystemSuites_Pagination_GqlReturnsPageMetadata() + public async Task GetSystemSuites_Pagination_ReturnsPageMetadata() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - const string gql = "{ systemSuites(page: 1, pageSize: 5) { page pageSize totalItems items { systemSuiteId code } } }"; - using var doc = await GqlQueryAsync(gql, ct); + using var doc = await GetSuitesAsync("page=1&pageSize=5", ct); - var list = doc.RootElement.GetProperty("data").GetProperty("systemSuites"); + var list = doc.RootElement; list.GetProperty("page").GetInt32().Should().Be(1); list.GetProperty("pageSize").GetInt32().Should().Be(5); list.GetProperty("totalItems").GetInt32().Should().BeGreaterThanOrEqualTo(0); @@ -152,7 +160,7 @@ public async Task GetSystemSuites_Pagination_GqlReturnsPageMetadata() } [Fact] - public async Task GetSystemSuites_FilterByTenantId_GqlOnlyReturnsTenantSuites() + public async Task GetSystemSuites_FilterByTenantId_OnlyReturnsTenantSuites() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -161,10 +169,9 @@ public async Task GetSystemSuites_FilterByTenantId_GqlOnlyReturnsTenantSuites() (await _client.PostAsJsonAsync("/api/v1/system-suites", NewSuitePayload(tenantId), ct)) .StatusCode.Should().Be(HttpStatusCode.Created); - var gql = $"{{ systemSuites(page: 1, pageSize: 50, tenantId: \"{tenantId}\") {{ items {{ systemSuiteId tenantId code }} }} }}"; - using var doc = await GqlQueryAsync(gql, ct); + using var doc = await GetSuitesAsync($"page=1&pageSize=50&tenantId={tenantId}", ct); - var items = doc.RootElement.GetProperty("data").GetProperty("systemSuites").GetProperty("items"); + var items = doc.RootElement.GetProperty("items"); items.GetArrayLength().Should().BeGreaterThan(0); foreach (var item in items.EnumerateArray()) { @@ -189,8 +196,8 @@ public async Task UpdateSystemSuite_ValidPayload_Returns204AndPersistsChanges() var updateRes = await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}", updatePayload, ct); updateRes.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - var suite = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + var suite = doc.RootElement; suite.GetProperty("name").GetString().Should().Be("Updated Suite Name"); suite.GetProperty("description").GetString().Should().Be("Updated description for E2E test"); } @@ -219,14 +226,15 @@ public async Task SetSystemSuiteStatus_Inactive_Returns204AndPersistsStatus() var ct = TestContext.Current.CancellationToken; var suiteId = await CreateSuiteId(ct); - var payload = new { systemSuiteId = suiteId, status = "Inactive" }; + // Los estados de SUITE son Active/Maintenance/Deprecated (SystemStatus); "Inactive" solo aplica a + // módulos (ModuleStatus). Se usa "Maintenance" como estado no-activo válido. + var payload = new { systemSuiteId = suiteId, status = "Maintenance" }; var res = await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}/status", payload, ct); - + res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - doc.RootElement.GetProperty("data").GetProperty("systemSuiteById") - .GetProperty("status").GetString().Should().Be("Inactive"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + doc.RootElement.GetProperty("status").GetString().Should().Be("Maintenance"); } [Fact] @@ -236,14 +244,15 @@ public async Task SetSystemSuiteStatus_BackToActive_Returns204() var ct = TestContext.Current.CancellationToken; var suiteId = await CreateSuiteId(ct); - await _client.PostAsync($"/api/v1/system-suites/{suiteId}/status?status=Inactive", null, ct); + // El endpoint de status es PUT con body { status } (no POST con query param); estados de suite + // válidos: Active/Maintenance/Deprecated. + await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}/status", new { systemSuiteId = suiteId, status = "Maintenance" }, ct); - var res = await _client.PostAsync($"/api/v1/system-suites/{suiteId}/status?status=Active", null, ct); + var res = await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}/status", new { systemSuiteId = suiteId, status = "Active" }, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - doc.RootElement.GetProperty("data").GetProperty("systemSuiteById") - .GetProperty("status").GetString().Should().Be("Active"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + doc.RootElement.GetProperty("status").GetString().Should().Be("Active"); } // ───────────────────────────────────────────────────────────────────────── @@ -263,8 +272,8 @@ public async Task AddModule_ValidPayload_Returns204AndAppearsInSuite() var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", payload, ct); res.StatusCode.Should().Be(HttpStatusCode.Created); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - var modules = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById").GetProperty("modules"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + var modules = doc.RootElement.GetProperty("modules"); var found = modules.EnumerateArray().Any(m => m.GetProperty("code").GetString() == moduleCode); found.Should().BeTrue(because: "the module should appear in the suite after being added"); } @@ -280,9 +289,9 @@ public async Task UpdateModule_ValidPayload_Returns204AndPersistsChanges() await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", new { systemSuiteId = suiteId, code = moduleCode, name = "Original", description = "Desc", sortOrder = 1 }, ct); - // Get moduleId via GraphQL - using var beforeDoc = await GqlSuiteByIdAsync(suiteId, ct); - var moduleId = beforeDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + // Get moduleId via REST + using var beforeDoc = await GetSuiteByIdAsync(suiteId, ct); + var moduleId = beforeDoc.RootElement .GetProperty("modules").EnumerateArray() .First(m => m.GetProperty("code").GetString() == moduleCode) .GetProperty("id").GetGuid(); @@ -291,8 +300,8 @@ await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", var res = await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}", updatePayload, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var afterDoc = await GqlSuiteByIdAsync(suiteId, ct); - var module = afterDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + using var afterDoc = await GetSuiteByIdAsync(suiteId, ct); + var module = afterDoc.RootElement .GetProperty("modules").EnumerateArray() .First(m => m.GetProperty("id").GetGuid() == moduleId); module.GetProperty("name").GetString().Should().Be("Updated Module"); @@ -310,21 +319,26 @@ public async Task ModuleLifecycle_DeactivateActivateRemove_FullCycle() await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", new { systemSuiteId = suiteId, code, name = "Lifecycle Module", description = "E2E lifecycle", sortOrder = 5 }, ct); - // Get moduleId via GraphQL - using var addedDoc = await GqlSuiteByIdAsync(suiteId, ct); - var moduleId = addedDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + // Get moduleId via REST + using var addedDoc = await GetSuiteByIdAsync(suiteId, ct); + var moduleId = addedDoc.RootElement .GetProperty("modules").EnumerateArray() .First(m => m.GetProperty("code").GetString() == code) .GetProperty("id").GetGuid(); + // Los módulos se crean Inactivos (se activan explícitamente antes de usarse); hay que activar + // antes de poder desactivar en este ciclo de vida. + (await _client.PostAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + // Deactivate (await _client.PostAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}/deactivate", null, ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify deactivated via GraphQL - using (var deactivatedDoc = await GqlSuiteByIdAsync(suiteId, ct)) + // Verify deactivated via REST + using (var deactivatedDoc = await GetSuiteByIdAsync(suiteId, ct)) { - deactivatedDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + deactivatedDoc.RootElement .GetProperty("modules").EnumerateArray() .First(m => m.GetProperty("id").GetGuid() == moduleId) .GetProperty("status").GetString().Should().Be("Inactive"); @@ -342,9 +356,9 @@ await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}", ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify gone via GraphQL - using var afterDoc = await GqlSuiteByIdAsync(suiteId, ct); - var still = afterDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + // Verify gone via REST + using var afterDoc = await GetSuiteByIdAsync(suiteId, ct); + var still = afterDoc.RootElement .GetProperty("modules").EnumerateArray() .Any(m => m.GetProperty("id").GetGuid() == moduleId); still.Should().BeFalse(because: "removed module should not appear in suite"); @@ -473,8 +487,8 @@ public async Task RegisterAction_ValidPayload_Returns204AndAppearsInSuite() var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/actions", payload, ct); res.StatusCode.Should().Be(HttpStatusCode.Created); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - var actions = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById").GetProperty("actions"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + var actions = doc.RootElement.GetProperty("actions"); actions.EnumerateArray().Any(a => a.GetProperty("code").GetString() == code).Should().BeTrue( because: "registered action should appear in suite actions"); } @@ -493,8 +507,8 @@ await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/actions", var res = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}/actions/{code}", ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - doc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + using var doc = await GetSuiteByIdAsync(suiteId, ct); + doc.RootElement .GetProperty("actions").EnumerateArray() .Any(a => a.GetProperty("code").GetString() == code).Should().BeFalse( because: "removed action should not appear in suite"); @@ -532,27 +546,34 @@ public async Task RemoveAction_NonExistentCode_Returns404() // Helpers // ───────────────────────────────────────────────────────────────────────── - /// Sends a raw GraphQL query to POST /graphql and returns the parsed response. - private async Task GqlQueryAsync(string gql, CancellationToken ct) + /// + /// Gets a system suite by ID via GET /api/v1/system-suites/{id}. Caller must dispose. + /// Fails the test if the suite does not exist (non-200). + /// + private async Task GetSuiteByIdAsync(Guid suiteId, CancellationToken ct) { - var res = await _client.PostAsJsonAsync("/graphql", new { query = gql }, ct); - res.EnsureSuccessStatusCode(); - var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); - doc.RootElement.TryGetProperty("errors", out _).Should().BeFalse( - because: "GraphQL query should not return errors"); - return doc; + var res = await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK, because: "the suite should exist"); + return JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); } /// - /// Queries systemSuiteById(systemSuiteId) with full fields via GraphQL. Caller must dispose. + /// Gets a paged list of system suites via GET /api/v1/system-suites?{query}. Caller must dispose. + /// The root element is the page: { items, page, pageSize, totalItems, totalPages }. /// - private Task GqlSuiteByIdAsync(Guid suiteId, CancellationToken ct) => - GqlQueryAsync( - $"{{ systemSuiteById(systemSuiteId: \"{suiteId}\") {{ systemSuiteId tenantId code name description status modules {{ id code name description status sortOrder }} actions {{ id code name }} }} }}", - ct); + private async Task GetSuitesAsync(string query, CancellationToken ct) + { + var res = await _client.GetAsync($"/api/v1/system-suites?{query}", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); + return JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + } + // Los códigos se normalizan en el dominio (DomainGuards.NormalizeCode: Trim + ToUpperInvariant + + // espacios→'_'). El GUID :N es hex en minúsculas, así que se normaliza aquí para que el código + // enviado coincida con el que el API almacena y devuelve (evita falsos "no aparece" por diferencia + // de mayúsculas en las comparaciones de round-trip). private static string UniqueCode(string prefix) - => $"{prefix}{Guid.NewGuid():N}"[..Math.Min(20, prefix.Length + 32)]; + => $"{prefix}{Guid.NewGuid():N}"[..Math.Min(20, prefix.Length + 32)].ToUpperInvariant(); private record SuitePayload(Guid TenantId, string Code, string Name, string Description); private static SuitePayload NewSuitePayload(Guid tenantId) @@ -564,12 +585,13 @@ private static SuitePayload NewSuitePayload(Guid tenantId) private async Task CreateTenantId(CancellationToken ct) { var uid = Guid.NewGuid().ToString("N")[..10].ToUpper(); - var payload = new { code = $"T{uid}", name = $"E2E SS Tenant {uid}", type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, isManagementOwner = true }; + // G-037: management owner único; el inquilino CLIENT auxiliar no debe reclamarlo. + var payload = new { code = $"T{uid}", name = $"E2E SS Tenant {uid}", type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, isManagementOwner = false }; var response = await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct); response.EnsureSuccessStatusCode(); var location = response.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteLogicalDeletionE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteLogicalDeletionE2ETests.cs new file mode 100644 index 00000000..b09f06eb --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteLogicalDeletionE2ETests.cs @@ -0,0 +1,483 @@ +using Npgsql; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.E2E; + +/// +/// Eliminación LÓGICA de un sistema del catálogo — G-246. +/// +/// La política es del propietario del producto y no admite matices: el borrado físico no +/// existe. Sobre el catálogo se consultan datos antiguos y una fila borrada de verdad se pierde +/// para siempre. Un sistema eliminado se marca Deleted, sigue en la tabla y desaparece de +/// todas las lecturas. +/// +/// La segunda mitad de la política es la regla de cascada, análoga a un +/// ON DELETE RESTRICT: no se elimina lógicamente algo a lo que todavía apuntan referencias +/// vivas. Las que ya están eliminadas lógicamente no bloquean. +/// +/// Estas pruebas van contra PostgreSQL real porque las dos mitades son exactamente lo que un +/// doble en memoria no puede demostrar: el conteo sobre las tablas que apuntan al sistema, y que la +/// fila siga ahí después del DELETE. +/// +/// Requisito: Docker en marcha. +/// +[Collection("PostgreSql")] +public sealed class SystemSuiteLogicalDeletionE2ETests +{ + private const string TenantsTable = "ums_identity.\"Tenants\""; + private const string SystemSuitesTable = "ums_authorization.\"SystemSuites\""; + + private readonly PostgreSqlContainerFixture _fixture; + private readonly HttpClient _client; + + public SystemSuiteLogicalDeletionE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + + if (fixture.IsAvailable) + { + var factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); + _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-logical-deletion"); + // ADR-0077: aprovisionar y ELIMINAR sistemas son operaciones on-behalf del internal-admin + // sobre un inquilino CLIENT auxiliar; sin este contexto TenantScopePolicy devuelve AUTH_015. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + } + else + { + _client = new HttpClient(); + } + } + + [Fact] + public async Task DeleteSystemSuite_DeprecatedWithoutLiveReferences_Returns204AndDisappearsFromReads() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + + // El sistema lleva su propia topología: es la forma exacta en que el carril A los deja + // (suite → módulo → nodo). La composición del agregado no debe bloquear la eliminación. + var moduleId = await AddModuleAsync(suiteId, ct); + await AddRootNodeAsync(suiteId, moduleId, ct); + + var code = await CodeOfAsync(suiteId, ct); + await DeprecateAsync(suiteId, ct); + + var delete = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + delete.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterGet = await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct); + afterGet.StatusCode.Should().Be( + HttpStatusCode.NotFound, + because: "un sistema eliminado lógicamente deja de leerse, aunque su fila siga en la base"); + + // Y tampoco por el listado, que es la otra puerta de lectura: si el filtro solo cubriera el + // GET por id, el catálogo seguiría mostrando lo eliminado. + (await ListedCodesAsync(ct)).Should().NotContain( + code, + because: "lo eliminado tampoco aparece en el catálogo"); + } + + /// + /// LA prueba de la política: tras el 204, la fila SIGUE en la base con su estado terminal. + /// + /// Se consulta con SQL crudo y no por la API a propósito: la API está obligada a no verla, así + /// que preguntarle a ella no distinguiría «oculto» de «borrado». Aquí se mira el almacenamiento. + /// + [Fact] + public async Task DeleteSystemSuite_RowSurvivesInDatabaseWithDeletedStatus() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + await DeprecateAsync(suiteId, ct); + + (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var statusId = await ScalarAsync( + $"SELECT \"StatusId\" FROM {SystemSuitesTable} WHERE \"Id\" = @id", + ct, + ("id", suiteId)); + + statusId.Should().NotBeNull( + because: "el borrado es LÓGICO: la fila no se borra, porque sobre el catálogo se consultan datos antiguos"); + statusId.Should().Be( + SystemStatus.Deleted.Id, + because: "la fila que sobrevive debe quedar marcada como eliminada, no en un estado cualquiera"); + } + + [Fact] + public async Task DeleteSystemSuite_WithLiveRole_Returns409WithBlockingDependenciesAndSuiteSurvives() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + await CreateRoleAsync(suiteId, ct); + + await DeprecateAsync(suiteId, ct); + + var delete = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + delete.StatusCode.Should().Be(HttpStatusCode.Conflict); + + using var error = JsonDocument.Parse(await delete.Content.ReadAsStringAsync(ct)); + error.RootElement.GetProperty("errorCode").GetString() + .Should().Be("SYSTEM_SUITE_HAS_DEPENDENTS", + because: "el rechazo debe ser una regla de dominio con nombre, no una violación de FK"); + + var blockers = error.RootElement.GetProperty("blockingDependencies"); + blockers.EnumerateArray() + .Any(d => d.GetProperty("entityType").GetString() == "Role" && d.GetProperty("count").GetInt32() > 0) + .Should().BeTrue(because: "la respuesta debe decir QUÉ bloquea, para que el llamador sepa qué eliminar antes"); + + var afterGet = await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct); + afterGet.StatusCode.Should().Be(HttpStatusCode.OK, because: "una eliminación rechazada no cambia nada"); + } + + /// + /// Las dos caras de la regla de cascada sobre el MISMO dependiente. + /// + /// Se usa el inquilino que apunta al sistema como su sistema por defecto porque hoy es el + /// ÚNICO de los siete referentes con eliminación lógica de verdad (Tenants.IsDeleted). + /// Roles, plantillas, banderas, configuraciones y flujos de aprobación no la tienen: para ellos + /// toda fila existente cuenta como viva, y por eso la mitad «ya eliminado, deja pasar» no se + /// puede demostrar con un rol. + /// + /// El estado se dispone con SQL porque la API no expone ni fijar el sistema por defecto de + /// un inquilino ni eliminar un inquilino; lo que la prueba ejercita es la GUARDA, no esos + /// caminos. + /// + [Fact] + public async Task DeleteSystemSuite_TenantDefault_BlocksWhileLive_AndPassesOnceLogicallyDeleted() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + var dependentTenantId = await CreateTenantAsync(setAsCurrent: false, ct); + + await ExecuteAsync( + $"UPDATE {TenantsTable} SET \"DefaultSystemSuiteId\" = @suite WHERE \"Id\" = @tenant", + ct, + ("suite", suiteId), ("tenant", dependentTenantId)); + + await DeprecateAsync(suiteId, ct); + + // (b) Referencia VIVA → bloquea, con el desglose. + var blocked = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + blocked.StatusCode.Should().Be(HttpStatusCode.Conflict); + + using (var error = JsonDocument.Parse(await blocked.Content.ReadAsStringAsync(ct))) + { + error.RootElement.GetProperty("errorCode").GetString().Should().Be("SYSTEM_SUITE_HAS_DEPENDENTS"); + error.RootElement.GetProperty("blockingDependencies").EnumerateArray() + .Any(d => d.GetProperty("entityType").GetString() == "Tenant" && d.GetProperty("count").GetInt32() > 0) + .Should().BeTrue(because: "el inquilino que lo usa por defecto es el referente más peligroso: se queda sin destino de autenticación"); + } + + // (c) El MISMO dependiente, ya eliminado lógicamente → es una lápida y deja de bloquear. + await ExecuteAsync( + $"UPDATE {TenantsTable} SET \"IsDeleted\" = true, \"DeletedAtUtc\" = (now() at time zone 'utc'), \"DeletedBy\" = 'e2e-logical-deletion' WHERE \"Id\" = @tenant", + ct, + ("tenant", dependentTenantId)); + + var allowed = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + allowed.StatusCode.Should().Be( + HttpStatusCode.NoContent, + because: "lo ya eliminado lógicamente no es una referencia real: no puede bloquear para siempre"); + } + + [Fact] + public async Task DeleteSystemSuite_StillInService_Returns409() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + // Sin archivar (nace Active): solo se elimina lo que ya se dio de baja. + var suiteId = await CreateSuiteId(ct); + + var delete = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + delete.StatusCode.Should().Be(HttpStatusCode.Conflict); + + (await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteSystemSuite_Repeated_Returns404() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + await DeprecateAsync(suiteId, ct); + + (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Ya no se lee, así que el segundo intento no lo encuentra: coherente con el GET. + (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task SetStatus_ToDeleted_Returns409() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + // La puerta de atrás: si el cambio de estado admitiera «Deleted», eliminaría el sistema sin + // pasar por la guarda de cascada. + var suiteId = await CreateSuiteId(ct); + + var res = await _client.PutAsJsonAsync( + $"/api/v1/system-suites/{suiteId}/status", new { status = "Deleted" }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + /// + /// Consecuencia directa de que la fila sobreviva: su código sigue ocupado. + /// + /// El índice único (TenantId, Code) cuenta también las lápidas, así que el alta tiene que + /// verlas. Si no las viera, reutilizar el código pasaría la validación y reventaría en el commit + /// con una violación de integridad: un 500 en lugar de una respuesta con sentido. + /// + [Fact] + public async Task CreateSystemSuite_ReusingCodeOfDeletedSuite_IsRejectedCleanly() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + var code = await CodeOfAsync(suiteId, ct); + var tenantId = await ScalarAsync( + $"SELECT \"TenantId\" FROM {SystemSuitesTable} WHERE \"Id\" = @id", ct, ("id", suiteId)); + + await DeprecateAsync(suiteId, ct); + (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var res = await _client.PostAsJsonAsync("/api/v1/system-suites", new + { + tenantId = (Guid)tenantId!, + code, + name = "Sistema que reutiliza un código eliminado", + description = "El código de un sistema eliminado sigue ocupado por su lápida.", + }, ct); + + ((int)res.StatusCode).Should().BeLessThan( + 500, + because: "el rechazo debe ser una respuesta de negocio, nunca una violación de índice único"); + res.StatusCode.Should().BeOneOf(HttpStatusCode.Conflict, HttpStatusCode.BadRequest); + } + + [Fact] + public async Task DeleteSystemSuite_UnknownId_Returns404() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var delete = await _client.DeleteAsync($"/api/v1/system-suites/{Guid.NewGuid()}", ct); + delete.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private async Task DeprecateAsync(Guid suiteId, CancellationToken ct) + { + var res = await _client.PutAsJsonAsync( + $"/api/v1/system-suites/{suiteId}/status", new { status = "Deprecated" }, ct); + res.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + private async Task CodeOfAsync(Guid suiteId, CancellationToken ct) + => (string?)await ScalarAsync($"SELECT \"Code\" FROM {SystemSuitesTable} WHERE \"Id\" = @id", ct, ("id", suiteId)) + ?? throw new InvalidOperationException($"El sistema {suiteId} no existe ni siquiera en la base."); + + private async Task> ListedCodesAsync(CancellationToken ct) + { + var res = await _client.GetAsync("/api/v1/system-suites?page=1&pageSize=100", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); + + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var items = doc.RootElement.TryGetProperty("items", out var itemsElement) + ? itemsElement + : doc.RootElement; + + return items.EnumerateArray() + .Select(x => x.TryGetProperty("code", out var code) ? code.GetString() : null) + .ToList(); + } + + private async Task AddModuleAsync(Guid suiteId, CancellationToken ct) + { + var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", new + { + systemSuiteId = suiteId, + code = UniqueCode("DELMOD"), + name = "Módulo de la suite eliminada", + description = "Composición propia del sistema, creada por el E2E de eliminación lógica.", + sortOrder = 1, + }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Created); + return await ReadGuid(res, "moduleId", ct); + } + + private async Task AddRootNodeAsync(Guid suiteId, Guid moduleId, CancellationToken ct) + { + // Los nodos exigen módulo activo (el dominio rechaza colgar navegación de un módulo inactivo). + (await _client.PostAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}/nodes", new + { + systemSuiteId = suiteId, + moduleId, + parentNodeId = (Guid?)null, + kind = "Menu", + code = UniqueCode("DELNODE"), + label = "Menú de la suite eliminada", + description = "Nodo raíz creado por el E2E de eliminación lógica.", + sortOrder = 1, + }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Created); + } + + private async Task CreateRoleAsync(Guid suiteId, CancellationToken ct) + { + var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/roles", new + { + code = UniqueCode("DELROLE"), + value = "Rol que bloquea la eliminación", + description = "Referencia externa viva del sistema, creada por el E2E de eliminación lógica.", + parentRoleId = (Guid?)null, + hierarchyLevel = 0, + promotionOrder = 0, + }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Created); + return await ReadGuid(res, "roleId", ct); + } + + // Los códigos se normalizan en el dominio (Trim + ToUpperInvariant); se normalizan aquí para que + // lo enviado coincida con lo almacenado. + private static string UniqueCode(string prefix) + => $"{prefix}{Guid.NewGuid():N}"[..Math.Min(20, prefix.Length + 32)].ToUpperInvariant(); + + /// + /// Si el inquilino recién creado pasa a ser el del encabezado X-Tenant-Id. Los inquilinos + /// auxiliares —el que solo referencia al sistema— NO deben cambiarlo: el filtro de inquilino + /// escondería el sistema bajo prueba, que pertenece a otro. + /// + private async Task CreateTenantAsync(bool setAsCurrent, CancellationToken ct) + { + var uid = Guid.NewGuid().ToString("N")[..10].ToUpper(); + // G-037: la propiedad de gestión es única; el inquilino CLIENT auxiliar no debe reclamarla. + var payload = new + { + code = $"T{uid}", + name = $"E2E Eliminacion Tenant {uid}", + type = "CLIENT", + idpStrategy = (string?)null, + companyReference = (string?)null, + isManagementOwner = false, + }; + + var response = await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct); + response.EnsureSuccessStatusCode(); + + var id = Guid.Parse(response.Headers.Location!.ToString().Split('/')[^1]); + + if (setAsCurrent) + { + _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + _client.DefaultRequestHeaders.Add("X-Tenant-Id", id.ToString()); + } + + return id; + } + + private async Task CreateSuiteId(CancellationToken ct) + { + var tenantId = await CreateTenantAsync(setAsCurrent: true, ct); + var uid = Guid.NewGuid().ToString("N")[..8].ToUpper(); + + var res = await _client.PostAsJsonAsync("/api/v1/system-suites", new + { + tenantId, + code = $"DEL{uid}", + name = $"E2E Eliminacion Suite {uid}", + description = "Sistema creado por el E2E de eliminación lógica (G-246).", + }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Created); + return await ReadGuid(res, "systemSuiteId", ct); + } + + private static async Task ReadGuid(HttpResponseMessage response, string property, CancellationToken ct) + { + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty(property).GetGuid(); + } + + // ── Acceso directo a la base ───────────────────────────────────────────── + // Se usa para DOS cosas que la API no puede dar: disponer estados que no expone (el sistema por + // defecto de un inquilino, la eliminación lógica de un inquilino) y, sobre todo, mirar el + // almacenamiento por debajo de la API para distinguir «oculto» de «borrado». + + private async Task OpenAsync(CancellationToken ct) + { + var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(ct); + + // Neutraliza la compuerta de RLS (`app.current_organization_id` vacía = sin restricción de + // inquilino). Sin esto, la aserción mediría visibilidad y no existencia, que es justo lo que + // esta prueba tiene que separar. + await using var gate = new NpgsqlCommand("SET app.current_organization_id = ''", connection); + await gate.ExecuteNonQueryAsync(ct); + + return connection; + } + + private async Task ExecuteAsync(string sql, CancellationToken ct, params (string Name, object Value)[] parameters) + { + await using var connection = await OpenAsync(ct); + await using var command = new NpgsqlCommand(sql, connection); + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + await command.ExecuteNonQueryAsync(ct); + } + + /// Escalar crudo, o null si la consulta no devolvió fila. + private async Task ScalarAsync(string sql, CancellationToken ct, params (string Name, object Value)[] parameters) + { + await using var connection = await OpenAsync(ct); + await using var command = new NpgsqlCommand(sql, connection); + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + var result = await command.ExecuteScalarAsync(ct); + return result is DBNull ? null : result; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/TenantE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/TenantE2ETests.cs index c9bb3d24..2e303d91 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/TenantE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/TenantE2ETests.cs @@ -12,7 +12,7 @@ namespace Ums.Presentation.IntegrationTest.E2E; /// /// Architecture: /// - Commands → REST API (POST / PUT / DELETE) -/// - Queries → GraphQL (POST /graphql) +/// - Queries → REST API (GET) /// /// Prerequisites: Docker must be running locally. /// Tests are automatically skipped when Docker is unavailable. @@ -37,6 +37,14 @@ public TenantE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077 (evolith-core#18): las operaciones de Branch (Add/Deactivate/Reactivate/ + // Remove) sobre un inquilino CLIENT auxiliar (X-Tenant-Id apunta a él) son acciones + // ON-BEHALF que sólo el operador de gestión (internal-admin) puede ejecutar; sin esta + // cabecera, TenantScopePolicy devuelve AUTH_015 → 400 en el setup. No hay tests de + // denegación-auth en esta clase: los 4xx afirmados (código vacío → 400, código/branch + // duplicado → 409, inquilino/branch inexistente → 404) son validación, conflicto y + // not-found, y siguen siendo correctos bajo internal-admin. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -90,11 +98,11 @@ public async Task CreateTenant_DuplicateCode_Returns409() } // ───────────────────────────────────────────────────────────────────────── - // READ — via GraphQL + // READ // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetTenantById_ExistingTenant_GqlReturnsCorrectData() + public async Task GetTenantById_ExistingTenant_ReturnsCorrectData() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -104,10 +112,12 @@ public async Task GetTenantById_ExistingTenant_GqlReturnsCorrectData() createRes.StatusCode.Should().Be(HttpStatusCode.Created); var tenantId = await ReadGuidProperty(createRes, "tenantId", ct); - using var doc = await GqlTenantByIdAsync(tenantId, ct); - var tenant = doc.RootElement.GetProperty("data").GetProperty("tenantById"); + var getRes = await _client.GetAsync($"/api/v1/tenants/{tenantId}", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK, because: "tenant should exist"); + + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var tenant = doc.RootElement; - tenant.ValueKind.Should().NotBe(JsonValueKind.Null, because: "tenant should exist"); tenant.GetProperty("tenantId").GetGuid().Should().Be(tenantId); tenant.GetProperty("code").GetString().Should().Be(payload.Code); tenant.GetProperty("name").GetString().Should().Be(payload.Name); @@ -116,28 +126,28 @@ public async Task GetTenantById_ExistingTenant_GqlReturnsCorrectData() } [Fact] - public async Task GetTenantById_NonExistent_GqlReturnsNull() + public async Task GetTenantById_NonExistent_Returns404() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - using var doc = await GqlTenantByIdAsync(Guid.NewGuid(), ct); - var tenant = doc.RootElement.GetProperty("data").GetProperty("tenantById"); + var res = await _client.GetAsync($"/api/v1/tenants/{Guid.NewGuid()}", ct); - tenant.ValueKind.Should().Be(JsonValueKind.Null, - because: "querying a non-existent tenant ID should return null"); + res.StatusCode.Should().Be(HttpStatusCode.NotFound, + because: "querying a non-existent tenant ID should return 404"); } [Fact] - public async Task GetTenants_Pagination_GqlReturnsCorrectMetadata() + public async Task GetTenants_Pagination_ReturnsCorrectMetadata() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - const string gql = "{ tenants(page: 1, pageSize: 5) { page pageSize totalItems items { tenantId code } } }"; - using var doc = await GqlQueryAsync(gql, ct); + var res = await _client.GetAsync("/api/v1/tenants?page=1&pageSize=5", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); - var list = doc.RootElement.GetProperty("data").GetProperty("tenants"); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var list = doc.RootElement; list.GetProperty("page").GetInt32().Should().Be(1); list.GetProperty("pageSize").GetInt32().Should().Be(5); list.GetProperty("totalItems").GetInt32().Should().BeGreaterThanOrEqualTo(0); @@ -145,7 +155,7 @@ public async Task GetTenants_Pagination_GqlReturnsCorrectMetadata() } [Fact] - public async Task GetTenants_SearchByCode_GqlFindsTenant() + public async Task GetTenants_SearchByCode_FindsTenant() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -154,10 +164,12 @@ public async Task GetTenants_SearchByCode_GqlFindsTenant() (await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct)) .StatusCode.Should().Be(HttpStatusCode.Created); - var gql = $"{{ tenants(page: 1, pageSize: 50, search: \"{payload.Code}\", criteria: \"code\") {{ items {{ tenantId code }} }} }}"; - using var doc = await GqlQueryAsync(gql, ct); + var res = await _client.GetAsync( + $"/api/v1/tenants?page=1&pageSize=50&search={payload.Code}&criteria=code", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); - var items = doc.RootElement.GetProperty("data").GetProperty("tenants").GetProperty("items"); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var items = doc.RootElement.GetProperty("items"); items.GetArrayLength().Should().BeGreaterThan(0); var found = items.EnumerateArray().Any(i => i.GetProperty("code").GetString() == payload.Code); found.Should().BeTrue(because: "tenant should be findable by exact code search"); @@ -180,23 +192,15 @@ public async Task SuspendAndActivateTenant_FullCycle_Returns204() suspendRes.StatusCode.Should().Be(HttpStatusCode.NoContent, because: "a newly created tenant should be suspendable"); - // Verify status via GraphQL - using (var afterSuspend = await GqlTenantByIdAsync(tenantId, ct)) - { - afterSuspend.RootElement.GetProperty("data").GetProperty("tenantById") - .GetProperty("status").GetString().Should().Be("Suspended"); - } + // Verify status via REST + (await GetTenantStatusAsync(tenantId, ct)).Should().Be("Suspended"); // Activate var activateRes = await _client.PostAsync($"/api/v1/tenants/{tenantId}/activate", null, ct); activateRes.StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify restored status via GraphQL - using (var afterActivate = await GqlTenantByIdAsync(tenantId, ct)) - { - afterActivate.RootElement.GetProperty("data").GetProperty("tenantById") - .GetProperty("status").GetString().Should().Be("Active"); - } + // Verify restored status via REST + (await GetTenantStatusAsync(tenantId, ct)).Should().Be("Active"); } [Fact] @@ -237,7 +241,7 @@ public async Task ActivateTenant_NonExistent_Returns404() // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task AddBranch_ValidPayload_Returns201AndAppearsInGqlBranches() + public async Task AddBranch_ValidPayload_Returns201AndAppearsInBranches() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -249,12 +253,13 @@ public async Task AddBranch_ValidPayload_Returns201AndAppearsInGqlBranches() var res = await _client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", branchPayload, ct); res.StatusCode.Should().Be(HttpStatusCode.Created); - // Verify via GraphQL tenantBranches query - var gql = $"{{ tenantBranches(tenantId: \"{tenantId}\") {{ branchId code name isActive }} }}"; - using var doc = await GqlQueryAsync(gql, ct); - var branches = doc.RootElement.GetProperty("data").GetProperty("tenantBranches"); - var found = branches.EnumerateArray().Any(b => b.GetProperty("code").GetString() == branchCode); - found.Should().BeTrue(because: "the added branch should appear in tenantBranches query"); + // Verify via REST branches endpoint. El value object Code normaliza a MAYÚSCULAS al + // persistir, así que la comparación debe ser insensible a mayúsculas (el código generado + // usa hex en minúsculas de Guid:N). + using var doc = await GetTenantBranchesAsync(tenantId, ct); + var found = doc.RootElement.EnumerateArray() + .Any(b => string.Equals(b.GetProperty("code").GetString(), branchCode, StringComparison.OrdinalIgnoreCase)); + found.Should().BeTrue(because: "the added branch should appear in the branches list"); } [Fact] @@ -270,7 +275,7 @@ public async Task AddBranch_ToNonExistentTenant_Returns404() } [Fact] - public async Task BranchLifecycle_DeactivateReactivateRemove_FullCycle() + public async Task BranchLifecycle_DesactivarReactivarYCerrar_CicloCompleto() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -282,27 +287,61 @@ public async Task BranchLifecycle_DeactivateReactivateRemove_FullCycle() var addRes = await _client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", branchPayload, ct); addRes.StatusCode.Should().Be(HttpStatusCode.Created); - // Retrieve branchId via GraphQL (AddBranchResponse only contains tenantId) + // Retrieve branchId via REST (AddBranchResponse only contains tenantId) var branchId = await GetBranchIdByCodeAsync(tenantId, branchCode, ct); - // Deactivate + // Desactivar y reactivar SIGUEN funcionando: ADR-0164 no toca el verbo reversible. (await _client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/deactivate", null, ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Reactivate (await _client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Remove - (await _client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct)) + // Y cerrar NO exige desactivar antes: son dos verbos independientes (ADR-0164 §2.4). Antes el + // invariante de `RemoveBranch` obligaba a desactivar primero, porque «eliminar» era el paso + // siguiente de «retirar del servicio»; ya no lo es. + (await _client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}?reason=Cese%20de%20operaciones", ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify branch is gone via GraphQL - var gql = $"{{ tenantBranches(tenantId: \"{tenantId}\") {{ branchId code }} }}"; - using var after = await GqlQueryAsync(gql, ct); - var branches = after.RootElement.GetProperty("data").GetProperty("tenantBranches"); - var stillPresent = branches.EnumerateArray().Any(b => b.GetProperty("branchId").GetGuid() == branchId); - stillPresent.Should().BeFalse(because: "removed branch should not appear in branches list"); + // El listado ordinario ya no la trae… + using var after = await GetTenantBranchesAsync(tenantId, ct); + after.RootElement.EnumerateArray().Any(b => b.GetProperty("branchId").GetGuid() == branchId) + .Should().BeFalse(because: "una sucursal cerrada desaparece del listado operativo"); + + // …pero se puede pedir, y eso es lo que distingue «oculta» de «borrada». + var conCerradas = await _client.GetAsync($"/api/v1/tenants/{tenantId}/branches?includeClosed=true", ct); + conCerradas.StatusCode.Should().Be(HttpStatusCode.OK); + using var doc = JsonDocument.Parse(await conCerradas.Content.ReadAsStringAsync(ct)); + var cerrada = doc.RootElement.EnumerateArray() + .Single(b => b.GetProperty("branchId").GetGuid() == branchId); + cerrada.GetProperty("isClosed").GetBoolean().Should().BeTrue(); + + // Cerrada no se reactiva ni se desactiva: el estado terminal no tiene puerta de atrás. + (await _client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + (await _client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + + // Reintentar el cierre tampoco es idempotente en silencio: es un conflicto de estado. + (await _client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + + // Y el código NO se libera (ADR-0164 §2.3): dar de alta otra con el mismo choca de forma + // legible, no con una violación de índice único convertida en 500. + var reintentoDeCodigo = await _client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", branchPayload, ct); + reintentoDeCodigo.StatusCode.Should().Be(HttpStatusCode.Conflict); + + // La bitácora conserva los cuatro episodios, con su autor y su fecha. + var bitacora = await _client.GetAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/bitacora", ct); + bitacora.StatusCode.Should().Be(HttpStatusCode.OK); + using var log = JsonDocument.Parse(await bitacora.Content.ReadAsStringAsync(ct)); + var episodios = log.RootElement.EnumerateArray().Select(e => e.GetProperty("episode").GetString()).ToList(); + episodios.Should().Equal("Opened", "Deactivated", "Reactivated", "Closed"); + log.RootElement.EnumerateArray().Last().GetProperty("reason").GetString() + .Should().Be("Cese de operaciones"); + log.RootElement.EnumerateArray() + .All(e => !string.IsNullOrWhiteSpace(e.GetProperty("actorId").GetString())) + .Should().BeTrue(because: "cada episodio debe decir quién lo hizo"); } [Fact] @@ -326,34 +365,37 @@ public async Task AddBranch_DuplicateCode_Returns409() // Helpers // ───────────────────────────────────────────────────────────────────────── - /// Sends a raw GraphQL query to POST /graphql and returns the parsed response. - private async Task GqlQueryAsync(string gql, CancellationToken ct) + /// Queries GET /api/v1/tenants/{tenantId}/branches. Caller must dispose the document. + private async Task GetTenantBranchesAsync(Guid tenantId, CancellationToken ct) { - var res = await _client.PostAsJsonAsync("/graphql", new { query = gql }, ct); - res.EnsureSuccessStatusCode(); + var res = await _client.GetAsync($"/api/v1/tenants/{tenantId}/branches", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK, because: "branches list should be retrievable"); var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); - doc.RootElement.TryGetProperty("errors", out _).Should().BeFalse( - because: "GraphQL query should not return errors"); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array, + because: "the branches endpoint returns a JSON array"); return doc; } - /// Queries tenantById(tenantId) via GraphQL. Caller must dispose. - private Task GqlTenantByIdAsync(Guid tenantId, CancellationToken ct) => - GqlQueryAsync( - $"{{ tenantById(tenantId: \"{tenantId}\") {{ tenantId code name type status }} }}", - ct); + /// Reads the current status of a tenant via GET /api/v1/tenants/{tenantId}. + private async Task GetTenantStatusAsync(Guid tenantId, CancellationToken ct) + { + var res = await _client.GetAsync($"/api/v1/tenants/{tenantId}", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty("status").GetString(); + } /// - /// Retrieves the branchId for a given tenant+code via GraphQL. + /// Retrieves the branchId for a given tenant+code via the REST branches endpoint. /// Needed because AddBranchResponse only contains tenantId. /// private async Task GetBranchIdByCodeAsync(Guid tenantId, string code, CancellationToken ct) { - var gql = $"{{ tenantBranches(tenantId: \"{tenantId}\") {{ branchId code }} }}"; - using var doc = await GqlQueryAsync(gql, ct); - var branches = doc.RootElement.GetProperty("data").GetProperty("tenantBranches"); - return branches.EnumerateArray() - .First(b => b.GetProperty("code").GetString() == code) + // El value object Code normaliza a MAYÚSCULAS al persistir; la búsqueda por código debe + // ser insensible a mayúsculas (el código generado usa hex en minúsculas de Guid:N). + using var doc = await GetTenantBranchesAsync(tenantId, ct); + return doc.RootElement.EnumerateArray() + .First(b => string.Equals(b.GetProperty("code").GetString(), code, StringComparison.OrdinalIgnoreCase)) .GetProperty("branchId").GetGuid(); } @@ -361,7 +403,9 @@ private record TenantPayload(string Code, string Name, string Type, string? IdpS private static TenantPayload NewTenantPayload() { var uid = Guid.NewGuid().ToString("N")[..10].ToUpper(); - return new TenantPayload($"T{uid}", $"E2E Tenant {uid}", "CLIENT", null, null, true); + // G-037: el management owner es único a nivel de sistema. Los inquilinos genéricos de + // estas pruebas no deben reclamarlo — la unicidad se cubre en IntegridadYResultPatternE2ETests. + return new TenantPayload($"T{uid}", $"E2E Tenant {uid}", "CLIENT", null, null, false); } private async Task CreateTenantAndGetId(CancellationToken ct) @@ -370,7 +414,7 @@ private async Task CreateTenantAndGetId(CancellationToken ct) res.StatusCode.Should().Be(HttpStatusCode.Created); var location = res.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/UserAccountE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/UserAccountE2ETests.cs index 14cba9a8..94ec59e5 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/UserAccountE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/UserAccountE2ETests.cs @@ -13,7 +13,7 @@ namespace Ums.Presentation.IntegrationTest.E2E; /// /// Architecture: /// - Commands → REST API (POST / PUT / DELETE) -/// - Queries → GraphQL (POST /graphql) +/// - Queries → REST API (GET) /// /// Each test creates its own Tenant to guarantee isolation. /// Prerequisites: Docker must be running locally. @@ -38,6 +38,12 @@ public UserAccountE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077: el aprovisionamiento (crear usuario, registrar intento de autenticación, + // transiciones de estado) es una operación ON-BEHALF que solo ejerce el operador de + // gestión / internal-admin. Estos E2E fijan X-Tenant-Id a un inquilino CLIENT auxiliar + // (CreateTenantId), lo que hace perder el contexto internal-admin y TenantScopePolicy + // devuelve AUTH_015 → 400. Se declara el rol de forma explícita para actuar on-behalf. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -59,7 +65,7 @@ public async Task CreateUserAccount_ValidPayload_Returns201WithId() var response = await _client.PostAsJsonAsync("/api/v1/user-accounts", NewUserPayload(tenantId), ct); var content = await response.Content.ReadAsStringAsync(ct); - System.IO.File.WriteAllText("e2e_error.txt", content); + await System.IO.File.WriteAllTextAsync("e2e_error.txt", content, ct); response.StatusCode.Should().Be(HttpStatusCode.Created, content); response.Headers.Location.Should().NotBeNull(); @@ -77,7 +83,8 @@ public async Task CreateUserAccount_InvalidEmail_Returns400() var payload = new { tenantId, branchId = (Guid?)null, email = "NOT_AN_EMAIL", category = "Internal", identityReference = (string?)null, identityReferenceType = (string?)null }; var res = await _client.PostAsJsonAsync("/api/v1/user-accounts", payload, ct); - res.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + // G-061: la convención de validación del API devuelve 400 (no 422); el nombre del test lo refleja. + res.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] @@ -97,11 +104,11 @@ public async Task CreateUserAccount_DuplicateEmail_SameTenant_Returns409() } // ───────────────────────────────────────────────────────────────────────── - // READ — via GraphQL + // READ — via REST // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetUserAccountById_ExistingAccount_GqlReturnsCorrectFields() + public async Task GetUserAccountById_ExistingAccount_ReturnsCorrectFields() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -112,10 +119,12 @@ public async Task GetUserAccountById_ExistingAccount_GqlReturnsCorrectFields() createRes.StatusCode.Should().Be(HttpStatusCode.Created); var userId = await ReadGuid(createRes, "userAccountId", ct); - using var doc = await GqlUserAccountByIdAsync(userId, ct); - var user = doc.RootElement.GetProperty("data").GetProperty("userAccountById"); + var getRes = await _client.GetAsync($"/api/v1/user-accounts/{userId}", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK, because: "user account should exist"); + + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var user = doc.RootElement; - user.ValueKind.Should().NotBe(JsonValueKind.Null, because: "user account should exist"); user.GetProperty("userAccountId").GetGuid().Should().Be(userId); user.GetProperty("tenantId").GetGuid().Should().Be(tenantId); user.GetProperty("email").GetString().Should().Be(payload.Email); @@ -124,28 +133,28 @@ public async Task GetUserAccountById_ExistingAccount_GqlReturnsCorrectFields() } [Fact] - public async Task GetUserAccountById_NonExistent_GqlReturnsNull() + public async Task GetUserAccountById_NonExistent_Returns404() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - using var doc = await GqlUserAccountByIdAsync(Guid.NewGuid(), ct); - var user = doc.RootElement.GetProperty("data").GetProperty("userAccountById"); + var getRes = await _client.GetAsync($"/api/v1/user-accounts/{Guid.NewGuid()}", ct); - user.ValueKind.Should().Be(JsonValueKind.Null, - because: "querying a non-existent user account ID should return null"); + getRes.StatusCode.Should().Be(HttpStatusCode.NotFound, + because: "querying a non-existent user account ID should return 404"); } [Fact] - public async Task GetUserAccounts_Pagination_GqlReturnsPageMetadata() + public async Task GetUserAccounts_Pagination_ReturnsPageMetadata() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - const string gql = "{ userAccounts(page: 1, pageSize: 5) { page pageSize totalItems items { userAccountId email } } }"; - using var doc = await GqlQueryAsync(gql, ct); + var getRes = await _client.GetAsync("/api/v1/user-accounts?page=1&pageSize=5", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK); - var list = doc.RootElement.GetProperty("data").GetProperty("userAccounts"); + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var list = doc.RootElement; list.GetProperty("page").GetInt32().Should().Be(1); list.GetProperty("pageSize").GetInt32().Should().Be(5); list.GetProperty("totalItems").GetInt32().Should().BeGreaterThanOrEqualTo(0); @@ -153,7 +162,7 @@ public async Task GetUserAccounts_Pagination_GqlReturnsPageMetadata() } [Fact] - public async Task GetUserAccounts_FilterByTenantId_GqlReturnsOnlyTenantUsers() + public async Task GetUserAccounts_FilterByTenantId_ReturnsOnlyTenantUsers() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -163,10 +172,11 @@ public async Task GetUserAccounts_FilterByTenantId_GqlReturnsOnlyTenantUsers() (await _client.PostAsJsonAsync("/api/v1/user-accounts", payload, ct)) .StatusCode.Should().Be(HttpStatusCode.Created); - var gql = $"{{ userAccounts(page: 1, pageSize: 50, tenantId: \"{tenantId}\") {{ items {{ userAccountId tenantId email }} }} }}"; - using var doc = await GqlQueryAsync(gql, ct); + var getRes = await _client.GetAsync($"/api/v1/user-accounts?page=1&pageSize=50&tenantId={tenantId}", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK); - var items = doc.RootElement.GetProperty("data").GetProperty("userAccounts").GetProperty("items"); + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var items = doc.RootElement.GetProperty("items"); items.GetArrayLength().Should().BeGreaterThan(0); foreach (var item in items.EnumerateArray()) { @@ -176,7 +186,7 @@ public async Task GetUserAccounts_FilterByTenantId_GqlReturnsOnlyTenantUsers() } [Fact] - public async Task GetUserAccounts_SearchByEmail_GqlFindsCreatedUser() + public async Task GetUserAccounts_SearchByEmail_FindsCreatedUser() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -187,10 +197,11 @@ public async Task GetUserAccounts_SearchByEmail_GqlFindsCreatedUser() .StatusCode.Should().Be(HttpStatusCode.Created); var emailPrefix = payload.Email.Split('@')[0]; - var gql = $"{{ userAccounts(page: 1, pageSize: 50, search: \"{emailPrefix}\", criteria: \"email\") {{ items {{ userAccountId email }} }} }}"; - using var doc = await GqlQueryAsync(gql, ct); + var getRes = await _client.GetAsync($"/api/v1/user-accounts?page=1&pageSize=50&search={emailPrefix}&criteria=email", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK); - var items = doc.RootElement.GetProperty("data").GetProperty("userAccounts").GetProperty("items"); + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var items = doc.RootElement.GetProperty("items"); items.GetArrayLength().Should().BeGreaterThan(0); var found = items.EnumerateArray().Any(i => i.GetProperty("email").GetString() == payload.Email); found.Should().BeTrue(because: "user should be findable by email prefix search"); @@ -211,9 +222,7 @@ public async Task ActivateUserAccount_FromPending_Returns204AndChangesStatus() var res = await _client.PostAsync($"/api/v1/user-accounts/{userId}/activate", null, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlUserAccountByIdAsync(userId, ct); - doc.RootElement.GetProperty("data").GetProperty("userAccountById") - .GetProperty("status").GetString().Should().Be("Active"); + (await ReadStatus(userId, ct)).Should().Be("Active"); } [Fact] @@ -229,9 +238,7 @@ public async Task BlockUserAccount_ActiveAccount_Returns204AndChangesStatus() var res = await _client.PostAsync($"/api/v1/user-accounts/{userId}/block?reason=Policy+violation", null, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlUserAccountByIdAsync(userId, ct); - doc.RootElement.GetProperty("data").GetProperty("userAccountById") - .GetProperty("status").GetString().Should().Be("Blocked"); + (await ReadStatus(userId, ct)).Should().Be("Blocked"); } [Fact] @@ -247,9 +254,7 @@ public async Task RestoreUserAccount_BlockedAccount_Returns204AndChangesStatus() var res = await _client.PostAsync($"/api/v1/user-accounts/{userId}/restore", null, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlUserAccountByIdAsync(userId, ct); - doc.RootElement.GetProperty("data").GetProperty("userAccountById") - .GetProperty("status").GetString().Should().Be("Active"); + (await ReadStatus(userId, ct)).Should().Be("Active"); } [Fact] @@ -272,10 +277,8 @@ public async Task FullStatusCycle_Pending_Active_Blocked_Restored_Active() (await _client.PostAsync($"/api/v1/user-accounts/{userId}/restore", null, ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify final state via GraphQL - using var doc = await GqlUserAccountByIdAsync(userId, ct); - doc.RootElement.GetProperty("data").GetProperty("userAccountById") - .GetProperty("status").GetString().Should().Be("Active"); + // Verify final state via REST + (await ReadStatus(userId, ct)).Should().Be("Active"); } [Fact] @@ -307,7 +310,7 @@ public async Task BlockUserAccount_AlreadyBlocked_Returns409() // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task DeleteUserAccount_ExistingAccount_Returns204AndNotAccessibleViaGql() + public async Task DeleteUserAccount_ExistingAccount_Returns204AndNotAccessible() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -317,10 +320,9 @@ public async Task DeleteUserAccount_ExistingAccount_Returns204AndNotAccessibleVi var deleteRes = await _client.DeleteAsync($"/api/v1/user-accounts/{userId}", ct); deleteRes.StatusCode.Should().Be(HttpStatusCode.NoContent); - // After GDPR deletion the account should not be retrievable via GraphQL either - using var doc = await GqlUserAccountByIdAsync(userId, ct); - var user = doc.RootElement.GetProperty("data").GetProperty("userAccountById"); - user.ValueKind.Should().Be(JsonValueKind.Null, + // After GDPR deletion the account should no longer be retrievable via the REST query + var getRes = await _client.GetAsync($"/api/v1/user-accounts/{userId}", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.NotFound, because: "soft-deleted accounts should not be returned by queries"); } @@ -387,30 +389,23 @@ public async Task RecordAuthAttempt_MissingReason_Returns400() var payload = new { userAccountId = userId, success = true, reason = "", ipAddress = "10.0.0.1" }; var res = await _client.PostAsJsonAsync($"/api/v1/user-accounts/{userId}/authentication-attempts", payload, ct); - res.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + // G-061: la convención de validación del API devuelve 400 (no 422); el nombre del test lo refleja. + res.StatusCode.Should().Be(HttpStatusCode.BadRequest); } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── - /// Sends a raw GraphQL query to POST /graphql and returns the parsed response. - private async Task GqlQueryAsync(string gql, CancellationToken ct) + /// Reads the current status of a user account via GET /api/v1/user-accounts/{id}. + private async Task ReadStatus(Guid userId, CancellationToken ct) { - var res = await _client.PostAsJsonAsync("/graphql", new { query = gql }, ct); - res.EnsureSuccessStatusCode(); - var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); - doc.RootElement.TryGetProperty("errors", out _).Should().BeFalse( - because: "GraphQL query should not return errors"); - return doc; + var res = await _client.GetAsync($"/api/v1/user-accounts/{userId}", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK, because: "user account should exist"); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty("status").GetString(); } - /// Queries userAccountById(userAccountId) via GraphQL. Caller must dispose. - private Task GqlUserAccountByIdAsync(Guid userId, CancellationToken ct) => - GqlQueryAsync( - $"{{ userAccountById(userAccountId: \"{userId}\") {{ userAccountId tenantId email category status }} }}", - ct); - private record UserPayload(Guid TenantId, Guid? BranchId, string Email, string Category, string? IdentityReference, string? IdentityReferenceType); private static UserPayload NewUserPayload(Guid tenantId) { @@ -421,17 +416,18 @@ private static UserPayload NewUserPayload(Guid tenantId) private async Task CreateTenantId(CancellationToken ct) { var uid = Guid.NewGuid().ToString("N")[..10].ToUpper(); - var payload = new { code = $"T{uid}", name = $"E2E UA Tenant {uid}", type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, isManagementOwner = true }; + // G-037: management owner único; el inquilino CLIENT auxiliar no debe reclamarlo. + var payload = new { code = $"T{uid}", name = $"E2E UA Tenant {uid}", type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, isManagementOwner = false }; var response = await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct); if (response.StatusCode != HttpStatusCode.Created) { var err = await response.Content.ReadAsStringAsync(ct); - System.IO.File.WriteAllText("e2e_tenant_error.txt", err); + await System.IO.File.WriteAllTextAsync("e2e_tenant_error.txt", err, ct); } response.EnsureSuccessStatusCode(); var location = response.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/GlobalUsings.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/GlobalUsings.cs index ab00a4bb..3919519c 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/GlobalUsings.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/GlobalUsings.cs @@ -19,7 +19,7 @@ global using Ums.Infrastructure.Persistence.Configuration; global using Ums.Infrastructure.Persistence.Identity; global using BeyondNetCode.Shell.Aop.Aspects; -global using BeyondNetCode.Shell.Aop.Aspects.Logger.Serilog; +global using Ums.Infrastructure.Observability; global using BeyondNetCode.Shell.Ddd; global using Xunit; diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/Fr042DbBackedRealIdpChainTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/Fr042DbBackedRealIdpChainTests.cs new file mode 100644 index 00000000..7ea8cdda --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/Fr042DbBackedRealIdpChainTests.cs @@ -0,0 +1,560 @@ +using System.Net.Http; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Moq; +using Ums.Application.Configuration.Services; +using Ums.Application.Identity.Auth; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Identity.Auth.Oidc; +using Ums.Infrastructure.Persistence.Configuration; +using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109 (residual de FR-042, ADR-UMS-097): E2E de resolución/fallback FR-042 respaldado por BD, +/// juntando los dos mundos que hasta ahora vivían aparte —el arnés Keycloak real (protocolo OIDC) y el +/// camino de producción de resolución por reglas (IdpConfigurationSelector → +/// IdpConfigurationOidcProviderConfigStore → adaptador OIDC → IdpChainAuthenticator)— +/// contra DOS contenedores reales (Postgres + Keycloak). +/// +/// A diferencia del KeycloakOidcRealHarnessTests (que usa un StaticOidcProviderConfigStore +/// y no ejercita la resolución), aquí el store REAL lee las IdpConfiguration sembradas en Postgres, +/// y sus endpoints apuntan al issuer/endpoints REALES del Keycloak del contenedor —tomados de +/// /, +/// nunca hardcodeados—. +/// +/// Escenarios (por valor de seguridad): +/// +/// Camino feliz: una IdpConfiguration real resuelta por reglas autentica contra el +/// Keycloak real (Authorization Code + PKCE) por la ruta real del IdpChainAuthenticator. +/// Resolución por reglas (AuthMethodResolverService) sobre configs reales de BD → +/// proveedor Keycloak del inquilino. +/// Fallback por indisponibilidad: primaria a endpoint muerto (AUTH_035 infra) → la cadena +/// avanza a la secundaria (Keycloak real) y el login procede. +/// Invariante anti credential-spraying: primaria = Keycloak real que RECHAZA la credencial +/// (400 invalid_grant → AUTH_021) es TERMINAL — la cadena NO avanza a la secundaria. +/// Ciclo con todos los eslabones indisponibles → cortado → 503 (AUTH_018). +/// +/// +/// El único doble de test es : enruta cualquier estrategia OIDC +/// al REAL, igual que hace IdpAuthAdapterFactorySetup (Shell.Factory) +/// en producción para Keycloak/GenericOidc/…; evita cablear la fábrica sin cambiar el camino real +/// (store → adaptador → token client → validador). No se toca producción (SD-05). +/// +[Collection("PostgresKeycloak")] +public sealed class Fr042DbBackedRealIdpChainTests +{ + private const string DeadIssuerPrimary = "http://127.0.0.1:1/realms/dead-primary"; + private const string DeadIssuerSecondary = "http://127.0.0.1:1/realms/dead-secondary"; + private static readonly ActorId Actor = ActorId.Create("00000000-0000-0000-0000-000000000109"); + + private readonly PostgresKeycloakFixture _fixture; + private readonly KeycloakContainerFixture _keycloak; + + public Fr042DbBackedRealIdpChainTests(PostgresKeycloakFixture fixture) + { + _fixture = fixture; + _keycloak = fixture.Keycloak; + } + + // ── (1) Camino feliz: resolución por reglas desde BD + auth contra Keycloak real ──────────────── + + [Fact] + public async Task HappyPath_SingleRealKeycloakConfigFromDb_AuthenticatesViaRealChain() + { + if (!_fixture.BothAvailable) + { + Assert.Skip($"Postgres+Keycloak requeridos. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // Se siembra en Postgres UNA IdpConfiguration Keycloak activa apuntando al Keycloak REAL. + await SeedConfigAsync(NewConfig(tenantId, ProviderType.Keycloak, _keycloak.RealmIssuer, priority: 1), ct); + + // Login federado real (Authorization Code + PKCE headless) → callback con code/state/nonce reales. + var callbackJson = await ObtainRealCallbackJsonAsync(BuildRealKeycloakConfig(), ct); + + // Inquilino con proveedor Keycloak activo (el puente reglas↔dominio lo requiere). + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId); + + await using var chainDb = CreateDbContext(); + await using var storeDb = CreateDbContext(); + using var tokenHttp = NewHttpClient(); + using var jwksHttp = NewHttpClient(); + var audit = new Mock(); + var chain = BuildChain(chainDb, storeDb, tokenHttp, jwksHttp, audit.Object); + + var result = await chain.AuthenticateAsync(tenant, callbackJson, systemSuiteId: null, emailDomain: null, "10.0.0.1", ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Identity.Email.Should().Be(KeycloakContainerFixture.TestEmail); + result.Value.Identity.ExternalId.Should().NotBeNullOrWhiteSpace("el 'sub' real de Keycloak mapea a ExternalId"); + result.Value.Provider.Strategy.Id.Should().Be(IdpStrategy.Keycloak.Id); + audit.Verify(evt => evt.RecordAuthEventAsync(It.IsAny(), It.IsAny()), Times.Once()); + } + + // ── (2) Resolución por reglas: AuthMethodResolverService sobre configs reales de BD ───────────── + + [Fact] + public async Task AuthMethodResolver_ResolvesRealKeycloakProviderByRules_FromDb() + { + if (!_fixture.PostgresAvailable) + { + Assert.Skip($"Postgres requerido. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // Dos configs activas en BD: Keycloak (prioridad 1, gana) y AzureAd (prioridad 10). El selector + // elige por prioridad y el resolver la reconcilia con el proveedor Keycloak activo del inquilino. + await SeedConfigAsync(NewConfig(tenantId, ProviderType.Keycloak, _keycloak.IsAvailable ? _keycloak.RealmIssuer : DeadIssuerPrimary, priority: 1), ct); + await SeedConfigAsync(NewConfig(tenantId, ProviderType.AzureAd, DeadIssuerSecondary, priority: 10), ct); + + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId); + + var config = new Mock(); + config.Setup(c => c.GetWithPrecedence(AppConfigurationCodes.AuthUseExternalIdp, tenantId, It.IsAny(), It.IsAny())) + .Returns(BuildUseExternalIdp(tenantId, true)); + var tenantRepo = new Mock(); + tenantRepo.Setup(r => r.GetByIdAsync(tenantId, It.IsAny())).ReturnsAsync(tenant); + + await using var db = CreateDbContext(); + var resolver = new AuthMethodResolverService(config.Object, tenantRepo.Object, new PostgreSqlIdpConfigurationRepository(db)); + + var result = await resolver.ResolveAsync(tenantId, AuthAccessScope.ExternalApi, systemSuiteId: null, emailDomain: null, ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Type.Should().Be(AuthMethodType.IDP); + result.Value.Provider.Should().NotBeNull(); + result.Value.Provider!.Strategy.Id.Should().Be(IdpStrategy.Keycloak.Id, "la regla FR-042 gana por prioridad y se reconcilia con el proveedor activo"); + } + + // ── (3) Fallback por indisponibilidad: primaria muerta → avanza a Keycloak real ───────────────── + + [Fact] + public async Task Fallback_PrimaryInfraDown_AdvancesToRealKeycloak_Succeeds() + { + if (!_fixture.BothAvailable) + { + Assert.Skip($"Postgres+Keycloak requeridos. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // Secundaria (Keycloak real) se crea primero para conocer su Id y enlazar la primaria a ella. + var secondary = NewConfig(tenantId, ProviderType.Keycloak, _keycloak.RealmIssuer, priority: 2); + var primary = NewConfig(tenantId, ProviderType.GenericOidc, DeadIssuerPrimary, priority: 1, fallbackToId: secondary.GetId().GetValue()); + await SeedConfigAsync(secondary, ct); + await SeedConfigAsync(primary, ct); + + // La credencial (callback) se emite en un login real contra el Keycloak real (la secundaria). + var callbackJson = await ObtainRealCallbackJsonAsync(BuildRealKeycloakConfig(), ct); + + // El inquilino registra AMBAS estrategias: GenericOidc (primaria) y Keycloak (secundaria). + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId, alsoRegister: IdpStrategy.GenericOidc); + + await using var chainDb = CreateDbContext(); + await using var storeDb = CreateDbContext(); + using var tokenHttp = NewHttpClient(); + using var jwksHttp = NewHttpClient(); + var audit = new Mock(); + var chain = BuildChain(chainDb, storeDb, tokenHttp, jwksHttp, audit.Object); + + var result = await chain.AuthenticateAsync(tenant, callbackJson, systemSuiteId: null, emailDomain: null, "10.0.0.1", ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Identity.Email.Should().Be(KeycloakContainerFixture.TestEmail); + // Autenticó la SECUNDARIA (Keycloak), no la primaria muerta (GenericOidc). + result.Value.Provider.Strategy.Id.Should().Be(IdpStrategy.Keycloak.Id); + // Un evento por proveedor intentado: primaria (infra→advance) + secundaria (success). + audit.Verify(evt => evt.RecordAuthEventAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + // ── (4) Invariante anti credential-spraying: rechazo real de credencial es TERMINAL ───────────── + + [Fact] + public async Task CredentialRejectedByRealKeycloak_IsTerminal_ChainDoesNotAdvance() + { + if (!_fixture.BothAvailable) + { + Assert.Skip($"Postgres+Keycloak requeridos. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // Primaria = Keycloak REAL (prioridad 1). Secundaria = endpoint muerto (prioridad 2). Si —y solo + // si— la cadena avanzara indebidamente tras un rechazo de credencial, caería en la secundaria + // muerta (infra) y agotaría la cadena → AUTH_018. El rechazo terminal debe cortar ANTES: AUTH_021. + var secondary = NewConfig(tenantId, ProviderType.GenericOidc, DeadIssuerSecondary, priority: 2); + var primary = NewConfig(tenantId, ProviderType.Keycloak, _keycloak.RealmIssuer, priority: 1, fallbackToId: secondary.GetId().GetValue()); + await SeedConfigAsync(secondary, ct); + await SeedConfigAsync(primary, ct); + + // Callback con code REAL emitido por Keycloak pero MANIPULADO: el token endpoint real lo rechaza + // con 400 invalid_grant (rechazo de credencial/petición) → AUTH_021 (terminal), no 5xx (infra). + var callbackJson = await ObtainRealCallbackJsonAsync(BuildRealKeycloakConfig(), ct, TamperCode); + + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId, alsoRegister: IdpStrategy.GenericOidc); + + await using var chainDb = CreateDbContext(); + await using var storeDb = CreateDbContext(); + using var tokenHttp = NewHttpClient(); + using var jwksHttp = NewHttpClient(); + var audit = new Mock(); + var chain = BuildChain(chainDb, storeDb, tokenHttp, jwksHttp, audit.Object); + + var result = await chain.AuthenticateAsync(tenant, callbackJson, systemSuiteId: null, emailDomain: null, "10.0.0.1", ct); + + result.IsFailure.Should().BeTrue("un rechazo real de credencial por Keycloak (400 invalid_grant) es terminal"); + result.Error.Should().Contain("AUTH_021", "el token endpoint real devolvió 4xx → clasificación terminal"); + result.Error.Should().NotContain("AUTH_018", "TERMINAL: la cadena NO avanzó a la secundaria (anti credential-spraying, ADR-UMS-097 §2.3)"); + // Solo el intento primario se auditó: la secundaria nunca se tocó. + audit.Verify(evt => evt.RecordAuthEventAsync(It.IsAny(), It.IsAny()), Times.Once()); + } + + // ── (5) Ciclo con todos los eslabones indisponibles → cortado → 503 (opcional, barato) ────────── + + [Fact] + public async Task Cycle_AllInfraUnavailable_Detected_Returns503() + { + if (!_fixture.PostgresAvailable) + { + Assert.Skip($"Postgres requerido. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // A (GenericOidc, muerto) → B (Keycloak-typed, muerto) → A. Ambos endpoints cerrados ⇒ AUTH_035 + // (infra) en cada intento ⇒ la cadena avanza hasta detectar el ciclo y se corta con 503. + var a = NewConfig(tenantId, ProviderType.GenericOidc, DeadIssuerPrimary, priority: 1); + var b = NewConfig(tenantId, ProviderType.Keycloak, DeadIssuerSecondary, priority: 2, fallbackToId: a.GetId().GetValue()); + a.Props.FallbackToId = b.GetId().GetValue(); // cierra el ciclo A→B→A + await SeedConfigAsync(a, ct); + await SeedConfigAsync(b, ct); + + // Callback bien formado (state==expectedState): la resolución de config y la comprobación de + // 'state' pasan; el intercambio de código muere en transporte (endpoint cerrado) → infra. + var callbackJson = BuildWellFormedButUnusedCallback(); + + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId, alsoRegister: IdpStrategy.GenericOidc); + + await using var chainDb = CreateDbContext(); + await using var storeDb = CreateDbContext(); + using var tokenHttp = NewHttpClient(); + using var jwksHttp = NewHttpClient(); + var audit = new Mock(); + var chain = BuildChain(chainDb, storeDb, tokenHttp, jwksHttp, audit.Object); + + var result = await chain.AuthenticateAsync(tenant, callbackJson, systemSuiteId: null, emailDomain: null, "10.0.0.1", ct); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("AUTH_018", "cadena agotada por indisponibilidad + ciclo → 503, no 401"); + // A y B intentados una vez cada uno; sin bucle infinito. + audit.Verify(evt => evt.RecordAuthEventAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + // ── Composición del camino REAL (store → adaptador → token client → validador → cadena) ───────── + + private static IdpChainAuthenticator BuildChain( + UmsPlatformDbContext chainDb, + UmsPlatformDbContext storeDb, + HttpClient tokenHttp, + HttpClient jwksHttp, + IAuthAuditService audit) + { + // Store REAL: lee las IdpConfiguration del inquilino de Postgres y parsea sus endpoints. + var store = new IdpConfigurationOidcProviderConfigStore(new PostgreSqlIdpConfigurationRepository(storeDb)); + // Adaptador OIDC REAL (Authorization Code + PKCE) con token client + validador reales. + var adapter = new OidcIdpAuthAdapter( + store, + new HttpOidcTokenClient(tokenHttp), + new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System)); + // Estrategia = enruta al adaptador OIDC real (mismo destino que la Shell.Factory de producción). + var strategy = new SingleAdapterIdpAuthStrategy(adapter); + + // Tope de saltos: default seguro (no configurado ⇒ el proveedor devuelve el default pasado). + var config = new Mock(); + config.Setup(c => c.GetValueAs(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, def) => def); + + return new IdpChainAuthenticator(new PostgreSqlIdpConfigurationRepository(chainDb), strategy, audit, config.Object); + } + + // ── Siembra y lectura sobre el Postgres REAL ──────────────────────────────────────────────────── + + private async Task SeedConfigAsync(IdpConfigurationAggregate config, CancellationToken ct) + { + await using var db = CreateDbContext(); + var repo = new PostgreSqlIdpConfigurationRepository(db); + await repo.AddAsync(config, ct); + await repo.SaveChangesAsync(ct); + } + + private UmsPlatformDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_fixture.ConnectionString, sql => sql.EnableRetryOnFailure(3)) + .Options; + // Contexto de sistema (org nula) ⇒ sin filtro global por inquilino: siembra y lectura ven la fila. + return new UmsPlatformDbContext( + options, + new SystemTenantContext(), + new Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private static IdpConfigurationAggregate NewConfig( + Guid tenantId, ProviderType providerType, string issuer, int priority, Guid? fallbackToId = null) + { + var payload = JsonSerializer.Serialize(new + { + issuer, + client_id = KeycloakContainerFixture.ClientId, + redirect_uri = KeycloakContainerFixture.RedirectUri, + scope = "openid email profile", + }); + + var config = IdpConfigurationAggregate.Create( + TenantId.Load(tenantId), + SystemSuiteId.Create(), + providerType, + Array.Empty(), + payload, + "vault/secret/idp", + priority, + fallbackToId, + Actor).Value; + config.Activate(Actor); + return config; + } + + // ── Inquilino con proveedores registrados (puente reglas↔dominio por Id de estrategia) ────────── + + private static TenantAggregate BuildTenant(IdpStrategy active, Guid tenantId, params IdpStrategy[] alsoRegister) + { + var tenant = TenantAggregate.Create( + Code.Create("RANSA"), + Name.Create("Ransa"), + OrganizationType.INTERNAL, + Actor, + active, + tenantId: TenantId.Load(tenantId)).Value; + + tenant.RegisterIdentityProvider( + Code.Create(active.Name.ToUpperInvariant()), Name.Create(active.Name), Description.Create(string.Empty), active, Actor); + + foreach (var strategy in alsoRegister) + { + tenant.RegisterIdentityProvider( + Code.Create(strategy.Name.ToUpperInvariant()), Name.Create(strategy.Name), Description.Create(string.Empty), strategy, Actor); + } + + // Se activa el proveedor primario; los demás quedan registrados+inactivos (la cadena 2b los usa + // igualmente porque la gobierna IdpConfiguration.Status, no el flag de proveedor activo, §2.5). + tenant.ActivateIdentityProvider(tenant.IdentityProviders.First().GetId(), Actor); + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private OidcProviderConfig BuildRealKeycloakConfig() + => new( + _keycloak.Endpoints, + new OidcClientSettings( + KeycloakContainerFixture.ClientId, + ClientSecret: null, // cliente público + PKCE + KeycloakContainerFixture.RedirectUri, + "openid email profile")); + + private static AppConfigurationAggregate BuildUseExternalIdp(Guid tenantId, bool value) + => AppConfigurationAggregate.Create( + TenantId.Load(tenantId), null, null, + Code.Create(AppConfigurationCodes.AuthUseExternalIdp), + ConfigurationValue.Create(value.ToString().ToLowerInvariant()), + Description.Create("Use external IDP"), + true, false, Actor).Value; + + // ── Login headless (Authorization Code + PKCE) contra el Keycloak real ────────────────────────── + + /// + /// Ejerce el login real (GET del formulario → POST de credenciales → 302 con ?code=&state=) + /// y empaqueta el callback como JSON tal cual lo consume el adaptador. Un + /// opcional permite manipular el code (para forzar un rechazo real de credencial, escenario 4). + /// + private async Task ObtainRealCallbackJsonAsync( + OidcProviderConfig config, CancellationToken ct, Func? codeMutator = null) + { + var authRequest = new OidcAuthorizationRequestFactory().Build(config); + var (code, state) = await PerformHeadlessLoginAsync(authRequest.AuthorizationUrl, ct); + state.Should().Be(authRequest.State, "Keycloak devuelve el mismo 'state' emitido"); + + return JsonSerializer.Serialize(new + { + code = codeMutator?.Invoke(code) ?? code, + state, + expectedState = authRequest.State, + expectedNonce = authRequest.Nonce, + codeVerifier = authRequest.CodeVerifier, + redirectUri = KeycloakContainerFixture.RedirectUri, + }); + } + + /// Callback bien formado con state==expectedState pero sin login real (para eslabones muertos). + private static string BuildWellFormedButUnusedCallback() + { + var opaque = Guid.NewGuid().ToString("N"); + return JsonSerializer.Serialize(new + { + code = "unused-code-" + opaque, + state = opaque, + expectedState = opaque, + expectedNonce = Guid.NewGuid().ToString("N"), + codeVerifier = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"), + redirectUri = KeycloakContainerFixture.RedirectUri, + }); + } + + /// Manipula el code preservando el formato: Keycloak lo rechaza con 400 invalid_grant. + private static string TamperCode(string code) + { + var chars = code.ToCharArray(); + chars[0] = chars[0] == 'A' ? 'B' : 'A'; + return new string(chars); + } + + private static async Task<(string Code, string State)> PerformHeadlessLoginAsync( + string authorizationUrl, CancellationToken ct) + { + // Manejo manual de cookies: la CookieContainer de .NET descarta las cookies de sesión de Keycloak + // (SameSite=None sobre HTTP), lo que hace que el POST de login sea rechazado. Se reenvían a mano. + using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }; + using var http = new HttpClient(handler); + + using var pageResponse = await http.GetAsync(authorizationUrl, ct); + pageResponse.IsSuccessStatusCode.Should().BeTrue("el endpoint de autorización debe servir el formulario de login"); + var html = await pageResponse.Content.ReadAsStringAsync(ct); + var cookieHeader = BuildCookieHeader(pageResponse); + + var formAction = ExtractLoginFormAction(html); + formAction.Should().NotBeNullOrWhiteSpace("el formulario de login de Keycloak debe exponer su 'action'"); + + var loginForm = new Dictionary + { + ["username"] = KeycloakContainerFixture.TestUsername, + ["password"] = KeycloakContainerFixture.TestPassword, + ["credentialId"] = string.Empty, + }; + + using var loginRequest = new HttpRequestMessage(HttpMethod.Post, formAction) + { + Content = new FormUrlEncodedContent(loginForm), + }; + if (!string.IsNullOrEmpty(cookieHeader)) + { + loginRequest.Headers.Add("Cookie", cookieHeader); + } + + using var loginResponse = await http.SendAsync(loginRequest, ct); + if (loginResponse.StatusCode is not (HttpStatusCode.Found or HttpStatusCode.SeeOther or HttpStatusCode.Redirect)) + { + var body = await loginResponse.Content.ReadAsStringAsync(ct); + var snippet = body.Length > 600 ? body[..600] : body; + throw new Xunit.Sdk.XunitException( + $"Login POST no redirigió. Status={(int)loginResponse.StatusCode}. Action={formAction}. Body:\n{snippet}"); + } + + var location = loginResponse.Headers.Location; + location.Should().NotBeNull("Keycloak debe devolver Location con el redirect_uri + code"); + + var query = ParseQuery(location!); + query.Should().ContainKey("code"); + query.Should().ContainKey("state"); + return (query["code"], query["state"]); + } + + private static string BuildCookieHeader(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + return string.Empty; + } + + var pairs = setCookies + .Select(sc => sc.Split(';', 2)[0].Trim()) + .Where(pair => pair.Length > 0 && pair.Contains('=')); + + return string.Join("; ", pairs); + } + + private static string ExtractLoginFormAction(string html) + { + var login = Regex.Match( + html, "id=\"kc-form-login\"[^>]*action=\"(?[^\"]+)\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + var raw = login.Success + ? login.Groups["url"].Value + : Regex.Match(html, "]*action=\"(?[^\"]+)\"[^>]*method=\"post\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline).Groups["url"].Value; + + return WebUtility.HtmlDecode(raw); + } + + private static Dictionary ParseQuery(Uri location) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in location.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = pair.Split('=', 2); + result[Uri.UnescapeDataString(kv[0])] = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty; + } + + return result; + } + + private static HttpClient NewHttpClient() => new() { Timeout = TimeSpan.FromSeconds(30) }; + + // ── Dobles de test (mínimos, documentados) ────────────────────────────────────────────────────── + + /// + /// Único doble del test: enruta cualquier estrategia al REAL, tal como + /// hace IdpAuthAdapterFactorySetup (Shell.Factory) en producción para las estrategias OIDC. + /// Evita cablear la fábrica DI sin desviarse del camino real (store → adaptador → token client → validador). + /// + private sealed class SingleAdapterIdpAuthStrategy : IIdpAuthStrategy + { + private readonly OidcIdpAuthAdapter _adapter; + + public SingleAdapterIdpAuthStrategy(OidcIdpAuthAdapter adapter) => _adapter = adapter; + + public Task> AuthenticateAsync( + Guid tenantId, string credential, IdentityProvider provider, CancellationToken cancellationToken = default) + => _adapter.ValidateAsync(provider, credential, cancellationToken); + } + + /// Contexto de inquilino de sistema (org nula ⇒ sin filtro global por inquilino). + private sealed class SystemTenantContext : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakCollectionDefinition.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakCollectionDefinition.cs new file mode 100644 index 00000000..085d6891 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakCollectionDefinition.cs @@ -0,0 +1,11 @@ +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109: colección que arranca el Keycloak del arnés () +/// una sola vez y lo comparte entre los tests del arnés OIDC real. +/// +[CollectionDefinition("Keycloak")] +public sealed class KeycloakCollectionDefinition : ICollectionFixture +{ + // Clase marcador — xUnit cablea el fixture por el atributo de arriba. +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakContainerFixture.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakContainerFixture.cs new file mode 100644 index 00000000..c538d737 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakContainerFixture.cs @@ -0,0 +1,123 @@ +using System.Net; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Ums.Infrastructure.Identity.Auth.Oidc; + +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109 (deuda de ADR-UMS-094): arnés Keycloak REAL sobre Testcontainers. Levanta un +/// Keycloak contenerizado (start-dev --import-realm) con el realm beyondnet +/// importado —cliente ums-client (PKCE S256 + Direct Access Grant) y usuario de +/// prueba con contraseña— para ejercer el adaptador OIDC federado contra un IdP real, +/// sin mocks del token endpoint ni del JWKS (lo que el ADR-UMS-094 prometió y el +/// slice 1 no construyó: sus tests usan JWKS/token mockeados con issuer ficticio +/// idp.test). +/// +/// Calca el patrón desechable/reproducible de PostgreSqlContainerFixture: si +/// Docker no está disponible o la imagen no se puede traer, +/// queda en false y los tests hacen Assert.Skip(...) en vez de fallar en +/// crudo. El contenedor se comparte una vez por la colección [Collection("Keycloak")]. +/// +public sealed class KeycloakContainerFixture : IAsyncLifetime +{ + // Imagen estable y pineada de Keycloak (26.x). start-dev habilita HTTP y no exige TLS, + // apropiado para un IdP de pruebas efímero. + private const string KeycloakImage = "quay.io/keycloak/keycloak:26.3.2"; + private const string RealmName = "beyondnet"; + + private IContainer? _container; + + /// Cliente OIDC público registrado en el realm importado. + public const string ClientId = "ums-client"; + + /// Redirect URI válida registrada en el cliente para el flujo authorization_code. + public const string RedirectUri = "https://ums.test/callback"; + + /// Usuario de prueba (con contraseña) sembrado por el realm importado. + public const string TestUsername = "ana.ransa"; + public const string TestPassword = "Password123!"; + public const string TestEmail = "ana.ransa@ransa.pe"; + + /// URL base del Keycloak del contenedor (host + puerto mapeado). Vacía si no arrancó. + public string BaseAddress { get; private set; } = string.Empty; + + /// Issuer real del realm: {BaseAddress}/realms/beyondnet. + public string RealmIssuer { get; private set; } = string.Empty; + + /// + /// Endpoints OIDC reales derivados del issuer del contenedor (auth/token/jwks), tal + /// como los consume el adaptador. Mismo esquema que Keycloak publica en su discovery. + /// + public OidcEndpoints Endpoints { get; private set; } = + new(string.Empty, string.Empty, string.Empty, string.Empty); + + /// + /// true cuando Keycloak arrancó y el realm quedó servido. Los tests deben + /// llamar Assert.Skip(!IsAvailable, "Docker/Keycloak requerido"). + /// + public bool IsAvailable { get; private set; } + + /// Diagnóstico del fallo de arranque (si lo hubo), para reportar sin fingir verde. + public string? StartupError { get; private set; } + + public async ValueTask InitializeAsync() + { + try + { + var realmPath = Path.Combine( + AppContext.BaseDirectory, "Identity", "Auth", "Oidc", "beyondnet-realm.json"); + var realmBytes = await File.ReadAllBytesAsync(realmPath); + + _container = new ContainerBuilder() + .WithImage(KeycloakImage) + // Bootstrap admin (KC 26+). No lo usa el test, pero evita advertencias de arranque. + .WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", "admin") + .WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin") + .WithEnvironment("KC_HTTP_ENABLED", "true") + .WithEnvironment("KC_HOSTNAME_STRICT", "false") + // Inyecta el realm importable en el directorio que --import-realm lee al arrancar. + .WithResourceMapping( + realmBytes, + "/opt/keycloak/data/import/beyondnet-realm.json", + UnixFileModes.UserRead | UnixFileModes.GroupRead | UnixFileModes.OtherRead) + .WithCommand("start-dev", "--import-realm") + .WithPortBinding(8080, assignRandomHostPort: true) + // Listo = el realm responde su discovery OIDC (el import ya ocurrió). + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request + .ForPort(8080) + .ForPath($"/realms/{RealmName}/.well-known/openid-configuration") + .ForStatusCode(HttpStatusCode.OK))) + .Build(); + + await _container.StartAsync(); + + BaseAddress = $"http://{_container.Hostname}:{_container.GetMappedPublicPort(8080)}"; + RealmIssuer = $"{BaseAddress}/realms/{RealmName}"; + Endpoints = new OidcEndpoints( + AuthorizationEndpoint: $"{RealmIssuer}/protocol/openid-connect/auth", + TokenEndpoint: $"{RealmIssuer}/protocol/openid-connect/token", + JwksUri: $"{RealmIssuer}/protocol/openid-connect/certs", + Issuer: RealmIssuer); + + IsAvailable = true; + } + catch (Exception ex) + { + // No relanzamos: Docker/Keycloak no disponibles ⇒ los tests se saltan con mensaje claro + // (a diferencia de Postgres, este arnés es aditivo y no debe tumbar la corrida entera). + StartupError = ex.ToString(); + IsAvailable = false; + } + } + + public async ValueTask DisposeAsync() + { + if (_container is not null) + { + await _container.DisposeAsync(); + } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakOidcRealHarnessTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakOidcRealHarnessTests.cs new file mode 100644 index 00000000..db9450dc --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakOidcRealHarnessTests.cs @@ -0,0 +1,408 @@ +using System.Net.Http; +using System.Text.RegularExpressions; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using Ums.Infrastructure.Identity.Auth.Oidc; + +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109 (deuda de ADR-UMS-094): arnés de integración que ejerce el adaptador OIDC federado +/// contra un Keycloak real (Testcontainers), sin mocks del token endpoint ni del JWKS. +/// +/// Los tests unitarios (Application.Test/.../Oidc) validan el id_token con JWKS/token +/// mockeados e issuer ficticio idp.test. Aquí lo esencial es lo que esos mocks NO cubren: +/// que un id_token emitido y firmado por llaves RSA reales de Keycloak pase por la ruta +/// de validación real del adaptador — contra el JWKS real + +/// (firma RS256, iss/aud/exp/nonce). +/// +/// Cobertura por test: +/// +/// ESENCIAL (Direct Access Grant): id_token real → JWKS real → validador real. +/// Positivo (firma/iss/aud reales) y negativos con llaves reales (aud, firma manipulada, iss). +/// DESEABLE (authorization_code + PKCE headless): flujo completo a través de +/// con real (intercambio de +/// código + PKCE) — incluye validación de nonce real emitido en la autorización. +/// OPCIONAL (resolución por configuración, estilo FR-042): endpoints derivados por +/// desde un ConfigPayload con solo el issuer del +/// Keycloak del contenedor, y validación de un id_token real contra ellos. +/// +/// +[Collection("Keycloak")] +public sealed class KeycloakOidcRealHarnessTests +{ + private readonly KeycloakContainerFixture _keycloak; + + public KeycloakOidcRealHarnessTests(KeycloakContainerFixture keycloak) => _keycloak = keycloak; + + // ── ESENCIAL: id_token REAL por la ruta de validación REAL (JWKS + validador) ────────────── + + [Fact] + public async Task RealIdToken_ViaDirectAccessGrant_PassesRealJwksAndValidator() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + idToken.Should().NotBeNullOrWhiteSpace("Keycloak debe emitir un id_token real para el password grant con scope openid"); + + // Ruta de validación REAL: HttpJwksProvider hace GET al JWKS real de Keycloak; + // OidcIdTokenValidator verifica firma RS256 contra las llaves reales + iss/aud/exp. + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + + var result = await validator.ValidateAsync( + idToken!, + _keycloak.Endpoints, + expectedAudience: KeycloakContainerFixture.ClientId, + // Direct Access Grant no emite 'nonce' (solo el authorization endpoint lo hace): + // el validador omite la comprobación de nonce cuando expectedNonce es vacío. + expectedNonce: string.Empty, + ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Email.Should().Be(KeycloakContainerFixture.TestEmail); + result.Value.ExternalId.Should().NotBeNullOrWhiteSpace("el 'sub' real de Keycloak mapea a ExternalId"); + result.Value.Claims.Should().ContainKey("preferred_username"); + } + + [Fact] + public async Task RealIdToken_WrongAudience_RejectedByRealValidator_AUTH028() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + + // Firma/JWKS reales, pero exigimos una audiencia distinta a la del token real ⇒ rechazo. + var result = await validator.ValidateAsync( + idToken!, _keycloak.Endpoints, expectedAudience: "otro-cliente", expectedNonce: string.Empty, ct); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("AUTH_028"); + } + + [Fact] + public async Task RealIdToken_TamperedSignature_RejectedByRealValidator_AUTH026() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + + // Manipula la firma (último segmento) preservando el formato JWT: el validador debe + // rechazarla al verificar contra las llaves públicas reales del JWKS. + var parts = idToken!.Split('.'); + var sig = parts[2].ToCharArray(); + sig[0] = sig[0] == 'A' ? 'B' : 'A'; + var tampered = $"{parts[0]}.{parts[1]}.{new string(sig)}"; + + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + + var result = await validator.ValidateAsync( + tampered, _keycloak.Endpoints, expectedAudience: KeycloakContainerFixture.ClientId, + expectedNonce: string.Empty, ct); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("AUTH_026"); + } + + [Fact] + public async Task RealIdToken_WrongIssuer_RejectedByRealValidator_AUTH027() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + + // JWKS real (mismo host), pero declaramos un issuer esperado distinto ⇒ rechazo por iss. + var endpointsWithWrongIssuer = _keycloak.Endpoints with { Issuer = "https://malicioso.test/realms/beyondnet" }; + + var result = await validator.ValidateAsync( + idToken!, endpointsWithWrongIssuer, expectedAudience: KeycloakContainerFixture.ClientId, + expectedNonce: string.Empty, ct); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("AUTH_027"); + } + + // ── DESEABLE: flujo authorization_code + PKCE headless por el adaptador completo ───────────── + + [Fact] + public async Task FullAuthorizationCodePkceFlow_ThroughAdapter_ReturnsExternalIdentity() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + + var config = new OidcProviderConfig( + _keycloak.Endpoints, + new OidcClientSettings( + ClientId: KeycloakContainerFixture.ClientId, + ClientSecret: null, // cliente público + PKCE + RedirectUri: KeycloakContainerFixture.RedirectUri, + Scopes: "openid email profile")); + + // 1) El adaptador construye la URL de autorización (state/nonce/code_verifier/code_challenge S256). + var authRequest = new OidcAuthorizationRequestFactory().Build(config); + + // 2) Login headless contra Keycloak real: GET del form → POST credenciales → 302 con ?code=&state=. + var (code, returnedState) = await PerformHeadlessAuthorizationCodeLoginAsync(authRequest.AuthorizationUrl, ct); + returnedState.Should().Be(authRequest.State, "Keycloak devuelve el mismo 'state' emitido"); + + // 3) El nivel de presentación empaqueta el callback como JSON; el adaptador lo intercambia por + // tokens (HttpOidcTokenClient real, grant_type=authorization_code + code_verifier) y valida + // el id_token real (JWKS real + nonce real emitido en la autorización). + var callbackJson = JsonSerializer.Serialize(new + { + code, + state = returnedState, + expectedState = authRequest.State, + expectedNonce = authRequest.Nonce, + codeVerifier = authRequest.CodeVerifier, + redirectUri = KeycloakContainerFixture.RedirectUri, + }); + + using var tokenHttp = new HttpClient(); + using var jwksHttp = new HttpClient(); + var adapter = new OidcIdpAuthAdapter( + new StaticOidcProviderConfigStore(config), + new HttpOidcTokenClient(tokenHttp), + new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System)); + + var result = await adapter.ValidateAsync(BuildKeycloakProvider(), callbackJson, ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Email.Should().Be(KeycloakContainerFixture.TestEmail); + result.Value.DisplayName.Should().NotBeNullOrWhiteSpace(); + } + + // ── OPCIONAL: resolución por configuración (parser) contra el Keycloak real (estilo FR-042) ── + + [Fact] + public async Task ConfigPayloadIssuerOnly_ResolvesRealKeycloakEndpoints_AndValidatesRealToken() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + + // ConfigPayload con SOLO el issuer del contenedor: el parser deriva auth/token/jwks al estilo + // Keycloak/OIDC (como lo haría IdpConfiguration.ConfigPayload en runtime, sin hardcodear endpoints). + var configPayload = JsonSerializer.Serialize(new + { + issuer = _keycloak.RealmIssuer, + client_id = KeycloakContainerFixture.ClientId, + redirect_uri = KeycloakContainerFixture.RedirectUri, + scope = "openid email profile", + }); + + var parsed = OidcProviderConfigParser.Parse(configPayload); + parsed.IsSuccess.Should().BeTrue(parsed.IsFailure ? parsed.Error : null); + + // Los endpoints derivados por el parser deben coincidir con los reales de Keycloak. + parsed.Value.Endpoints.JwksUri.Should().Be(_keycloak.Endpoints.JwksUri); + parsed.Value.Endpoints.TokenEndpoint.Should().Be(_keycloak.Endpoints.TokenEndpoint); + + // Y un id_token real debe validar contra esos endpoints derivados (JWKS real alcanzable). + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + var result = await validator.ValidateAsync( + idToken!, parsed.Value.Endpoints, expectedAudience: KeycloakContainerFixture.ClientId, + expectedNonce: string.Empty, ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Email.Should().Be(KeycloakContainerFixture.TestEmail); + } + + // ── Helpers ───────────────────────────────────────────────────────────────────────────────── + + /// + /// Pide un id_token REAL a Keycloak por Direct Access Grant (grant_type=password) con + /// scope openid. Es un token firmado por las llaves reales del realm, aunque no sea el + /// flujo authorization_code — suficiente para ejercer la validación real (JWKS + firma). + /// + private async Task RequestIdTokenViaDirectAccessGrantAsync(HttpClient http, CancellationToken ct) + { + var form = new Dictionary + { + ["grant_type"] = "password", + ["client_id"] = KeycloakContainerFixture.ClientId, + ["username"] = KeycloakContainerFixture.TestUsername, + ["password"] = KeycloakContainerFixture.TestPassword, + ["scope"] = "openid email profile", + }; + + using var response = await http.PostAsync( + _keycloak.Endpoints.TokenEndpoint, new FormUrlEncodedContent(form), ct); + var body = await response.Content.ReadAsStringAsync(ct); + response.IsSuccessStatusCode.Should().BeTrue($"token endpoint real debe responder 200; body: {body}"); + + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("id_token", out var idToken) ? idToken.GetString() : null; + } + + /// + /// Ejercita el login headless del flujo authorization_code contra Keycloak real: GET del + /// endpoint de autorización → parsea el action del formulario de login → POST de + /// credenciales → captura el 302 al redirect_uri y extrae code/state. + /// No se sigue el redirect (redirect_uri no resuelve; solo interesa el 'code'). + /// + private static async Task<(string Code, string State)> PerformHeadlessAuthorizationCodeLoginAsync( + string authorizationUrl, CancellationToken ct) + { + // Manejo manual de cookies: la CookieContainer de .NET descarta las cookies de sesión de + // Keycloak (KC_RESTART/AUTH_SESSION_ID con SameSite=None sobre HTTP), lo que hace que el POST + // de login sea rechazado con 400 «cookie not found». Reenviamos los pares name=value a mano. + using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }; + using var http = new HttpClient(handler); + + using var pageResponse = await http.GetAsync(authorizationUrl, ct); + pageResponse.IsSuccessStatusCode.Should().BeTrue("el endpoint de autorización debe servir el formulario de login"); + var html = await pageResponse.Content.ReadAsStringAsync(ct); + var cookieHeader = BuildCookieHeader(pageResponse); + + var formAction = ExtractLoginFormAction(html); + formAction.Should().NotBeNullOrWhiteSpace("el formulario de login de Keycloak debe exponer su 'action'"); + + var loginForm = new Dictionary + { + ["username"] = KeycloakContainerFixture.TestUsername, + ["password"] = KeycloakContainerFixture.TestPassword, + ["credentialId"] = string.Empty, + }; + + using var loginRequest = new HttpRequestMessage(HttpMethod.Post, formAction) + { + Content = new FormUrlEncodedContent(loginForm), + }; + if (!string.IsNullOrEmpty(cookieHeader)) + { + loginRequest.Headers.Add("Cookie", cookieHeader); + } + + using var loginResponse = await http.SendAsync(loginRequest, ct); + if (loginResponse.StatusCode is not (HttpStatusCode.Found or HttpStatusCode.SeeOther or HttpStatusCode.Redirect)) + { + var diagBody = await loginResponse.Content.ReadAsStringAsync(ct); + var snippet = diagBody.Length > 600 ? diagBody[..600] : diagBody; + throw new Xunit.Sdk.XunitException( + $"Login POST no redirigió. Status={(int)loginResponse.StatusCode}. Cookies=[{cookieHeader}]. Action={formAction}. Body:\n{snippet}"); + } + + var location = loginResponse.Headers.Location; + location.Should().NotBeNull("Keycloak debe devolver Location con el redirect_uri + code"); + + var query = ParseQuery(location!); + query.Should().ContainKey("code"); + query.Should().ContainKey("state"); + return (query["code"], query["state"]); + } + + /// + /// Extrae los pares name=value de todas las cabeceras Set-Cookie de la respuesta + /// y los une para reenviarlos como cabecera Cookie en el POST de login (manejo manual, + /// evitando las peculiaridades de con Keycloak). + /// + private static string BuildCookieHeader(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + return string.Empty; + } + + var pairs = setCookies + .Select(sc => sc.Split(';', 2)[0].Trim()) + .Where(pair => pair.Length > 0 && pair.Contains('=')); + + return string.Join("; ", pairs); + } + + private static string ExtractLoginFormAction(string html) + { + // El template de login de Keycloak usa id="kc-form-login" con un action de POST. + var login = Regex.Match( + html, "id=\"kc-form-login\"[^>]*action=\"(?[^\"]+)\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + var raw = login.Success + ? login.Groups["url"].Value + : Regex.Match(html, "]*action=\"(?[^\"]+)\"[^>]*method=\"post\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline).Groups["url"].Value; + + return WebUtility.HtmlDecode(raw); + } + + private static Dictionary ParseQuery(Uri location) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in location.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = pair.Split('=', 2); + result[Uri.UnescapeDataString(kv[0])] = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty; + } + + return result; + } + + private static IdentityProvider BuildKeycloakProvider() + => IdentityProvider.Create( + TenantId.Load(Guid.NewGuid()), + Code.Create("KC"), + Name.Create("Keycloak"), + Description.Create("IdP corporativo (arnes real G-109)"), + IdpStrategy.Keycloak, + ActorId.Create("00000000-0000-0000-0000-000000000109")).Value; + + /// + /// estático que devuelve la configuración OIDC del + /// Keycloak del contenedor. La resolución real por prioridad/suite/dominio (FR-042) vive en + /// otro subsistema; aquí no se prueba la resolución sino el protocolo OIDC contra el IdP real. + /// + private sealed class StaticOidcProviderConfigStore : IOidcProviderConfigStore + { + private readonly OidcProviderConfig _config; + + public StaticOidcProviderConfigStore(OidcProviderConfig config) => _config = config; + + public Task> GetAsync( + IdentityProvider provider, CancellationToken cancellationToken = default) + => Task.FromResult(Ums.Domain.Kernel.Result.Success(_config)); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakCollectionDefinition.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakCollectionDefinition.cs new file mode 100644 index 00000000..5194947d --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakCollectionDefinition.cs @@ -0,0 +1,12 @@ +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109: colección que arranca UNA sola vez el fixture compuesto Postgres+Keycloak +/// () y lo comparte entre los tests del E2E de +/// resolución/fallback FR-042 respaldado por BD. +/// +[CollectionDefinition("PostgresKeycloak")] +public sealed class PostgresKeycloakCollectionDefinition : ICollectionFixture +{ + // Clase marcador — xUnit cablea el fixture por el atributo de arriba. +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakFixture.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakFixture.cs new file mode 100644 index 00000000..783798da --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakFixture.cs @@ -0,0 +1,76 @@ +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109 (residual de FR-042, ADR-UMS-097): fixture compuesto que orquesta a la vez los DOS +/// contenedores reales que exige el E2E de resolución/fallback FR-042 respaldado por BD: +/// +/// Postgres — donde se siembran y de donde se leen las IdpConfiguration reales +/// (el camino de producción del store IdpConfigurationOidcProviderConfigStore). +/// Keycloak — el IdP OIDC real contra el que se autentica (Authorization Code + PKCE). +/// +/// +/// Reto xUnit v3 resuelto: una clase de test pertenece a UNA sola colección, así que no +/// puede combinar las colecciones PostgreSql y Keycloak por separado. Aquí se elige la +/// opción de fixture que compone ambos: reutiliza tal cual los fixtures existentes +/// ( y ) sin tocarlos, y +/// se expone bajo un único ICollectionFixture (ver PostgresKeycloakCollectionDefinition). +/// +/// Skip sin Docker en AMBOS: el fixture de Keycloak ya traga el fallo de arranque; el de +/// Postgres, en cambio, relanza (semántica de su colección original). Este compuesto captura ese +/// relanzamiento para conservar la semántica de Assert.Skip también cuando falta Docker. +/// +public sealed class PostgresKeycloakFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainerFixture _postgres = new(); + + /// Fixture del Keycloak real (endpoints, issuer y constantes del realm importado). + public KeycloakContainerFixture Keycloak { get; } = new(); + + /// Cadena de conexión al Postgres del contenedor (vacía si no arrancó). + public string ConnectionString => _postgres.ConnectionString; + + public bool PostgresAvailable { get; private set; } + + public bool KeycloakAvailable => Keycloak.IsAvailable; + + /// Ambos contenedores listos: precondición para no saltar los tests del E2E. + public bool BothAvailable => PostgresAvailable && KeycloakAvailable; + + /// Diagnóstico agregado del fallo de arranque (sin fingir verde, SD-05). + public string StartupError => + $"Postgres={(PostgresAvailable ? "OK" : _postgresError ?? "no disponible")}; " + + $"Keycloak={(KeycloakAvailable ? "OK" : Keycloak.StartupError ?? "no disponible")}"; + + private string? _postgresError; + + public async ValueTask InitializeAsync() + { + // Dos contenedores son LENTOS: se arrancan en PARALELO para acotar el tiempo de la colección. + var keycloakTask = Keycloak.InitializeAsync().AsTask(); + var postgresTask = StartPostgresAsync(); + await Task.WhenAll(keycloakTask, postgresTask); + } + + private async Task StartPostgresAsync() + { + try + { + await _postgres.InitializeAsync(); + PostgresAvailable = _postgres.IsAvailable; + } + catch (Exception ex) + { + // El fixture de Postgres relanza ante Docker ausente; lo capturamos para saltar (no tumbar). + _postgresError = ex.Message; + PostgresAvailable = false; + } + } + + public async ValueTask DisposeAsync() + { + await Keycloak.DisposeAsync(); + await _postgres.DisposeAsync(); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/beyondnet-realm.json b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/beyondnet-realm.json new file mode 100644 index 00000000..abd894f8 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/beyondnet-realm.json @@ -0,0 +1,63 @@ +{ + "realm": "beyondnet", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "accessTokenLifespan": 300, + "clients": [ + { + "clientId": "ums-client", + "name": "UMS Federated Client (arnes de prueba)", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "fullScopeAllowed": true, + "redirectUris": [ + "https://ums.test/callback", + "http://localhost/*", + "http://127.0.0.1/*" + ], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + }, + "defaultClientScopes": [ + "acr", + "basic", + "email", + "profile", + "roles", + "web-origins" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "users": [ + { + "username": "ana.ransa", + "enabled": true, + "emailVerified": true, + "email": "ana.ransa@ransa.pe", + "firstName": "Ana", + "lastName": "Ransa", + "credentials": [ + { + "type": "password", + "value": "Password123!", + "temporary": false + } + ] + } + ] +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/AuthEndpointRoutingTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/AuthEndpointRoutingTests.cs index 16d2b6f1..79f96c8e 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/AuthEndpointRoutingTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/AuthEndpointRoutingTests.cs @@ -26,4 +26,24 @@ public void VisualLoginAndClientAuthentication_ShouldBeMappedToDifferentApiRoots routes.Should().Contain("/api/v1/client/authenticate"); routes.Should().NotContain("/api/v1/auth/client/authenticate"); } + + /// + /// El carril de satélite tiene su propio cambio de perfil (ADR-0156 §8). Los dos endpoints + /// coexisten a propósito: `/auth/switch-profile` sirve al portal —forma de respuesta propia y + /// cookie de sesión— y `/client/switch-profile` al portador semántico, que no tiene ni puede + /// usar esa cookie. Que este test los exija a los dos es lo que impide que alguien «unifique» + /// el carril de satélite dentro del endpoint del portal, que valida el token a mano con + /// `ValidateIssuer=false` (G-201). + /// + [Fact] + public void SwitchProfile_ShouldExistOnBothRails() + { + var routes = _endpointDataSource.Endpoints + .OfType() + .Select(endpoint => endpoint.RoutePattern.RawText) + .ToArray(); + + routes.Should().Contain("/api/v1/auth/switch-profile"); + routes.Should().Contain("/api/v1/client/switch-profile"); + } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/BranchClosureTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/BranchClosureTests.cs new file mode 100644 index 00000000..bd0e1067 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/BranchClosureTests.cs @@ -0,0 +1,344 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Identity; + +/// +/// ADR-0164 aplicado a las SUCURSALES, verificado contra PostgreSQL real (Testcontainers). +/// +/// Antes de este cambio, DELETE /tenants/{id}/branches/{branchId} ejecutaba un +/// DELETE de verdad: Tenant.RemoveBranch quitaba la sucursal de la colección y +/// EfChildCollectionReconciler traducía la ausencia en un borrado físico. Como +/// UserAccounts.BranchId y Profiles.BranchId no tienen clave ajena contra +/// TenantBranches, esas filas quedaban apuntando al vacío sin que nadie se enterara. +/// +/// Lo que se fija aquí, y por qué cada cosa se mide donde se mide: +/// +/// +/// La fila sigue en la base. Se lee con un contexto EF propio, NO por el API: preguntar al +/// API no distingue «oculto» de «borrado», y esa distinción es justamente lo que hay que probar. +/// +/// +/// Cerrar con referencias vivas se rechaza con 409 y el cuerpo nombra qué bloquea. Con la +/// contraprueba obligatoria: una vez que esa referencia deja de estar activa, el cierre procede. +/// +/// +/// El código no se libera. El índice único no filtra por el estado de cierre, y el alta +/// responde un conflicto legible en vez de reventar con un 23505. +/// +/// La bitácora registra el episodio, con su fecha y su autor. +/// +/// +[Collection("PostgreSql")] +public sealed class BranchClosureTests : IntegrationTestBase +{ + /// Identificadores de BranchLifecycleEpisode tal y como se persisten. + private const int EpisodioApertura = 1; + private const int EpisodioCierre = 4; + + public BranchClosureTests(PostgreSqlContainerFixture fixture) : base(fixture) { } + + [Fact] + public async Task Cerrar_DejaLaFilaEnLaBase_ConSuCodigoYSuAuditoria() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, codigo) = await CrearSucursalAsync(tenantId, "Terminal Callao", ct); + + var cierre = await Client.DeleteAsync( + $"/api/v1/tenants/{tenantId}/branches/{branchId}?reason=Cese%20de%20operaciones%20en%20la%20plaza", ct); + cierre.StatusCode.Should().Be(HttpStatusCode.NoContent, await cierre.Content.ReadAsStringAsync(ct)); + + // LA FILA SIGUE AHÍ. Esta es la afirmación central del ADR y se mide sobre el almacenamiento, + // no sobre el API. + await using var db = CrearContextoDirecto(); + var fila = await db.TenantBranches.SingleOrDefaultAsync(b => b.Id == branchId, ct); + + fila.Should().NotBeNull("el borrado es LÓGICO: la fila no puede desaparecer de la tabla"); + fila!.IsClosed.Should().BeTrue(); + fila.IsActive.Should().BeFalse("una sucursal cerrada evidentemente no opera"); + fila.ClosedAtUtc.Should().NotBeNull(); + fila.ClosedBy.Should().NotBeNullOrWhiteSpace("la fila debe decir quién la cerró"); + fila.Code.Should().Be(codigo, "el histórico conserva el código con el que operó"); + fila.Name.Should().Be("Terminal Callao"); + + // Y la lectura ordinaria la esconde, que es lo que ve quien opera hoy. + using var listado = await LeerSucursalesAsync(tenantId, ct); + listado.RootElement.EnumerateArray().Any(b => b.GetProperty("branchId").GetGuid() == branchId) + .Should().BeFalse(); + } + + [Fact] + public async Task Cerrar_ConUsuariosActivos_Responde409ConElDesglose_YProcedeCuandoDejanDeEstarlo() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, _) = await CrearSucursalAsync(tenantId, "Almacén Paita", ct); + var userId = await CrearUsuarioActivoEnSucursalAsync(tenantId, branchId, ct); + + // (b) Referencia VIVA: se rechaza y se nombra qué bloquea. + var bloqueado = await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct); + bloqueado.StatusCode.Should().Be(HttpStatusCode.Conflict, await bloqueado.Content.ReadAsStringAsync(ct)); + + using var error = JsonDocument.Parse(await bloqueado.Content.ReadAsStringAsync(ct)); + error.RootElement.GetProperty("errorCode").GetString().Should().Be("BRANCH_HAS_LIVE_REFERENCES"); + error.RootElement.GetProperty("message").GetString().Should().NotBeNullOrWhiteSpace(); + var deps = error.RootElement.GetProperty("blockingDependencies"); + deps.GetArrayLength().Should().BeGreaterThan(0); + deps.EnumerateArray().Any(d => d.GetProperty("entityType").GetString() == "UserAccount") + .Should().BeTrue("el desglose debe decir que lo que bloquea son cuentas de usuario"); + deps.EnumerateArray().First(d => d.GetProperty("entityType").GetString() == "UserAccount") + .GetProperty("count").GetInt32().Should().Be(1); + + // Un rechazo no puede haber escrito nada. + await using (var verificacion = CrearContextoDirecto()) + { + (await verificacion.TenantBranches.SingleAsync(b => b.Id == branchId, ct)).IsClosed + .Should().BeFalse("un cierre rechazado no toca la fila"); + } + + // CONTRAPRUEBA (ADR-0164 §2.2): lo ya retirado NO bloquea. Sin ella la regla se cumpliría por + // accidente —bastaría con rechazar siempre— y nadie lo notaría. + (await Client.PostAsync($"/api/v1/user-accounts/{userId}/block?reason=reubicacion", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var cierre = await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct); + cierre.StatusCode.Should().Be(HttpStatusCode.NoContent, await cierre.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task ElCodigoDeUnaSucursalCerrada_NoSeLibera() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, codigo) = await CrearSucursalAsync(tenantId, "Depósito Tacna", ct); + + (await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // ADR-0164 §2.3: el índice único NO se filtra por el estado de cierre, así que el alta con el + // mismo código responde un CONFLICTO DE DOMINIO legible —no una violación de índice + // convertida en 500, y desde luego no un 201 que dejaría dos sucursales con el mismo código y + // volvería ambigua cualquier consulta histórica. + var reintento = await Client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", new + { + code = codigo, + name = "Depósito Tacna (segunda época)", + geofencingMetadata = (string?)null, + }, ct); + + reintento.StatusCode.Should().Be(HttpStatusCode.Conflict, await reintento.Content.ReadAsStringAsync(ct)); + + await using var db = CrearContextoDirecto(); + var filas = await db.TenantBranches.Where(b => b.TenantId == tenantId && b.Code == codigo).ToListAsync(ct); + filas.Should().HaveCount(1, "el código sigue ocupado por la sucursal cerrada y no admite una segunda"); + } + + [Fact] + public async Task LaBitacora_RegistraElCierre_ConSuFechaSuAutorYSuMotivo() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, _) = await CrearSucursalAsync(tenantId, "Terminal Matarani", ct); + + (await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}?reason=Fin%20de%20concesion", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Se lee de la TABLA, no del API: la bitácora tiene que estar persistida en la misma + // transacción que el cambio de estado, no depender de un manejador post-commit que por + // contrato (ADR-0098 D4) es best-effort y puede perder episodios sin avisar. + await using var db = CrearContextoDirecto(); + var asientos = await db.TenantBranchLifecycleEntries + .Where(e => e.BranchId == branchId) + .OrderBy(e => e.OccurredAtUtc) + .ToListAsync(ct); + + asientos.Should().HaveCount(4, "apertura, baja, reapertura y cierre"); + asientos[0].EpisodeId.Should().Be(EpisodioApertura); + asientos[^1].EpisodeId.Should().Be(EpisodioCierre); + asientos[^1].Reason.Should().Be("Fin de concesion"); + asientos.Should().OnlyContain(a => a.TenantId == tenantId); + asientos.Should().OnlyContain(a => !string.IsNullOrWhiteSpace(a.ActorId)); + asientos.Should().OnlyContain(a => a.OccurredAtUtc != default); + asientos.Should().OnlyContain(a => a.NameSnapshot == "Terminal Matarani"); + + // Y es consultable por el API, también después de cerrada: preguntar por el pasado de una + // sucursal cerrada es el caso de uso, no una excepción. + var respuesta = await Client.GetAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/bitacora", ct); + respuesta.StatusCode.Should().Be(HttpStatusCode.OK); + using var payload = JsonDocument.Parse(await respuesta.Content.ReadAsStringAsync(ct)); + payload.RootElement.EnumerateArray().Select(e => e.GetProperty("episode").GetString()) + .Should().Equal("Opened", "Deactivated", "Reactivated", "Closed"); + } + + [Fact] + public async Task DesactivarYReactivar_SiguenFuncionando_YNoSeConfundenConCerrar() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, _) = await CrearSucursalAsync(tenantId, "Sucursal Ilo", ct); + + (await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Desactivada NO es cerrada: sigue en el listado, marcada como inactiva, y vuelve. + await using (var db = CrearContextoDirecto()) + { + var fila = await db.TenantBranches.SingleAsync(b => b.Id == branchId, ct); + fila.IsActive.Should().BeFalse(); + fila.IsClosed.Should().BeFalse("desactivar no es eliminar (ADR-0164 §2.4)"); + fila.ClosedAtUtc.Should().BeNull(); + } + + using (var listado = await LeerSucursalesAsync(tenantId, ct)) + { + var dto = listado.RootElement.EnumerateArray() + .Single(b => b.GetProperty("branchId").GetGuid() == branchId); + dto.GetProperty("isActive").GetBoolean().Should().BeFalse(); + dto.GetProperty("isClosed").GetBoolean().Should().BeFalse(); + } + + (await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + await using (var db = CrearContextoDirecto()) + { + (await db.TenantBranches.SingleAsync(b => b.Id == branchId, ct)).IsActive.Should().BeTrue(); + } + } + + [Fact] + public async Task UnaSucursalCerrada_NoSePuedeReactivar() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, _) = await CrearSucursalAsync(tenantId, "Sucursal Chimbote", ct); + + (await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // La puerta de atrás cerrada: al estado terminal no se entra ni se sale manipulando el + // estado reversible (ADR-0164 §2.4). + var reactivacion = await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct); + reactivacion.StatusCode.Should().Be(HttpStatusCode.Conflict, await reactivacion.Content.ReadAsStringAsync(ct)); + + await using var db = CrearContextoDirecto(); + var fila = await db.TenantBranches.SingleAsync(b => b.Id == branchId, ct); + fila.IsClosed.Should().BeTrue(); + fila.IsActive.Should().BeFalse("una reactivación rechazada no puede haber devuelto la sucursal al servicio"); + } + + // ── Utilidades de aprovisionamiento ───────────────────────────────────── + + private async Task ProvisionarInquilinoAsync(CancellationToken ct) + { + // El host PostgreSQL corre con SeedDevData=false: cada prueba levanta su propio inquilino. + var code = $"BRCL{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var response = await Client.PostAsJsonAsync("/api/v1/tenants", new + { + code, + name = $"Operador logístico {code}", + type = "CLIENT", + isManagementOwner = false, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + var tenantId = Guid.Parse(response.Headers.Location!.ToString().Split('/')[^1]); + + // ADR-0077: aprovisionar recursos de un inquilino CLIENT es una operación ON-BEHALF que solo + // ejerce el internal-admin; sin la cabecera, TenantScopePolicy devuelve AUTH_015 → 400. + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + Client.DefaultRequestHeaders.Remove("X-Is-Internal-Admin"); + Client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + + return tenantId; + } + + private async Task<(Guid BranchId, string Code)> CrearSucursalAsync(Guid tenantId, string nombre, CancellationToken ct) + { + var codigo = $"SUC{Guid.NewGuid():N}"[..10].ToUpperInvariant(); + var response = await Client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", new + { + code = codigo, + name = nombre, + geofencingMetadata = (string?)null, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return (payload.RootElement.GetProperty("branchId").GetGuid(), codigo); + } + + private async Task CrearUsuarioActivoEnSucursalAsync(Guid tenantId, Guid branchId, CancellationToken ct) + { + var response = await Client.PostAsJsonAsync("/api/v1/user-accounts", new + { + tenantId, + branchId, + email = $"operador.{Guid.NewGuid():N}"[..24] + "@beyondnet.local", + category = "Internal", + identityReference = $"EMP-{Guid.NewGuid():N}"[..10], + identityReferenceType = "HrId", + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var userId = payload.RootElement.GetProperty("userAccountId").GetGuid(); + + (await Client.PostAsync($"/api/v1/user-accounts/{userId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + return userId; + } + + private async Task LeerSucursalesAsync(Guid tenantId, CancellationToken ct) + { + var response = await Client.GetAsync($"/api/v1/tenants/{tenantId}/branches", ct); + response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync(ct)); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + } + + /// + /// Contexto EF conectado al MISMO contenedor que el API, con inquilino nulo para que ningún filtro + /// global recorte la vista. Es la ventana al almacenamiento real: lo que el API oculta, aquí se ve. + /// + private UmsPlatformDbContext CrearContextoDirecto() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(Fixture.ConnectionString) + .Options; + + return new UmsPlatformDbContext( + options, + new ContextoDeInquilinoDelSistema(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private sealed class ContextoDeInquilinoDelSistema : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DelegationApprovalGateBehavioralTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DelegationApprovalGateBehavioralTests.cs new file mode 100644 index 00000000..83b38ffb --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DelegationApprovalGateBehavioralTests.cs @@ -0,0 +1,106 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Identity; + +/// +/// G-056 — E2E dedicado de la compuerta de aprobación de UserManagementDelegation. +/// +/// Prueba, extremo a extremo por REST, que una delegación creada con requiresApproval=true +/// NO puede activarse por la vía directa POST /delegations/{id}/activate (fail-closed): la +/// única promoción legítima a Active es SubmitForApproval → Approve. Como control positivo, una +/// delegación sin aprobación requerida sí se activa por esa misma vía. +/// +/// Los dos administradores son usuarios internos BEYONDNET sembrados (Callao, índices 1 y 2). Sus +/// GUID se derivan del GUID del inquilino BEYONDNET igual que en el seeder (byte[0] = índice). +/// +public sealed class DelegationApprovalGateBehavioralTests : IClassFixture +{ + private readonly UmsApiWebApplicationFactory _factory; + + public DelegationApprovalGateBehavioralTests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + } + + private static Guid DeriveBeyondNetUserId(byte index) + { + var bytes = Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId).ToByteArray(); + bytes[0] = index; + return new Guid(bytes); + } + + private HttpClient CreateClientAs(Guid delegatingAdminId) + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-User-Id", delegatingAdminId.ToString()); + client.DefaultRequestHeaders.Add("X-User-Name", "Delegating Admin"); + client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.BeyondNetTenantId); + return client; + } + + private static object BuildCreateBody(Guid delegatingAdminId, Guid delegatedAdminId, bool requiresApproval) => new + { + tenantId = Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId), + delegatingAdminId, + delegatedAdminId, + scopeType = "Tenant", + scopeId = (Guid?)null, + allowedActions = new[] { "CreateUser" }, + validFrom = DateTimeOffset.UtcNow, + validUntil = DateTimeOffset.UtcNow.AddDays(15), + maxDurationDays = (int?)null, + requiresApproval, + }; + + [Fact] + public async Task Activate_WhenRequiresApproval_IsRejected_FailClosed() + { + var ct = TestContext.Current.CancellationToken; + var delegatingAdminId = DeriveBeyondNetUserId(1); // admin.callao@beyondnet.com.pe + var delegatedAdminId = DeriveBeyondNetUserId(2); // agente.aduanas.callao@beyondnet.com.pe + var client = CreateClientAs(delegatingAdminId); + + var createResponse = await client.PostAsJsonAsync( + "/api/v1/delegations", BuildCreateBody(delegatingAdminId, delegatedAdminId, requiresApproval: true), ct); + + var createBody = await createResponse.Content.ReadAsStringAsync(ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created, because: createBody); + + using var created = JsonDocument.Parse(createBody); + var delegationId = created.RootElement.GetProperty("delegationId").GetGuid(); + + var activateResponse = await client.PostAsync($"/api/v1/delegations/{delegationId}/activate", null, ct); + + activateResponse.StatusCode.Should().NotBe(HttpStatusCode.NoContent, + because: "una delegación que exige aprobación no puede activarse por la vía directa (G-056)"); + activateResponse.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.Conflict); + } + + [Fact] + public async Task Activate_WhenNoApprovalRequired_Succeeds() + { + var ct = TestContext.Current.CancellationToken; + var delegatingAdminId = DeriveBeyondNetUserId(1); + var delegatedAdminId = DeriveBeyondNetUserId(2); + var client = CreateClientAs(delegatingAdminId); + + var createResponse = await client.PostAsJsonAsync( + "/api/v1/delegations", BuildCreateBody(delegatingAdminId, delegatedAdminId, requiresApproval: false), ct); + + var createBody = await createResponse.Content.ReadAsStringAsync(ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created, because: createBody); + + using var created = JsonDocument.Parse(createBody); + var delegationId = created.RootElement.GetProperty("delegationId").GetGuid(); + + var activateResponse = await client.PostAsync($"/api/v1/delegations/{delegationId}/activate", null, ct); + + activateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent, + because: "sin aprobación requerida, la activación directa es la vía legítima"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DependencyGuardIntegrationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DependencyGuardIntegrationTests.cs index 89b0de83..3e9032b8 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DependencyGuardIntegrationTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DependencyGuardIntegrationTests.cs @@ -24,6 +24,9 @@ public DependencyGuardIntegrationTests(UmsApiWebApplicationFactory factory) _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000123"); _client.DefaultRequestHeaders.Add("X-User-Name", "Integration Tester"); _client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.InternalAdminTenantId); + // ADR-0071 / FS-26: INTERNAL_ADMIN ya no es management owner; las escrituras acotadas + // exigen contexto internal-admin explícito o devuelven AUTH_015 → 400. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } [Fact] @@ -36,7 +39,10 @@ public async Task SuspendTenant_WithActiveUsers_ShouldReturn409WithBlockingDepen code = tenantCode, name = "Dependency Guard Test Tenant", type = "CLIENT", - isManagementOwner = true, + // G-045/G-037: la propiedad de gestión es única en todo el sistema (BEYONDNET ya la ostenta). + // Un segundo owner devuelve 409. El guard de dependencias se ejerce como internal-admin + // (on-behalf) sobre un tenant cliente ordinario, no marcándolo como owner. + isManagementOwner = false, }, TestContext.Current.CancellationToken); createTenantResponse.StatusCode.Should().Be(HttpStatusCode.Created); @@ -198,7 +204,9 @@ private HttpClient CreateTenantClient(Guid tenantId) tenantClient.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000456"); tenantClient.DefaultRequestHeaders.Add("X-User-Name", "Tenant Dependency Guard Tester"); tenantClient.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); - tenantClient.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "false"); + // El tenant objetivo es un cliente ordinario (no management owner). Para crear/activar + // usuarios y suspenderlo se actúa como operador de gestión (internal-admin on-behalf). + tenantClient.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); return tenantClient; } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/RefreshTokenStoreTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/RefreshTokenStoreTests.cs new file mode 100644 index 00000000..851f8dc0 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/RefreshTokenStoreTests.cs @@ -0,0 +1,400 @@ +namespace Ums.Presentation.IntegrationTest.Identity; + +using Microsoft.EntityFrameworkCore; +using Moq; +using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Persistence.Identity.Entities; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Cobertura de persistencia (SD-04/SD-05) de la revocación/rotación de refresh tokens +/// (ADR-UMS-091 / FR-015/016, G-050). Los tests de handler de la capa Application mockean el +/// ; aquí se ejerce el store real (RefreshTokenStore +/// sobre EF InMemory, igual patrón que PostgreSqlUserAccountRepositoryTests) para probar +/// que la revocación es efectiva en el almacén y no solo en el mock: +/// +/// · logout () marca Revoked todas las +/// familias vivas del usuario ⇒ un token revocado ya no renueva (round-trip +/// store + real); +/// · la revocación de familia (reuso / max-renewals / principal inactivo) invalida la cadena +/// entera y es idempotente; +/// · la rotación deja el token anterior Rotated (detectable como reuso) y crea el nuevo Active +/// con el contador incrementado; +/// · aislamiento estricto multi-inquilino: revocar (usuario, inquilino) no toca otros +/// usuarios ni otros inquilinos (invariante de ADR-UMS-091). +/// +/// El mecanismo opaco sigue apagado en el piloto (fail-closed); estos tests NO lo encienden a +/// nivel de configuración: ejercen directamente la lógica de store/handler, que debe estar +/// completa y probada aunque el flag esté OFF. +/// +public sealed class RefreshTokenStoreTests +{ + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + + // ── Store: emisión + búsqueda por hash ─────────────────────────────────────── + + [Fact] + public async Task IssueAsync_ThenFindByHash_ReturnsActiveSnapshot() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var plaintext = RefreshTokenGenerator.Generate(); + var hash = RefreshTokenHasher.Hash(plaintext); + var now = DateTime.UtcNow; + + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), hash, now, now.AddMinutes(60), ct); + + var found = await store.FindByHashAsync(hash, ct); + + found.Should().NotBeNull(); + found!.Status.Should().Be(RefreshTokenStatuses.Active); + found.TenantId.Should().Be(TenantGuid); + found.UserId.Should().Be(UserGuid); + found.RenewalCount.Should().Be(0); + } + + // ── Store: logout revoca ⇒ el token deja de ser renovable ──────────────────── + + [Fact] + public async Task RevokeAllForUserAsync_MarksLiveTokensRevoked_SoTheyNoLongerRenew() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var hash = RefreshTokenHasher.Hash(RefreshTokenGenerator.Generate()); + var now = DateTime.UtcNow; + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), hash, now, now.AddMinutes(60), ct); + + // Logout: no conoce el familyId, revoca por (inquilino, usuario). + await store.RevokeAllForUserAsync(TenantGuid, UserGuid, "logout", now, ct); + + // El token sigue existiendo pero ya no está Active ⇒ el handler lo rechaza como Revoked. + var found = await store.FindByHashAsync(hash, ct); + found.Should().NotBeNull(); + found!.Status.Should().Be(RefreshTokenStatuses.Revoked); + + var record = await ctx.RefreshTokens.SingleAsync(r => r.TokenHash == hash, ct); + record.RevokedReason.Should().Be("logout"); + record.RevokedAtUtc.Should().NotBeNull(); + } + + [Fact] + public async Task RevokeAllForUserAsync_DoesNotCrossUsersOrTenants() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + var now = DateTime.UtcNow; + + var targetHash = RefreshTokenHasher.Hash("target"); + var otherUserHash = RefreshTokenHasher.Hash("other-user"); + var otherTenantHash = RefreshTokenHasher.Hash("other-tenant"); + var otherUserId = Guid.NewGuid(); + var otherTenantId = Guid.NewGuid(); + + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), targetHash, now, now.AddMinutes(60), ct); + await store.IssueAsync(TenantGuid, otherUserId, Guid.NewGuid(), otherUserHash, now, now.AddMinutes(60), ct); + await store.IssueAsync(otherTenantId, UserGuid, Guid.NewGuid(), otherTenantHash, now, now.AddMinutes(60), ct); + + await store.RevokeAllForUserAsync(TenantGuid, UserGuid, "logout", now, ct); + + // Solo el token del (inquilino, usuario) objetivo se revoca. + (await store.FindByHashAsync(targetHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Revoked); + // El mismo inquilino, otro usuario ⇒ intacto. + (await store.FindByHashAsync(otherUserHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Active); + // El mismo usuario, otro inquilino ⇒ intacto (aislamiento estricto de ADR-UMS-091). + (await store.FindByHashAsync(otherTenantHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Active); + } + + // ── Store: revocación de familia (reuso / max-renewals / principal inactivo) ── + + [Fact] + public async Task RevokeFamilyAsync_RevokesWholeFamily_AndIsIdempotent() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + var now = DateTime.UtcNow; + var familyId = Guid.NewGuid(); + + var firstHash = RefreshTokenHasher.Hash("family-1"); + var secondHash = RefreshTokenHasher.Hash("family-2"); + await store.IssueAsync(TenantGuid, UserGuid, familyId, firstHash, now, now.AddMinutes(60), ct); + await store.IssueAsync(TenantGuid, UserGuid, familyId, secondHash, now, now.AddMinutes(60), ct); + + await store.RevokeFamilyAsync(familyId, "reuse_detected", now, ct); + + (await store.FindByHashAsync(firstHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Revoked); + (await store.FindByHashAsync(secondHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Revoked); + + // Idempotente: repetir no arroja ni cambia nada. + await store.RevokeFamilyAsync(familyId, "reuse_detected", now.AddSeconds(1), ct); + var revokedCount = await ctx.RefreshTokens.CountAsync(r => r.FamilyId == familyId && r.Status == RefreshTokenStatuses.Revoked, ct); + revokedCount.Should().Be(2); + } + + // ── Store: rotación ⇒ el anterior queda reutilizable-detectable, el nuevo Active ─ + + [Fact] + public async Task RotateAsync_MarksOldRotated_AndAddsActiveWithIncrementedRenewalCount() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + var now = DateTime.UtcNow; + var familyId = Guid.NewGuid(); + + var oldHash = RefreshTokenHasher.Hash("old"); + await store.IssueAsync(TenantGuid, UserGuid, familyId, oldHash, now, now.AddMinutes(60), ct); + var current = await store.FindByHashAsync(oldHash, ct); + + var newTokenId = Guid.NewGuid(); + var newHash = RefreshTokenHasher.Hash("new"); + await store.RotateAsync(current!, newTokenId, newHash, now, now.AddMinutes(60), ct); + + // El anterior queda Rotated (presentarlo de nuevo será reuso), apuntando al nuevo. + var oldSnapshot = await store.FindByHashAsync(oldHash, ct); + oldSnapshot!.Status.Should().Be(RefreshTokenStatuses.Rotated); + var oldRecord = await ctx.RefreshTokens.SingleAsync(r => r.TokenHash == oldHash, ct); + oldRecord.ReplacedByTokenId.Should().Be(newTokenId); + + // El nuevo entra Active en la misma familia con el contador incrementado. + var newSnapshot = await store.FindByHashAsync(newHash, ct); + newSnapshot!.Status.Should().Be(RefreshTokenStatuses.Active); + newSnapshot.FamilyId.Should().Be(familyId); + newSnapshot.RenewalCount.Should().Be(1); + } + + // ── Round-trip store + handler real: un token ACTIVO renueva ───────────────── + + [Fact] + public async Task ActiveToken_RenewsThroughRealStore_AndRotationIsPersisted() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var plaintext = RefreshTokenGenerator.Generate(); + var oldHash = RefreshTokenHasher.Hash(plaintext); + var now = DateTime.UtcNow; + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), oldHash, now, now.AddMinutes(60), ct); + + var handler = CreateHandler(store, out var audit); + + var result = await handler.Handle(new RefreshAuthenticationCommand(plaintext, "10.0.0.1"), ct); + + result.IsSuccess.Should().BeTrue(); + result.Value.NewRefreshToken.Should().NotBeNullOrWhiteSpace(); + // La rotación se persistió: el token presentado quedó Rotated en el store real. + (await store.FindByHashAsync(oldHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Rotated); + audit.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Refresh.Success" && e.Succeeded), + It.IsAny()), Times.Once); + } + + // ── Round-trip store + handler real: tras logout, el MISMO token ya no renueva ─ + + [Fact] + public async Task RevokedTokenNoLongerRenews_AfterLogout_EndToEnd() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var plaintext = RefreshTokenGenerator.Generate(); + var hash = RefreshTokenHasher.Hash(plaintext); + var now = DateTime.UtcNow; + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), hash, now, now.AddMinutes(60), ct); + + // Logout real (ADR-UMS-091/FR-016): revoca todas las familias vivas del usuario. + await store.RevokeAllForUserAsync(TenantGuid, UserGuid, "logout", now, ct); + + var handler = CreateHandler(store, out var audit); + + var result = await handler.Handle(new RefreshAuthenticationCommand(plaintext, "10.0.0.1"), ct); + + // Revocación efectiva: el token revocado ya NO renueva, y el fallo se audita. + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain(RefreshErrorCodes.Revoked); + audit.Verify(a => a.RecordAuthEventAsync( + It.Is(e => + e.EventType == "Auth.Refresh.Failure" && !e.Succeeded && + e.FailureReason != null && e.FailureReason.Contains(RefreshErrorCodes.Revoked)), + It.IsAny()), Times.Once); + } + + // ── Round-trip store + handler real: el reuso de un token rotado revoca la familia ─ + + [Fact] + public async Task ReuseOfRotatedToken_RevokesFamilyInRealStore_AndAudits() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var plaintext = RefreshTokenGenerator.Generate(); + var hash = RefreshTokenHasher.Hash(plaintext); + var now = DateTime.UtcNow; + var familyId = Guid.NewGuid(); + await store.IssueAsync(TenantGuid, UserGuid, familyId, hash, now, now.AddMinutes(60), ct); + + var handler = CreateHandler(store, out var audit); + + // Primera renovación: rota ⇒ el token presentado queda Rotated y nace uno nuevo Active. + var first = await handler.Handle(new RefreshAuthenticationCommand(plaintext, "10.0.0.1"), ct); + first.IsSuccess.Should().BeTrue(); + + // Segunda presentación del MISMO (ya rotado) ⇒ reuso ⇒ familia revocada en el store real. + var reuse = await handler.Handle(new RefreshAuthenticationCommand(plaintext, "10.0.0.1"), ct); + + reuse.IsFailure.Should().BeTrue(); + reuse.Error.Should().Contain(RefreshErrorCodes.ReuseDetected); + // Toda la familia (incluido el token nuevo Active) queda Revoked: la sesión se corta. + var live = await ctx.RefreshTokens.CountAsync( + r => r.FamilyId == familyId && r.Status != RefreshTokenStatuses.Revoked, ct); + live.Should().Be(0); + audit.Verify(a => a.RecordAuthEventAsync( + It.Is(e => + e.EventType == "Auth.Refresh.Failure" && !e.Succeeded && + e.FailureReason != null && e.FailureReason.Contains(RefreshErrorCodes.ReuseDetected)), + It.IsAny()), Times.Once); + } + + // ── Infra de test ──────────────────────────────────────────────────────────── + + private static UmsPlatformDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + // Contexto de sistema (OrganizationId null): RefreshTokenRecord no lleva filtro global + // de inquilino — el aislamiento lo impone el store por predicado explícito, y estos + // tests lo verifican. + return new UmsPlatformDbContext( + options, + new SystemTenantContext(), + new Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + /// + /// Handler real cableado con el real; el resto de colaboradores + /// se mockean con un principal (inquilino/usuario) activo y una política habilitada, para + /// aislar el efecto de la revocación en el almacén. + /// + private static RefreshAuthenticationCommandHandler CreateHandler( + IRefreshTokenStore store, out Mock audit) + { + var policyProvider = new Mock(); + policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new RefreshTokenPolicy(Enabled: true, LifetimeMinutes: 60, Rotate: true, DetectReuse: true, MaxRenewals: 0)); + + var tenantRepo = new Mock(); + tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync(BuildActiveTenant()); + + var userRepo = new Mock(); + userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildActiveUser()); + + var methodResolver = new Mock(); + methodResolver.Setup(m => m.ResolveAsync(TenantGuid, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(AuthMethod.Local())); + + var graphBuilder = new Mock(); + graphBuilder.Setup(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(BuildGraph())); + + var formatProvider = new Mock(); + formatProvider.Setup(f => f.GetDefaultFormatAsync(TenantGuid, It.IsAny())) + .ReturnsAsync("JSON"); + + var serializer = new Mock(); + serializer.Setup(s => s.Serialize(It.IsAny(), It.IsAny())) + .Returns("{}"); + + audit = new Mock(); + + return new RefreshAuthenticationCommandHandler( + store, policyProvider.Object, tenantRepo.Object, userRepo.Object, + methodResolver.Object, graphBuilder.Object, formatProvider.Object, + serializer.Object, audit.Object); + } + + private static TenantAggregate BuildActiveTenant() + { + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + OrganizationType.INTERNAL, + ActorId.Create("test"), + IdpStrategy.InternalBcrypt, + tenantId: TenantId.Load(TenantGuid)).Value; // Create ⇒ Status Active + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static UserAccountAggregate BuildActiveUser() + { + var user = UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create("user@test.com"), + UserCategory.Internal, + null, null, + ActorId.Create("test"), + null, + UserAccountId.Load(UserGuid)).Value; + user.Activate(ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static AuthorizationGraph BuildGraph() + { + var context = new GraphContext( + new GraphUser(UserGuid, "user@test.com", "user", "User", "Active"), + new GraphTenant(TenantGuid, "TEST", "Test Tenant", "Active", false), + SystemSuite: null, Role: null, Profile: null, Branch: null); + + var authentication = new GraphAuthentication( + "Local", Provider: null, MfaRequired: false, + IssuedAt: DateTime.UtcNow, SessionExpiresAt: DateTime.UtcNow.AddMinutes(30)); + + var effectiveConfig = new GraphEffectiveConfig( + SessionTimeoutMinutes: 30, MaxLoginAttempts: 5, MinPasswordLength: 8, + MfaRequiredForAdmin: false, MfaAllowedMethods: Array.Empty(), + AccessTokenDurationMs: 900_000, AuthUseExternalIdp: false); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + DateTime.UtcNow); + } + + private sealed class SystemTenantContext : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantParameterSoftDeleteTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantParameterSoftDeleteTests.cs new file mode 100644 index 00000000..d858a19f --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantParameterSoftDeleteTests.cs @@ -0,0 +1,231 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Domain.Identity.Tenant.TenantParameter; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Persistence.Identity.TenantParameter; +using Ums.Presentation.IntegrationTest.Infrastructure; +using TenantParameterAggregate = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; + +namespace Ums.Presentation.IntegrationTest.Identity; + +/// +/// Política del propietario: SOLO existe borrado lógico. El repositorio de parámetros de inquilino +/// hacía dbContext.TenantParameters.Remove(...) y la configuración histórica del inquilino +/// —qué valor regía, quién lo puso y cuándo— se perdía sin remedio. +/// +/// El agregado NO tiene endpoint DELETE (nadie llamaba a DeleteAsync: el borrado físico era +/// código muerto alcanzable solo desde infraestructura), así que la evidencia se toma en el nivel +/// donde el borrado ocurre de verdad —repositorio sobre PostgreSQL real— en vez de simularla por HTTP: +/// +/// a. DeleteAsync responde igual que antes (sin excepción, sin valor de retorno nuevo). +/// b. La FILA SIGUE EN LA BASE, con todos sus datos y con IsDeleted = true. +/// c. Las lecturas la ocultan: el repositorio devuelve null / listas sin ella. +/// d. Con el vínculo VIVO (parámetro activo) el dominio rechaza con el código de operación +/// bloqueada que la presentación traduce a 409. +/// e. Con ese vínculo ya eliminado lógicamente (parámetro desactivado), el borrado sí procede. +/// +[Collection("PostgreSql")] +public sealed class TenantParameterSoftDeleteTests +{ + private readonly PostgreSqlContainerFixture _fixture; + private static readonly ActorId Actor = ActorId.Create("integration-test"); + + public TenantParameterSoftDeleteTests(PostgreSqlContainerFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Delete_ConVinculoActivo_EsRechazadoPorElDominio_YNoTocaLaBase() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = Guid.NewGuid(); + var parametro = NuevoParametro(tenantId, "AUTH_GRAPH_DEFAULT_FORMAT", "JSON"); + + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(parametro, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + // (d) Referencia viva: el parámetro está ACTIVO, o sea que la configuración del inquilino lo + // resuelve ahora mismo por su código. El borrado se rechaza con el código de bloqueo. + var resultado = parametro.Delete(Actor); + + resultado.IsFailure.Should().BeTrue(); + resultado.Error.Should().Contain(DomainErrors.TenantParameter.HasActiveBinding); + parametro.IsDeleted.Should().BeFalse(); + + await using var verificacion = CrearContexto(); + var fila = await verificacion.TenantParameters + .IgnoreQueryFilters() + .SingleAsync(x => x.Id == parametro.GetId().GetValue(), ct); + fila.IsDeleted.Should().BeFalse("un borrado rechazado no puede haber escrito nada"); + } + + [Fact] + public async Task Delete_TrasDesactivar_DejaLaFilaEnLaBase_YLaOcultaDeLasLecturas() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = Guid.NewGuid(); + var parametro = NuevoParametro(tenantId, "EXPORT_PROFILE_PERMISSION_GRAPH_DEFAULT_FORMAT", "XML"); + var parametroId = parametro.GetId().GetValue(); + + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(parametro, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + // (e) Se desactiva primero —esa desactivación ES la eliminación lógica del vínculo— y entonces + // el borrado procede. + parametro.Deactivate(Actor).IsSuccess.Should().BeTrue(); + parametro.Delete(Actor).IsSuccess.Should().BeTrue(); + + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + // (a) Misma firma y mismo comportamiento observable que antes: no lanza y no devuelve nada. + await repo.DeleteAsync(parametro, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + // (b) LA FILA SIGUE EN LA BASE, con su valor y su auditoría intactos. Es la prueba que fija la + // política: hay que saltarse el filtro global para verla, pero está. + await using var verificacion = CrearContexto(); + var fila = await verificacion.TenantParameters + .IgnoreQueryFilters() + .SingleOrDefaultAsync(x => x.Id == parametroId, ct); + + fila.Should().NotBeNull("el borrado es LÓGICO: la fila no puede desaparecer de la tabla"); + fila!.IsDeleted.Should().BeTrue(); + fila.Value.Should().Be("XML", "el histórico conserva el valor que regía cuando se eliminó"); + fila.Code.Should().Be("EXPORT_PROFILE_PERMISSION_GRAPH_DEFAULT_FORMAT"); + + // (c) Las lecturas la ocultan, tanto por el filtro global como por el repositorio. + (await verificacion.TenantParameters.SingleOrDefaultAsync(x => x.Id == parametroId, ct)) + .Should().BeNull("el filtro global de borrado lógico saca la fila de toda consulta ordinaria"); + + var lectura = new PostgreSqlTenantParameterRepository(verificacion); + (await lectura.GetByIdAsync(parametroId, ct)).Should().BeNull(); + (await lectura.GetByCodeAsync(tenantId, "EXPORT_PROFILE_PERMISSION_GRAPH_DEFAULT_FORMAT", ct)).Should().BeNull(); + (await lectura.GetByTenantIdAsync(tenantId, ct)).Should().BeEmpty(); + (await lectura.ExistsActiveCodeAsync(tenantId, "EXPORT_PROFILE_PERMISSION_GRAPH_DEFAULT_FORMAT", ct)) + .Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_SinPasarPorElDominio_SeCorta() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // La DECISIÓN de eliminar es del dominio, que es donde vive la guardia de cascada. Si alguien + // llama al repositorio saltándosela, se corta en vez de escribir un borrado que nadie validó: + // así el borrado físico no puede reaparecer por la puerta de atrás. + var parametro = NuevoParametro(Guid.NewGuid(), "SESSION_IDLE_TIMEOUT_MINUTES", "30"); + + await using var db = CrearContexto(); + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(parametro, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + + var act = async () => await repo.DeleteAsync(parametro, ct); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Delete_PermiteVolverAAltaElMismoCodigo() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // El índice único parcial IX_TenantParameters_TenantId_Code_IsActive solo cubre filas ACTIVAS. + // Como el borrado lógico exige desactivación previa, la fila eliminada queda fuera del índice y + // el inquilino puede volver a dar de alta ese código sin chocar con 23505. + var tenantId = Guid.NewGuid(); + const string codigo = "AUTH_GRAPH_ALLOWED_FORMATS"; + + var original = NuevoParametro(tenantId, codigo, "JSON,XML"); + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(original, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + original.Deactivate(Actor); + original.Delete(Actor); + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.DeleteAsync(original, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + var reemplazo = NuevoParametro(tenantId, codigo, "JSON,XML,YAML"); + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(reemplazo, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + await using var verificacion = CrearContexto(); + var filas = await verificacion.TenantParameters + .IgnoreQueryFilters() + .Where(x => x.TenantId == tenantId && x.Code == codigo) + .ToListAsync(ct); + + filas.Should().HaveCount(2, "la eliminada permanece como histórico junto a la nueva"); + filas.Count(x => x.IsDeleted).Should().Be(1); + filas.Single(x => !x.IsDeleted).Value.Should().Be("JSON,XML,YAML"); + } + + // ── Utilidades ────────────────────────────────────────────────────────── + + private static TenantParameterAggregate NuevoParametro(Guid tenantId, string codigo, string valor) + => TenantParameterAggregate.Create( + TenantId.Load(tenantId), + codigo, + "Parámetro de inquilino para la prueba de borrado lógico.", + valor, + TenantParameterValueType.String, + TenantParameterCategory.Export, + isSensitive: false, + defaultValue: null, + allowedValues: null, + Actor).Value; + + /// + /// Contexto EF contra el contenedor real, con inquilino nulo: el filtro de aislamiento no recorta y + /// el ÚNICO predicado que queda sobre TenantParameters es el de borrado lógico, que es lo que se + /// quiere observar. + /// + private UmsPlatformDbContext CrearContexto() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_fixture.ConnectionString) + .Options; + + return new UmsPlatformDbContext( + options, + new ContextoDeInquilinoDelSistema(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private sealed class ContextoDeInquilinoDelSistema : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/UserAccountRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/UserAccountRestEndpointTests.cs index 9995e5f6..69451cde 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/UserAccountRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/UserAccountRestEndpointTests.cs @@ -17,6 +17,11 @@ public UserAccountRestEndpointTests(UmsApiWebApplicationFactory factory) _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000123"); _client.DefaultRequestHeaders.Add("X-User-Name", "Integration Tester"); _client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.InternalAdminTenantId); + // ADR-0071 / FS-26: tras mover la propiedad de gestión al Tenant Raíz (BEYONDNET), el tenant + // sintético INTERNAL_ADMIN ya NO es management owner (isManagementOwner:false). Las escrituras + // acotadas por EnsureManagementOwnerScopeAsync exigen contexto internal-admin explícito; sin él + // el comando devuelve AUTH_015 → 400. Se declara el rol de operador de gestión de forma explícita. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } [Fact] diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionEffectE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionEffectE2ETests.cs new file mode 100644 index 00000000..86abb017 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionEffectE2ETests.cs @@ -0,0 +1,218 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using Ums.Presentation.IntegrationTest.Infrastructure; +using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; +using RoleAggregate = Ums.Domain.Authorization.Role.Role; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; + +namespace Ums.Presentation.IntegrationTest.Iga; + +/// +/// ADR-UMS-096 (Brecha 2 / G-093) endurecido por G-094: E2E del EFECTO real de la promoción de rol IGA +/// sobre un contenedor PostgreSQL real (Testcontainers). El efecto ya NO se aplica por un manejador +/// in-process best-effort, sino por el Transactional Outbox de MassTransit: +/// ExecuteRolePromotionCommandHandler publica +/// dentro de la +/// transacción que confirma Execute, y RolePromotionRoleAssignmentConsumer reasigna el +/// rol del Profile del usuario objetivo en su propia transacción (D-016). +/// +/// En este host el bus es en memoria (sin outbox EF): el publish entrega el mensaje al consumidor en +/// un task de fondo, de modo que la reasignación es ASÍNCRONA respecto a la respuesta HTTP de +/// execute. Por eso la aserción del efecto relee el perfil con reintentos deterministas (no +/// sleeps arbitrarios) hasta observar el cambio. +/// +/// El test conduce el happy-path completo (Create → Submit → ConfirmEligibility → ManagerApprove → +/// [SecurityApprove] → Execute) con actores DISTINTOS por transición (segregación de funciones, +/// INV-RPR3 endurecida por ADR-UMS-096) y, tras Execute, asevera que Profile.RoleId del usuario +/// objetivo pasó del rol origen al rol destino. +/// +[Collection("PostgreSql")] +public sealed class RolePromotionEffectE2ETests +{ + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + private static readonly Guid SeedTenantId = Guid.NewGuid(); + private static readonly Guid SeedTargetUserId = Guid.NewGuid(); + + // Actores distintos entre sí y del objetivo, para respetar la SoD a lo largo del flujo. + private static readonly Guid RequesterActor = Guid.NewGuid(); + private static readonly Guid ApproverActor = Guid.NewGuid(); + private static readonly Guid SecurityActor = Guid.NewGuid(); + private static readonly Guid ExecutorActor = Guid.NewGuid(); + + public RolePromotionEffectE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + [Fact] + public async Task FullPromotionFlow_UntilExecute_ReassignsTargetProfileRole() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + + // Fuerza la construcción del host (que resetea la BD y siembra la línea base) ANTES de sembrar + // nuestros propios agregados, para que el TRUNCATE de arranque no borre lo que sembramos. + using var warmup = _factory.CreateClient(); + + var (currentRoleId, targetRoleId, profileId) = await SeedRolesProfileAndEligibilityAsync(ct); + + var requester = CreateActorClient(RequesterActor); + var approver = CreateActorClient(ApproverActor); + var security = CreateActorClient(SecurityActor); + var executor = CreateActorClient(ExecutorActor); + + // 1. Create (Draft) — el solicitante no es el objetivo. + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new + { + TenantId = SeedTenantId, + TargetUserId = SeedTargetUserId, + CurrentRoleId = currentRoleId, + TargetRoleId = targetRoleId, + }, + ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(ct); + created.Should().NotBeNull(); + var baseUrl = $"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}"; + + // 2. Submit → PendingEligibilityCheck (congela el RiskScore; el cálculo lee los roles sembrados). + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + + // 3. ConfirmEligibility → PendingManagerApproval (elegible por el RoleMaturityStatus sembrado). + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, ct)).Status.Should().Be("PendingManagerApproval"); + + // 4. ManagerApprove → Approved o PendingSecurityReview según el RiskScore. + (await approver.PostAsync($"{baseUrl}/manager-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterManager = await GetAsync(approver, baseUrl, ct); + afterManager.Status.Should().BeOneOf("Approved", "PendingSecurityReview"); + + // 4b. Si escaló a revisión de seguridad, un revisor distinto la aprueba. + if (afterManager.Status == "PendingSecurityReview") + { + (await security.PostAsync($"{baseUrl}/security-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + (await GetAsync(security, baseUrl, ct)).Status.Should().Be("Approved"); + + // 5. Execute → Executed. El efecto (G-094) se publica al outbox y lo aplica el consumidor. + (await executor.PostAsync($"{baseUrl}/execute", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, ct)).Status.Should().Be("Executed"); + + // 6. Aserción del EFECTO: el Profile del usuario objetivo quedó reasignado al rol destino. + // La reasignación llega por el bus (RolePromotionRoleAssignmentConsumer) de forma asíncrona, + // así que se relee con reintentos deterministas —creando un scope/DbContext nuevo por intento + // para no leer del mapa de identidad— hasta observar el rol destino. No hay sleeps ciegos: + // la espera termina en cuanto el efecto es visible o se agotan los intentos. + ProfileAggregate? profile = null; + for (var attempt = 0; attempt < 50; attempt++) + { + using var scope = _factory.Services.CreateScope(); + var profileRepository = scope.ServiceProvider.GetRequiredService(); + profile = await profileRepository.GetByIdAsync(profileId, ct); + + if (profile is not null && profile.RoleId.GetValue() == targetRoleId) + { + break; + } + + await Task.Delay(100, ct); + } + + profile.Should().NotBeNull(); + profile!.RoleId.GetValue().Should().Be(targetRoleId, "la promoción ejecutada debe reasignar el rol del perfil objetivo (ADR-UMS-096 / G-094, vía outbox + consumidor)"); + profile.IsActive.Should().BeTrue(); + } + + /// + /// Siembra, sobre el contenedor real: un SystemSuite y dos Role raíz (origen/destino) + /// —que el cálculo de RiskScore necesita resolver por id—; un RoleMaturityStatus ELEGIBLE + /// para (inquilino, objetivo, rol origen) —Junior, ingreso > 2 años, desempeño 4.5, sin + /// incidencias— para que la confirmación de elegibilidad avance; y un Profile ACTIVO del + /// usuario objetivo con el rol origen, que es el que la promoción debe reasignar. Devuelve los ids + /// de rol origen/destino y del perfil. + /// + private async Task<(Guid CurrentRoleId, Guid TargetRoleId, Guid ProfileId)> SeedRolesProfileAndEligibilityAsync(CancellationToken ct) + { + using var scope = _factory!.Services.CreateScope(); + var actor = ActorId.Create(RequesterActor.ToString()); + var tenantId = TenantId.Load(SeedTenantId); + var targetUser = UserId.Load(SeedTargetUserId); + var suffix = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); + + // SystemSuite (contenedor de los roles; el RoleRecord tiene FK a SystemSuiteRecord). + var suiteRepository = scope.ServiceProvider.GetRequiredService(); + var suite = SystemSuiteAggregate.Create( + tenantId, + Code.Create($"SS{suffix}"), + Name.Create($"IGA Effect Suite {suffix}"), + Description.Create("Suite sembrada por RolePromotionEffectE2ETests (ADR-UMS-096)."), + actor).Value; + await suiteRepository.AddAsync(suite, ct); + await suiteRepository.UnitOfWork.SaveEntitiesAsync(ct); + + // Dos roles raíz: origen (orden 0) y destino (orden 1). RiskScore resultante < umbral ⇒ Approved. + var roleRepository = scope.ServiceProvider.GetRequiredService(); + var currentRole = RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLCUR{suffix}"), Name.Create($"Current {suffix}"), + Description.Create("Rol origen (ADR-UMS-096)."), parentRoleId: null, hierarchyLevel: 0, promotionOrder: 0, actor).Value; + var targetRole = RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLTGT{suffix}"), Name.Create($"Target {suffix}"), + Description.Create("Rol destino (ADR-UMS-096)."), parentRoleId: null, hierarchyLevel: 0, promotionOrder: 1, actor).Value; + await roleRepository.AddAsync(currentRole, ct); + await roleRepository.AddAsync(targetRole, ct); + await roleRepository.UnitOfWork.SaveEntitiesAsync(ct); + + var currentRoleId = currentRole.GetId(); + + // RoleMaturityStatus ELEGIBLE del objetivo en el rol origen. + var maturityRepository = scope.ServiceProvider.GetRequiredService(); + var eligible = RoleMaturityStatusAggregate.Create( + tenantId, targetUser, currentRoleId, RoleMaturityLevel.Junior, DateTime.UtcNow.AddYears(-2), actor).Value; + eligible.UpdatePerformanceScore(4.5m, actor); + await maturityRepository.AddAsync(eligible, ct); + await maturityRepository.UnitOfWork.SaveEntitiesAsync(ct); + + // Profile ACTIVO del objetivo con el rol origen: el sujeto del efecto. + var profileRepository = scope.ServiceProvider.GetRequiredService(); + var profile = ProfileAggregate.Create(tenantId, targetUser, currentRoleId, branchId: null, actor).Value; + await profileRepository.AddAsync(profile, ct); + await profileRepository.UnitOfWork.SaveEntitiesAsync(ct); + + return (currentRole.GetId().GetValue(), targetRole.GetId().GetValue(), profile.GetId().GetValue()); + } + + private HttpClient CreateActorClient(Guid actorId) + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeedTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-Actor-Id", actorId.ToString()); + return client; + } + + private static async Task GetAsync(HttpClient client, string baseUrl, CancellationToken ct) + { + var dto = await client.GetFromJsonAsync(baseUrl, ct); + dto.Should().NotBeNull(); + return dto!; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionLifecycleE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionLifecycleE2ETests.cs new file mode 100644 index 00000000..d2c8beba --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionLifecycleE2ETests.cs @@ -0,0 +1,489 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using Ums.Presentation.IntegrationTest.Infrastructure; +using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; +using RoleAggregate = Ums.Domain.Authorization.Role.Role; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; +using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; + +namespace Ums.Presentation.IntegrationTest.Iga; + +/// +/// G-087 (ADR-UMS-093 / ADR-UMS-096, D-014 / D-020): E2E de integración del ciclo de vida completo +/// de la promoción de rol IGA sobre un contenedor PostgreSQL real (Testcontainers), complementando el +/// —que sólo llega a Execute y verifica el efecto— y las +/// pruebas InMemory de RolePromotionRequestRestEndpointTests. Aquí se ejercitan, de forma +/// DETERMINISTA (los datos de siembra fuerzan cada rama, sin BeOneOf): +/// +/// 1. Ciclo feliz de BAJO riesgo de punta a punta —Create → Submit → ConfirmEligibility → +/// ManagerApprove(→Approved) → Execute → Verify— aseverando cada transición, que el RiskScore +/// congelado queda por debajo del umbral, y el EFECTO real (reasignación del rol del perfil objetivo). +/// 2. Ruta de ALTO riesgo (RiskScore ≥ umbral) que enruta a PendingSecurityReview y sólo tras la +/// aprobación de seguridad llega a Approved y Executed. +/// 3. Rechazo fail-closed de elegibilidad (sin RoleMaturityStatus sembrado) y verificación +/// de que la promoción no puede avanzar tras el corte (INV-RPR4). +/// 4. Violación de segregación de funciones (ADR-UMS-096): el aprobador NO puede ejecutar su propia decisión +/// (ejecutor ≠ aprobador); tras el rechazo, un ejecutor distinto sí puede. +/// 5. Consulta de RoleMaturityStatus por usuario, aislada por inquilino (la madurez no cruza +/// fronteras de inquilino). +/// +/// Cada transición la ejecuta un actor DISTINTO mediante el encabezado X-Test-Actor-Id que honra el +/// del host PostgreSQL, respetando la segregación de funciones (INV-RPR3). +/// El bus es en memoria (sin outbox EF): el efecto de Execute se aplica de forma ASÍNCRONA, por lo +/// que se relee el perfil con reintentos deterministas (no sleep ciegos). +/// +[Collection("PostgreSql")] +public sealed class RolePromotionLifecycleE2ETests +{ + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + public RolePromotionLifecycleE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + /// + /// (1) Ciclo feliz completo de BAJO riesgo: con ambos roles raíz (nivel 0) el RiskScore resultante + /// queda < umbral, de modo que ManagerApprove va directo a Approved (sin revisión de + /// seguridad). La solicitud atraviesa toda la máquina hasta Verified y el perfil del objetivo + /// queda reasignado al rol destino (efecto de ADR-UMS-096 / G-094 vía outbox + consumidor). + /// + [Fact] + public async Task FullLowRiskLifecycle_ReachesVerified_AndReassignsTargetProfileRole() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var requesterActor = Guid.NewGuid(); + var approverActor = Guid.NewGuid(); + var executorActor = Guid.NewGuid(); + var verifierActor = Guid.NewGuid(); + + var (currentRoleId, targetRoleId, profileId) = await SeedScenarioAsync( + tenantId, targetUserId, highRisk: false, seedEligibleMaturity: true, ct); + + var requester = CreateActorClient(tenantId, requesterActor); + var approver = CreateActorClient(tenantId, approverActor); + var executor = CreateActorClient(tenantId, executorActor); + var verifier = CreateActorClient(tenantId, verifierActor); + + var baseUrl = await CreateAsync(requester, tenantId, targetUserId, currentRoleId, targetRoleId, ct); + + // Draft → PendingEligibilityCheck (congela el RiskScore). + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterSubmit = await GetAsync(requester, baseUrl, ct); + afterSubmit.Status.Should().Be("PendingEligibilityCheck"); + afterSubmit.RiskScore.Should().NotBeNull(); + afterSubmit.RiskScore!.Value.Should().BeLessThan( + RolePromotionRequestAggregate.DefaultHighRiskThreshold, + "los roles raíz de nivel 0 producen un RiskScore de bajo riesgo (< umbral)"); + + // PendingEligibilityCheck → PendingManagerApproval (elegible por el RoleMaturityStatus sembrado). + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, ct)).Status.Should().Be("PendingManagerApproval"); + + // PendingManagerApproval → Approved DIRECTO (bajo riesgo ⇒ sin revisión de seguridad). + (await approver.PostAsync($"{baseUrl}/manager-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterManager = await GetAsync(approver, baseUrl, ct); + afterManager.Status.Should().Be("Approved", "el bajo riesgo no debe enrutar a revisión de seguridad"); + afterManager.SecurityReviewerId.Should().BeNull(); + + // Approved → Executed (ejecutor distinto del objetivo y del aprobador). + (await executor.PostAsync($"{baseUrl}/execute", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, ct)).Status.Should().Be("Executed"); + + // Executed → Verified (verificador distinto del ejecutor y del objetivo). + (await verifier.PostAsync($"{baseUrl}/verify", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var verified = await GetAsync(verifier, baseUrl, ct); + verified.Status.Should().Be("Verified"); + verified.ApproverId.Should().NotBeNull(); + verified.ExecutorId.Should().NotBeNull(); + verified.VerifierId.Should().NotBeNull(); + + // Efecto: el rol del perfil objetivo quedó reasignado al rol destino (reentrega asíncrona por el bus). + var profile = await WaitForProfileRoleAsync(profileId, targetRoleId, ct); + profile.Should().NotBeNull(); + profile!.RoleId.GetValue().Should().Be( + targetRoleId, + "la promoción ejecutada debe reasignar el rol del perfil objetivo (ADR-UMS-096 / G-094)"); + profile.IsActive.Should().BeTrue(); + } + + /// + /// (2) Ruta de ALTO riesgo: el rol destino es un rol hijo de nivel jerárquico 4, de modo que el + /// RiskScore congelado alcanza/supera el umbral y ManagerApprove enruta a + /// PendingSecurityReview. Sólo tras la aprobación de un revisor de seguridad distinto llega a + /// Approved, y un ejecutor distinto la ejecuta. + /// + [Fact] + public async Task HighRiskLifecycle_RoutesToSecurityReview_ThenApprovesAndExecutes() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var requesterActor = Guid.NewGuid(); + var approverActor = Guid.NewGuid(); + var securityActor = Guid.NewGuid(); + var executorActor = Guid.NewGuid(); + + var (currentRoleId, targetRoleId, _) = await SeedScenarioAsync( + tenantId, targetUserId, highRisk: true, seedEligibleMaturity: true, ct); + + var requester = CreateActorClient(tenantId, requesterActor); + var approver = CreateActorClient(tenantId, approverActor); + var security = CreateActorClient(tenantId, securityActor); + var executor = CreateActorClient(tenantId, executorActor); + + var baseUrl = await CreateAsync(requester, tenantId, targetUserId, currentRoleId, targetRoleId, ct); + + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterSubmit = await GetAsync(requester, baseUrl, ct); + afterSubmit.RiskScore.Should().NotBeNull(); + afterSubmit.RiskScore!.Value.Should().BeGreaterThanOrEqualTo( + RolePromotionRequestAggregate.DefaultHighRiskThreshold, + "una escalación jerárquica de 4 niveles sobre un rol destino sensible produce alto riesgo (≥ umbral)"); + + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, ct)).Status.Should().Be("PendingManagerApproval"); + + // PendingManagerApproval → PendingSecurityReview (DETERMINISTA por el alto RiskScore). + (await approver.PostAsync($"{baseUrl}/manager-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(approver, baseUrl, ct)).Status.Should().Be( + "PendingSecurityReview", "el alto riesgo debe enrutar a revisión de seguridad"); + + // PendingSecurityReview → Approved (revisor distinto del objetivo y del aprobador). + (await security.PostAsync($"{baseUrl}/security-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterSecurity = await GetAsync(security, baseUrl, ct); + afterSecurity.Status.Should().Be("Approved"); + afterSecurity.SecurityReviewerId.Should().NotBeNull("la ruta de alto riesgo registra al revisor de seguridad"); + + // Approved → Executed (ejecutor distinto del objetivo, del aprobador y del revisor). + (await executor.PostAsync($"{baseUrl}/execute", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, ct)).Status.Should().Be("Executed"); + } + + /// + /// (3) Rechazo fail-closed de elegibilidad: sin un RoleMaturityStatus sembrado, la + /// confirmación traduce la ausencia a «no elegible» y la solicitud pasa a Rejected (INV-RPR4). + /// Se verifica además que NO puede avanzar tras el corte: un ManagerApprove posterior falla con + /// transición de estado inválida. + /// + [Fact] + public async Task ConfirmEligibility_WithoutSeededMaturity_RejectsFailClosed_AndHalts() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var requesterActor = Guid.NewGuid(); + var approverActor = Guid.NewGuid(); + + // Roles y perfil, pero SIN estado de madurez ⇒ la confirmación de elegibilidad rechaza (fail-closed). + var (currentRoleId, targetRoleId, _) = await SeedScenarioAsync( + tenantId, targetUserId, highRisk: false, seedEligibleMaturity: false, ct); + + var requester = CreateActorClient(tenantId, requesterActor); + var approver = CreateActorClient(tenantId, approverActor); + + var baseUrl = await CreateAsync(requester, tenantId, targetUserId, currentRoleId, targetRoleId, ct); + + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + + var rejected = await GetAsync(requester, baseUrl, ct); + rejected.Status.Should().Be("Rejected", "sin madurez sembrada la elegibilidad debe rechazar (fail-closed)"); + rejected.DecisionReason.Should().NotBeNullOrWhiteSpace(); + + // La promoción rechazada no puede avanzar: aprobar un estado terminal falla (INV-RPR1). + var approveAttempt = await approver.PostAsync($"{baseUrl}/manager-approve", null, ct); + approveAttempt.StatusCode.Should().Be(HttpStatusCode.BadRequest, "una solicitud rechazada no puede aprobarse"); + (await GetAsync(requester, baseUrl, ct)).Status.Should().Be("Rejected", "el rechazo es terminal"); + } + + /// + /// (4) Segregación de funciones endurecida (ADR-UMS-096, INV-RPR3): quien AUTORIZA no puede EJECUTAR su + /// propia decisión. Se conduce la solicitud hasta Approved y el APROBADOR intenta ejecutarla: + /// debe rechazarse (400) y el estado permanecer Approved. Un ejecutor distinto sí puede ejecutar, + /// lo que confirma que el corte es la SoD y no un flujo roto. + /// + [Fact] + public async Task Execute_WhenExecutorIsApprover_RejectsSegregationOfDuties() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var requesterActor = Guid.NewGuid(); + var approverActor = Guid.NewGuid(); + var executorActor = Guid.NewGuid(); + + var (currentRoleId, targetRoleId, _) = await SeedScenarioAsync( + tenantId, targetUserId, highRisk: false, seedEligibleMaturity: true, ct); + + var requester = CreateActorClient(tenantId, requesterActor); + var approver = CreateActorClient(tenantId, approverActor); + var executor = CreateActorClient(tenantId, executorActor); + + var baseUrl = await CreateAsync(requester, tenantId, targetUserId, currentRoleId, targetRoleId, ct); + + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await approver.PostAsync($"{baseUrl}/manager-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(approver, baseUrl, ct)).Status.Should().Be("Approved"); + + // El aprobador intenta ejecutar su propia decisión ⇒ violación de SoD (ejecutor ≠ aprobador). + var sodViolation = await approver.PostAsync($"{baseUrl}/execute", null, ct); + sodViolation.StatusCode.Should().Be(HttpStatusCode.BadRequest, "el aprobador no puede ejecutar su propia promoción (ADR-UMS-096)"); + (await GetAsync(approver, baseUrl, ct)).Status.Should().Be("Approved", "la ejecución rechazada por SoD no cambia el estado"); + + // Un ejecutor distinto sí puede ejecutar: el corte era la SoD, no un flujo roto. + (await executor.PostAsync($"{baseUrl}/execute", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, ct)).Status.Should().Be("Executed"); + } + + /// + /// (5) Consulta de RoleMaturityStatus por usuario, aislada por inquilino: se siembra un estado + /// para el MISMO usuario en dos inquilinos distintos. La consulta acotada a un inquilino devuelve + /// exclusivamente su estado (nunca el del otro inquilino), verificando que la madurez no cruza + /// fronteras de inquilino (FR-062, filtro global + parámetro tenantId). + /// + [Fact] + public async Task GetRoleMaturityStatusByUser_IsScopedByTenant() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleInA = Guid.NewGuid(); + var roleInB = Guid.NewGuid(); + + await SeedMaturityAsync(tenantA, userId, roleInA, RoleMaturityLevel.Junior, 4.5m, ct); + await SeedMaturityAsync(tenantB, userId, roleInB, RoleMaturityLevel.Senior, 3.2m, ct); + + // Inquilino A: sólo ve su propio estado (Junior en roleInA), nunca el de B. + var clientA = CreateActorClient(tenantA, Guid.NewGuid()); + var listA = await clientA.GetFromJsonAsync>( + $"/api/v1/role-maturity-status/users/{userId}?tenantId={tenantA}", ct); + listA.Should().NotBeNull(); + listA!.Should().ContainSingle(); + listA.Should().NotContain(s => s.TenantId == tenantB, "la madurez del inquilino B no puede filtrarse al inquilino A"); + var statusA = listA!.Single(); + statusA.TenantId.Should().Be(tenantA); + statusA.RoleId.Should().Be(roleInA); + statusA.CurrentMaturityLevel.Should().Be("Junior"); + + // Inquilino B: simétricamente, sólo ve su propio estado (Senior en roleInB). + var clientB = CreateActorClient(tenantB, Guid.NewGuid()); + var listB = await clientB.GetFromJsonAsync>( + $"/api/v1/role-maturity-status/users/{userId}?tenantId={tenantB}", ct); + listB.Should().NotBeNull(); + listB!.Should().ContainSingle(); + listB.Should().NotContain(s => s.TenantId == tenantA, "la madurez del inquilino A no puede filtrarse al inquilino B"); + var statusB = listB!.Single(); + statusB.TenantId.Should().Be(tenantB); + statusB.RoleId.Should().Be(roleInB); + statusB.CurrentMaturityLevel.Should().Be("Senior"); + } + + // ── Siembra ─────────────────────────────────────────────────────────────── + + /// + /// Siembra, sobre el contenedor real: un SystemSuite y dos Role (origen/destino) que el + /// cálculo de RiskScore resuelve por id; opcionalmente un RoleMaturityStatus ELEGIBLE del objetivo + /// en el rol origen; y un Profile ACTIVO del objetivo con el rol origen (sujeto del efecto). + /// Con el rol destino es un rol hijo de nivel jerárquico 4 (escalación que + /// lleva el RiskScore ≥ umbral); en caso contrario ambos roles son raíz (nivel 0) ⇒ bajo riesgo. + /// + private async Task<(Guid CurrentRoleId, Guid TargetRoleId, Guid ProfileId)> SeedScenarioAsync( + Guid tenantIdValue, + Guid targetUserIdValue, + bool highRisk, + bool seedEligibleMaturity, + CancellationToken ct) + { + using var scope = _factory!.Services.CreateScope(); + var actor = ActorId.Create(Guid.NewGuid().ToString()); + var tenantId = TenantId.Load(tenantIdValue); + var targetUser = UserId.Load(targetUserIdValue); + var suffix = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); + + var suiteRepository = scope.ServiceProvider.GetRequiredService(); + var suite = SystemSuiteAggregate.Create( + tenantId, + Code.Create($"SS{suffix}"), + Name.Create($"IGA Lifecycle Suite {suffix}"), + Description.Create("Suite sembrada por RolePromotionLifecycleE2ETests (G-087)."), + actor).Value; + await suiteRepository.AddAsync(suite, ct); + await suiteRepository.UnitOfWork.SaveEntitiesAsync(ct); + + var roleRepository = scope.ServiceProvider.GetRequiredService(); + + // Rol origen: raíz (nivel 0, orden 0). + var currentRole = RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLCUR{suffix}"), Name.Create($"Current {suffix}"), + Description.Create("Rol origen (G-087)."), parentRoleId: null, hierarchyLevel: 0, promotionOrder: 0, actor).Value; + + // Rol destino: raíz nivel 0 (bajo riesgo) o hijo de nivel 4 (alto riesgo). Un rol raíz DEBE tener + // nivel 0 y un rol hijo un nivel distinto de 0 (ValidateHierarchy en el dominio de Autorización). + var targetRole = highRisk + ? RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLTGT{suffix}"), Name.Create($"Target {suffix}"), + Description.Create("Rol destino de alto riesgo (G-087)."), + parentRoleId: currentRole.GetId(), hierarchyLevel: 4, promotionOrder: 1, actor).Value + : RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLTGT{suffix}"), Name.Create($"Target {suffix}"), + Description.Create("Rol destino de bajo riesgo (G-087)."), + parentRoleId: null, hierarchyLevel: 0, promotionOrder: 1, actor).Value; + + await roleRepository.AddAsync(currentRole, ct); + await roleRepository.AddAsync(targetRole, ct); + await roleRepository.UnitOfWork.SaveEntitiesAsync(ct); + + var currentRoleId = currentRole.GetId(); + + if (seedEligibleMaturity) + { + var maturityRepository = scope.ServiceProvider.GetRequiredService(); + var eligible = RoleMaturityStatusAggregate.Create( + tenantId, targetUser, currentRoleId, RoleMaturityLevel.Junior, DateTime.UtcNow.AddYears(-2), actor).Value; + eligible.UpdatePerformanceScore(4.5m, actor); + await maturityRepository.AddAsync(eligible, ct); + await maturityRepository.UnitOfWork.SaveEntitiesAsync(ct); + } + + var profileRepository = scope.ServiceProvider.GetRequiredService(); + var profile = ProfileAggregate.Create(tenantId, targetUser, currentRoleId, branchId: null, actor).Value; + await profileRepository.AddAsync(profile, ct); + await profileRepository.UnitOfWork.SaveEntitiesAsync(ct); + + return (currentRole.GetId().GetValue(), targetRole.GetId().GetValue(), profile.GetId().GetValue()); + } + + /// Siembra un RoleMaturityStatus aislado (sin roles/perfil) para la consulta por inquilino. + private async Task SeedMaturityAsync( + Guid tenantIdValue, + Guid userIdValue, + Guid roleIdValue, + RoleMaturityLevel level, + decimal performanceScore, + CancellationToken ct) + { + using var scope = _factory!.Services.CreateScope(); + var actor = ActorId.Create(Guid.NewGuid().ToString()); + var maturityRepository = scope.ServiceProvider.GetRequiredService(); + + var status = RoleMaturityStatusAggregate.Create( + TenantId.Load(tenantIdValue), + UserId.Load(userIdValue), + RoleId.Load(roleIdValue), + level, + DateTime.UtcNow.AddYears(-1), + actor).Value; + status.UpdatePerformanceScore(performanceScore, actor); + + await maturityRepository.AddAsync(status, ct); + await maturityRepository.UnitOfWork.SaveEntitiesAsync(ct); + } + + // ── Utilidades HTTP ───────────────────────────────────────────────────────── + + private async Task CreateAsync( + HttpClient requester, Guid tenantId, Guid targetUserId, Guid currentRoleId, Guid targetRoleId, CancellationToken ct) + { + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new { TenantId = tenantId, TargetUserId = targetUserId, CurrentRoleId = currentRoleId, TargetRoleId = targetRoleId }, + ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(ct); + created.Should().NotBeNull(); + return $"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}"; + } + + private HttpClient CreateActorClient(Guid tenantId, Guid actorId) + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-Actor-Id", actorId.ToString()); + return client; + } + + private async Task WaitForProfileRoleAsync(Guid profileId, Guid expectedRoleId, CancellationToken ct) + { + // La reasignación llega por el bus de forma asíncrona: se relee con reintentos deterministas + // —scope/DbContext nuevo por intento para no leer del mapa de identidad— hasta observar el rol. + ProfileAggregate? profile = null; + for (var attempt = 0; attempt < 50; attempt++) + { + using var scope = _factory!.Services.CreateScope(); + var profileRepository = scope.ServiceProvider.GetRequiredService(); + profile = await profileRepository.GetByIdAsync(profileId, ct); + + if (profile is not null && profile.RoleId.GetValue() == expectedRoleId) + { + return profile; + } + + await Task.Delay(100, ct); + } + + return profile; + } + + private static async Task GetAsync(HttpClient client, string baseUrl, CancellationToken ct) + { + var dto = await client.GetFromJsonAsync(baseUrl, ct); + dto.Should().NotBeNull(); + return dto!; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRequestRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRequestRestEndpointTests.cs new file mode 100644 index 00000000..0c379f2a --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRequestRestEndpointTests.cs @@ -0,0 +1,303 @@ +using System.Net; +using System.Net.Http.Json; +using Ums.Application.IGA.DTOs; +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Iga; + +/// +/// Pruebas de los endpoints REST del contexto acotado IGA (ADR-UMS-093, G-052). Cubren la máquina de +/// estados de promoción de rol de punta a punta —Draft → PendingEligibilityCheck → PendingManagerApproval +/// → (PendingSecurityReview) → Approved → Executed → Verified—, la confirmación de elegibilidad +/// fail-closed (feliz y borde no elegible), la lectura acotada por inquilino y la segregación de +/// funciones (SoD, INV-RPR3). +/// +/// El happy-path se apoya en el : siembra un RoleMaturityStatus +/// ELEGIBLE para RansaAdminUserId en DemoAdminRoleId y uno NO ELEGIBLE (borde) para +/// RansaAnalystUserId en DemoOperatorRoleId. La segregación de funciones se ejerce con +/// actores distintos por transición mediante el encabezado de prueba X-Test-Actor-Id. +/// +public sealed class RolePromotionRequestRestEndpointTests : IClassFixture +{ + private static readonly Guid SeededTenantId = Guid.Parse(CoreDevDataSeeder.RansaTenantId); + + // Coincide con el NameIdentifier por defecto que emite TestAuthHandler cuando no se envía override. + private static readonly Guid AuthenticatedRequesterId = Guid.Parse("00000000-0000-0000-0000-000000000111"); + + // Usuario objetivo ELEGIBLE (con RoleMaturityStatus sembrado) y sus roles (ambos sembrados en RANSA). + private static readonly Guid TargetUserId = Guid.Parse(CoreDevDataSeeder.RansaAdminUserId); + private static readonly Guid CurrentRoleId = Guid.Parse(CoreDevDataSeeder.DemoAdminRoleId); + private static readonly Guid TargetRoleId = Guid.Parse(CoreDevDataSeeder.DemoOperatorRoleId); + + // Usuario objetivo NO ELEGIBLE (borde): tiempo insuficiente en nivel ⇒ elegibilidad rechazada. + private static readonly Guid IneligibleTargetUserId = Guid.Parse(CoreDevDataSeeder.RansaAnalystUserId); + private static readonly Guid IneligibleCurrentRoleId = Guid.Parse(CoreDevDataSeeder.DemoOperatorRoleId); + private static readonly Guid IneligibleTargetRoleId = Guid.Parse(CoreDevDataSeeder.DemoAdminRoleId); + + // Actores distintos para respetar la segregación de funciones a lo largo del flujo. + private const string RequesterActor = "a0000000-0000-0000-0000-000000000a01"; + private const string ApproverActor = "b0000000-0000-0000-0000-000000000b02"; + private const string SecurityActor = "c0000000-0000-0000-0000-000000000c03"; + private const string ExecutorActor = "d0000000-0000-0000-0000-000000000d04"; + private const string VerifierActor = "e0000000-0000-0000-0000-000000000e05"; + + private readonly UmsApiWebApplicationFactory _factory; + private readonly HttpClient _client; + + public RolePromotionRequestRestEndpointTests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + _client.DefaultRequestHeaders.Add("X-User-Id", AuthenticatedRequesterId.ToString()); + _client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + } + + private HttpClient CreateActorClient(string actorId) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + // G-057: el host InMemory (UmsApiWebApplicationFactory) autentica con DevAuthMiddleware, que lee + // X-User-Id (→ NameIdentifier) e IGNORA X-Test-Actor-Id; sin X-User-Id el actor quedaba en + // "dev-user" (no-GUID) y el create fallaba con 400 «identificador no válido». Se envía el actor + // también como X-User-Id para que el host InMemory lo honre (el TestAuthHandler del host PostgreSQL + // sigue leyendo X-Test-Actor-Id); ambos resuelven el mismo actorId → segregación de funciones intacta. + client.DefaultRequestHeaders.Add("X-User-Id", actorId); + client.DefaultRequestHeaders.Add("X-Test-Actor-Id", actorId); + return client; + } + + [Fact] + public async Task Create_ThenSubmit_ShouldAdvanceThroughStateMachine() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new + { + TenantId = SeededTenantId, + TargetUserId, + CurrentRoleId, + TargetRoleId, + }; + + var createResponse = await _client.PostAsJsonAsync("/api/v1/role-promotion-requests", command, cancellationToken); + createResponse.EnsureSuccessStatusCode(); + + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + created.Should().NotBeNull(); + + var getResponse = await _client.GetAsync($"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}", cancellationToken); + getResponse.EnsureSuccessStatusCode(); + + var draft = await getResponse.Content.ReadFromJsonAsync(cancellationToken); + draft.Should().NotBeNull(); + draft!.Status.Should().Be("Draft"); + draft.TargetUserId.Should().Be(TargetUserId); + draft.RequesterId.Should().Be(AuthenticatedRequesterId); + + var submitResponse = await _client.PostAsync( + $"/api/v1/role-promotion-requests/{created.RolePromotionRequestId}/submit", content: null, cancellationToken); + submitResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterSubmit = await _client.GetFromJsonAsync( + $"/api/v1/role-promotion-requests/{created.RolePromotionRequestId}", cancellationToken); + afterSubmit.Should().NotBeNull(); + afterSubmit!.Status.Should().Be("PendingEligibilityCheck"); + afterSubmit.RiskScore.Should().NotBeNull(); + } + + /// + /// Happy-path completo (G-052): con el RoleMaturityStatus sembrado ELEGIBLE, la solicitud + /// atraviesa toda la máquina de estados hasta Verified. Cada transición la ejecuta un actor distinto + /// para respetar la segregación de funciones (INV-RPR3). + /// + [Fact] + public async Task FullPromotionFlow_WithEligibleMaturity_ShouldReachVerified() + { + var cancellationToken = TestContext.Current.CancellationToken; + + var requester = CreateActorClient(RequesterActor); + var approver = CreateActorClient(ApproverActor); + var security = CreateActorClient(SecurityActor); + var executor = CreateActorClient(ExecutorActor); + var verifier = CreateActorClient(VerifierActor); + + // 1. Crear (Draft) — el solicitante no es el objetivo. + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new { TenantId = SeededTenantId, TargetUserId, CurrentRoleId, TargetRoleId }, + cancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + created.Should().NotBeNull(); + var id = created!.RolePromotionRequestId; + var baseUrl = $"/api/v1/role-promotion-requests/{id}"; + + // 2. Submit → PendingEligibilityCheck (congela el RiskScore). + (await requester.PostAsync($"{baseUrl}/submit", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, cancellationToken)).Status.Should().Be("PendingEligibilityCheck"); + + // 3. ConfirmEligibility → PendingManagerApproval (ELEGIBLE gracias al seed; ya no rechaza). + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, cancellationToken)).Status.Should().Be("PendingManagerApproval"); + + // 4. ManagerApprove → Approved o PendingSecurityReview según el RiskScore. + (await approver.PostAsync($"{baseUrl}/manager-approve", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterManager = await GetAsync(approver, baseUrl, cancellationToken); + afterManager.Status.Should().BeOneOf("Approved", "PendingSecurityReview"); + + // 4b. Si el riesgo escaló a revisión de seguridad, un revisor distinto la aprueba. + if (afterManager.Status == "PendingSecurityReview") + { + (await security.PostAsync($"{baseUrl}/security-approve", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + (await GetAsync(security, baseUrl, cancellationToken)).Status.Should().Be("Approved"); + + // 5. Execute → Executed (ejecutor distinto del objetivo). + (await executor.PostAsync($"{baseUrl}/execute", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, cancellationToken)).Status.Should().Be("Executed"); + + // 6. Verify → Verified (verificador distinto del ejecutor, del aprobador y del objetivo). + (await verifier.PostAsync($"{baseUrl}/verify", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var verified = await GetAsync(verifier, baseUrl, cancellationToken); + verified.Status.Should().Be("Verified"); + verified.ApproverId.Should().NotBeNull(); + verified.ExecutorId.Should().NotBeNull(); + verified.VerifierId.Should().NotBeNull(); + } + + /// + /// Borde fail-closed (G-052): el objetivo tiene un RoleMaturityStatus sembrado pero con tiempo + /// insuficiente en nivel, de modo que la confirmación de elegibilidad RECHAZA y la promoción nunca + /// avanza (INV-RPR4). Cubre el corte sin depender de la ausencia del dato de madurez. + /// + [Fact] + public async Task ConfirmEligibility_WithIneligibleMaturity_ShouldRejectAndHalt() + { + var cancellationToken = TestContext.Current.CancellationToken; + var requester = CreateActorClient(RequesterActor); + + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new + { + TenantId = SeededTenantId, + TargetUserId = IneligibleTargetUserId, + CurrentRoleId = IneligibleCurrentRoleId, + TargetRoleId = IneligibleTargetRoleId, + }, + cancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + created.Should().NotBeNull(); + var baseUrl = $"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}"; + + (await requester.PostAsync($"{baseUrl}/submit", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var rejected = await GetAsync(requester, baseUrl, cancellationToken); + rejected.Status.Should().Be("Rejected"); + rejected.DecisionReason.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Create_WhenRequesterIsTargetUser_ShouldFailSegregationOfDuties() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new + { + TenantId = SeededTenantId, + TargetUserId = AuthenticatedRequesterId, // viola SoD: el solicitante no puede promoverse a sí mismo. + CurrentRoleId, + TargetRoleId, + }; + + var response = await _client.PostAsJsonAsync("/api/v1/role-promotion-requests", command, cancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ManagerApprove_WhenApproverIsRequester_ShouldFailSegregationOfDuties() + { + var cancellationToken = TestContext.Current.CancellationToken; + var requester = CreateActorClient(RequesterActor); + + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new { TenantId = SeededTenantId, TargetUserId, CurrentRoleId, TargetRoleId }, + cancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + var baseUrl = $"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}"; + + (await requester.PostAsync($"{baseUrl}/submit", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // El solicitante intenta aprobar su propia solicitud → violación de SoD (INV-RPR3). + var approveResponse = await requester.PostAsync($"{baseUrl}/manager-approve", null, cancellationToken); + approveResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task List_ShouldReturnCreatedRequestScopedByTenant() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new + { + TenantId = SeededTenantId, + TargetUserId, + CurrentRoleId, + TargetRoleId, + }; + + var createResponse = await _client.PostAsJsonAsync("/api/v1/role-promotion-requests", command, cancellationToken); + createResponse.EnsureSuccessStatusCode(); + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + created.Should().NotBeNull(); + + var list = await _client.GetFromJsonAsync>( + $"/api/v1/role-promotion-requests?tenantId={SeededTenantId}", cancellationToken); + + list.Should().NotBeNull(); + list!.Should().Contain(r => r.Id == created!.RolePromotionRequestId); + } + + /// + /// G-100: un GET por id inexistente debe resolver 404. El handler devuelve el error de dominio de + /// «no encontrado» en español (código estable iga.role_promotion_request_not_found); antes el + /// DomainErrorStatusMapper sólo reconocía el substring en inglés «not found» y lo colapsaba a 400. + /// Esta prueba recorre el endpoint real y el mapeador real, con lo que verifica la clasificación por + /// código con independencia del idioma del mensaje. + /// + [Fact] + public async Task GetById_WhenRequestDoesNotExist_ShouldReturn404() + { + var cancellationToken = TestContext.Current.CancellationToken; + var missingId = Guid.NewGuid(); + + var response = await _client.GetAsync($"/api/v1/role-promotion-requests/{missingId}", cancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private static async Task GetAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken) + { + var dto = await client.GetFromJsonAsync(baseUrl, cancellationToken); + dto.Should().NotBeNull(); + return dto!; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRoleAssignmentConsumerTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRoleAssignmentConsumerTests.cs new file mode 100644 index 00000000..fbdac628 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRoleAssignmentConsumerTests.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Ums.Domain.Events; +using Ums.Infrastructure.Hosting; +using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; + +namespace Ums.Presentation.IntegrationTest.Iga; + +/// +/// G-094: pruebas AISLADAS de —el consumidor que +/// aplica el efecto de la promoción de rol IGA entregado por el Transactional Outbox—. Se ejercita su +/// núcleo (ApplyRoleAssignmentAsync) contra un real, sin +/// bus ni contenedor, para verificar de forma determinista: +/// +/// 1. Dado el evento, reasigna el rol del perfil ACTIVO del objetivo cuyo rol coincide con el origen. +/// 2. Ante un ChangeRole fallido NO confirma éxito: lanza excepción para que MassTransit +/// reintente y, agotados los reintentos, mueva el mensaje a la dead-letter — el efecto de la +/// promoción no se descarta en silencio (fin del fallo silencioso G-093). +/// +/// Complementa a (happy-path E2E por el bus real) cubriendo +/// la rama de fallo, que el harness in-memory no puede forzar de forma determinista. +/// +public sealed class RolePromotionRoleAssignmentConsumerTests +{ + // El campo del inquilino NO se llama «TenantId» para no ensombrecer el tipo homónimo del dominio. + private static readonly Guid SeedTenantId = Guid.NewGuid(); + private static readonly Guid TargetUserId = Guid.NewGuid(); + private static readonly Guid CurrentRoleId = Guid.NewGuid(); + private static readonly Guid TargetRoleId = Guid.NewGuid(); + private static readonly Guid ExecutorId = Guid.NewGuid(); + + private static RolePromotionRoleAssignmentConsumer CreateConsumer(InMemoryProfileRepository repository) + => new(repository, NullLogger.Instance); + + private static ProfileAggregate SeedActiveProfile(InMemoryProfileRepository repository, Guid roleId) + { + var profile = ProfileAggregate.Create( + TenantId.Load(SeedTenantId), + UserId.Load(TargetUserId), + RoleId.Load(roleId), + branchId: null, + ActorId.Create(ExecutorId.ToString())).Value; + + repository.Seed(profile); + return profile; + } + + [Fact] + public async Task ApplyRoleAssignment_WhenActiveProfileMatchesSourceRole_ReassignsToTargetRole() + { + var ct = TestContext.Current.CancellationToken; + var repository = new InMemoryProfileRepository(); + var seeded = SeedActiveProfile(repository, CurrentRoleId); + var consumer = CreateConsumer(repository); + + var message = new RolePromotionExecutedIntegrationEvent( + SeedTenantId, RequestId: Guid.NewGuid(), TargetUserId, CurrentRoleId, TargetRoleId, ExecutorId); + + await consumer.ApplyRoleAssignmentAsync(message, ct); + + var reloaded = await repository.GetByIdAsync(seeded.GetId().GetValue(), ct); + reloaded.Should().NotBeNull(); + reloaded!.RoleId.GetValue().Should().Be(TargetRoleId, "el consumidor debe reasignar el perfil objetivo al rol destino (G-094)"); + reloaded.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task ApplyRoleAssignment_WhenNoMatchingProfile_DoesNothingAndDoesNotThrow() + { + var ct = TestContext.Current.CancellationToken; + var repository = new InMemoryProfileRepository(); + // Perfil activo con OTRO rol: la regla de selección (RoleId == CurrentRoleId) no lo alcanza. + var seeded = SeedActiveProfile(repository, Guid.NewGuid()); + var consumer = CreateConsumer(repository); + + var message = new RolePromotionExecutedIntegrationEvent( + SeedTenantId, RequestId: Guid.NewGuid(), TargetUserId, CurrentRoleId, TargetRoleId, ExecutorId); + + // No hay sujeto sobre el que actuar: se confirma el mensaje (reintentar no ayudaría) sin lanzar. + await consumer.ApplyRoleAssignmentAsync(message, ct); + + var reloaded = await repository.GetByIdAsync(seeded.GetId().GetValue(), ct); + reloaded!.RoleId.GetValue().Should().NotBe(TargetRoleId, "sin perfil coincidente no debe tocarse ningún rol"); + } + + [Fact] + public async Task ApplyRoleAssignment_WhenChangeRoleFails_ThrowsAndDoesNotMarkSuccess() + { + var ct = TestContext.Current.CancellationToken; + var repository = new InMemoryProfileRepository(); + var seeded = SeedActiveProfile(repository, CurrentRoleId); + var consumer = CreateConsumer(repository); + + // Evento degenerado (rol destino == rol origen): el perfil ES seleccionado, pero ChangeRole + // falla («rol sin cambio»). El consumidor debe LANZAR —no confirmar éxito— para que el mensaje + // se reintente y termine en la dead-letter en lugar de descartarse en silencio (G-094). + var message = new RolePromotionExecutedIntegrationEvent( + SeedTenantId, RequestId: Guid.NewGuid(), TargetUserId, CurrentRoleId, TargetRoleId: CurrentRoleId, ExecutorId); + + var act = async () => await consumer.ApplyRoleAssignmentAsync(message, ct); + + await act.Should().ThrowAsync("un ChangeRole fallido no debe confirmarse como éxito (G-094)"); + + var reloaded = await repository.GetByIdAsync(seeded.GetId().GetValue(), ct); + reloaded!.RoleId.GetValue().Should().Be(CurrentRoleId, "un efecto fallido no debe dejar el perfil mutado a medias"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/AuthorizationConfigurationRepositoryTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/AuthorizationConfigurationRepositoryTests.cs index 51fcb035..5d0be02a 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/AuthorizationConfigurationRepositoryTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/AuthorizationConfigurationRepositoryTests.cs @@ -128,7 +128,7 @@ public async Task Profile_CrudOperations_WorkCorrectly() private UmsPlatformDbContext CreateContext(DbContextOptions options) { var tenantContext = new TestTenantContext(_testTenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private class TestTenantContext(Guid tenantId) : ITenantContext @@ -287,13 +287,13 @@ public async Task FeatureFlagCriteria_AddAndRetrieve_WorkCorrectly() .ToListAsync(ct); retrievedCriteria.Should().HaveCount(1); - retrievedCriteria.First().Value.Should().Be("test-value"); + retrievedCriteria[0].Value.Should().Be("test-value"); } private UmsPlatformDbContext CreateContext(DbContextOptions options) { var tenantContext = new TestTenantContext(_testTenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private class TestTenantContext(Guid tenantId) : ITenantContext diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/EntityRepositoryTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/EntityRepositoryTests.cs index 4dfb31e1..79468686 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/EntityRepositoryTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/EntityRepositoryTests.cs @@ -76,7 +76,7 @@ await context.Tenants.AddRangeAsync( private UmsPlatformDbContext CreateContext(DbContextOptions options) { var tenantContext = new TestTenantContext(_testTenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private class TestTenantContext(Guid tenantId) : ITenantContext @@ -203,7 +203,7 @@ await context.UserAccounts.AddRangeAsync( private UmsPlatformDbContext CreateContext(DbContextOptions options) { var tenantContext = new TestTenantContext(_testTenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private class TestTenantContext(Guid tenantId) : ITenantContext diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/FakeFeatureFlagHandler.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/FakeFeatureFlagHandler.cs index a1cb4da6..76a80c9d 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/FakeFeatureFlagHandler.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/FakeFeatureFlagHandler.cs @@ -4,5 +4,6 @@ public sealed class FakeFeatureFlagHandler { public void Handle() { + // Fake handler for testing purposes, deliberately left empty. } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlAuthorizationPersistenceTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlAuthorizationPersistenceTests.cs index 79935384..65edb907 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlAuthorizationPersistenceTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlAuthorizationPersistenceTests.cs @@ -10,80 +10,169 @@ namespace Ums.Presentation.IntegrationTest.Infrastructure; [Collection("PostgreSql")] public sealed class PostgreSqlAuthorizationPersistenceTests : IntegrationTestBase { - private static readonly Guid TenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); - public PostgreSqlAuthorizationPersistenceTests(PostgreSqlContainerFixture fixture) : base(fixture) { } + // G-014 (residual): el host PostgreSQL corre con SeedDevData=false y ResetDatabase() TRUNCA toda + // la data antes de cada test; PostgresTestSeeder no siembra NINGÚN tenant (ni RANSA ni BEYONDNET). + // Antes estas pruebas apuntaban al GUID fijo de RANSA, que ya no existe en la BD → EnsureManagementOwnerScope + // no resuelve el tenant objetivo ("AUTH_002: Tenant not found") y el create devolvía 404. Se crea el + // tenant objetivo por REST (como hacen los demás E2E de este host) y se opera sobre su id real. + private async Task CreateTenantAsync(CancellationToken ct) + { + var code = $"AUTHZ{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var response = await Client.PostAsJsonAsync("/api/v1/tenants", new + { + code, + name = $"Authz Persistence Tenant {code}", + type = "CLIENT", + isManagementOwner = false + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var location = response.Headers.Location?.ToString(); + return Guid.Parse(location!.Split('/')[^1]); + } + [Fact] public async Task CreateAndGetSystemSuite_UsesPostgreSqlAuthorizationStore() { if (!Fixture.IsAvailable) Assert.Skip("Docker is required for SQL Server integration tests."); + var ct = TestContext.Current.CancellationToken; + var tenantId = await CreateTenantAsync(ct); var code = $"SS{Guid.NewGuid():N}"[..10]; var createBody = new { - tenantId = TenantId, + tenantId, code, name = "Tenant Console", description = "SQL-backed authorization system suite." }; - var createResponse = await Client.PostAsJsonAsync("/api/v1/system-suites", createBody, TestContext.Current.CancellationToken); + var createResponse = await Client.PostAsJsonAsync("/api/v1/system-suites", createBody, ct); createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(ct)); var systemSuiteId = createPayload.RootElement.GetProperty("systemSuiteId").GetGuid(); - var getResponse = await Client.GetAsync($"/api/v1/system-suites/{systemSuiteId}", TestContext.Current.CancellationToken); + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + var getResponse = await Client.GetAsync($"/api/v1/system-suites/{systemSuiteId}", ct); getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - using var getPayload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var getPayload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(ct)); getPayload.RootElement.GetProperty("systemSuiteId").GetGuid().Should().Be(systemSuiteId); - getPayload.RootElement.GetProperty("tenantId").GetGuid().Should().Be(TenantId); - getPayload.RootElement.GetProperty("code").GetString().Should().Be(code); + getPayload.RootElement.GetProperty("tenantId").GetGuid().Should().Be(tenantId); + // G-014 (residual): producción canonicaliza el code a MAYÚSCULAS (Code.Create → + // DomainGuards.NormalizeCode = Trim().ToUpperInvariant()). El code enviado lleva la parte + // del GUID en minúsculas, así que la expectativa correcta es su forma canónica en mayúsculas. + getPayload.RootElement.GetProperty("code").GetString().Should().Be(code.ToUpperInvariant()); } [Fact] public async Task CreatePublishAndGetPermissionTemplate_UsesPostgreSqlAuthorizationStore() { if (!Fixture.IsAvailable) Assert.Skip("Docker is required for SQL Server integration tests."); + var ct = TestContext.Current.CancellationToken; + var tenantId = await CreateTenantAsync(ct); var suiteCode = $"PT{Guid.NewGuid():N}"[..10]; var createSuite = await Client.PostAsJsonAsync("/api/v1/system-suites", new { - tenantId = TenantId, + tenantId, code = suiteCode, name = "Approvals", description = "System suite for template integration." - }, TestContext.Current.CancellationToken); + }, ct); createSuite.StatusCode.Should().Be(HttpStatusCode.Created); - using var suitePayload = JsonDocument.Parse(await createSuite.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var suitePayload = JsonDocument.Parse(await createSuite.Content.ReadAsStringAsync(ct)); var systemSuiteId = suitePayload.RootElement.GetProperty("systemSuiteId").GetGuid(); - var roleId = Guid.NewGuid(); + + // G-014 (residual): en el host PostgreSQL las FK son reales. Antes el test usaba un roleId + // aleatorio inexistente y el insert de la plantilla violaba FK_PermissionTemplates_Roles_RoleId + // → DbUpdateException → 500. Se crea un rol real bajo la suite y se usa su id (FK satisfecha). + var roleCode = $"ROLE{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + // ADR-0077 (G-014 residual): fijado X-Tenant-Id a un inquilino CLIENT, aprovisionar roles es + // una operación ON-BEHALF que solo el operador management-owner/internal-admin puede ejecutar + // (TenantScopePolicy → AUTH_015 → 400). Modelamos al internal-admin de BEYONDNET aprovisionando + // recursos para el CLIENT (mismo patrón que RoleE2ETests, verde 4/4). + Client.DefaultRequestHeaders.Remove("X-Is-Internal-Admin"); + Client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + var createRole = await Client.PostAsJsonAsync($"/api/v1/system-suites/{systemSuiteId}/roles", new + { + code = roleCode, + value = "Authz Persistence Role", + description = "Rol para la prueba de round-trip del store de autorización.", + parentRoleId = (Guid?)null, + hierarchyLevel = 0, + promotionOrder = 0, + }, ct); + createRole.StatusCode.Should().Be(HttpStatusCode.Created); + using var rolePayload = JsonDocument.Parse(await createRole.Content.ReadAsStringAsync(ct)); + var roleId = rolePayload.RootElement.GetProperty("roleId").GetGuid(); var createTemplate = await Client.PostAsJsonAsync("/api/v1/permission-templates", new { - tenantId = TenantId, + tenantId, roleId, systemSuiteId - }, TestContext.Current.CancellationToken); + }, ct); createTemplate.StatusCode.Should().Be(HttpStatusCode.Created); - using var templatePayload = JsonDocument.Parse(await createTemplate.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var templatePayload = JsonDocument.Parse(await createTemplate.Content.ReadAsStringAsync(ct)); var templateId = templatePayload.RootElement.GetProperty("templateId").GetGuid(); - var publishResponse = await Client.PostAsync($"/api/v1/permission-templates/{templateId}/publish", content: null, TestContext.Current.CancellationToken); + // G-014 (residual): publicar una plantilla exige al menos un ítem de permiso + // (PermissionTemplate.Publish → DomainErrors.Authorization.TemplateItemsRequired). El round-trip + // original nunca añadía ítems; con el rojo previo de AUTH_015 corregido, la publicación de una + // plantilla vacía devolvía 400. Se añade un ítem allow válido antes de publicar. TargetId/ActionId + // no tienen FK (solo TemplateId la tiene), así que basta con GUIDs; TargetType es un display name + // válido de ExclusiveArcTarget ("SystemSuite"). + var addItem = await Client.PostAsJsonAsync($"/api/v1/permission-templates/{templateId}/items", new + { + targetType = "SystemSuite", + targetId = systemSuiteId, + actionId = Guid.NewGuid(), + isAllowed = true, + isDenied = false + }, ct); + addItem.StatusCode.Should().Be(HttpStatusCode.Created); + + var publishResponse = await Client.PostAsync($"/api/v1/permission-templates/{templateId}/publish", content: null, ct); publishResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - var getResponse = await Client.GetAsync($"/api/v1/permission-templates/{templateId}", TestContext.Current.CancellationToken); + var getResponse = await Client.GetAsync($"/api/v1/permission-templates/{templateId}", ct); getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - using var getPayload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var getPayload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(ct)); getPayload.RootElement.GetProperty("templateId").GetGuid().Should().Be(templateId); - getPayload.RootElement.GetProperty("tenantId").GetGuid().Should().Be(TenantId); + getPayload.RootElement.GetProperty("tenantId").GetGuid().Should().Be(tenantId); getPayload.RootElement.GetProperty("roleId").GetGuid().Should().Be(roleId); getPayload.RootElement.GetProperty("systemSuiteId").GetGuid().Should().Be(systemSuiteId); getPayload.RootElement.GetProperty("status").GetString().Should().Be("Published"); + + // G-140: dar de alta una SEGUNDA plantilla para la misma terna (tenant, rol, suite) —ya + // plantillada por la anterior— debe generar una versión nueva (0.2.0) en lugar de colisionar + // con IX_PermissionTemplates_TenantId_RoleId_SystemSuiteId_Version → 500. La primera quedó en + // 0.1.0; la segunda toma la siguiente. + var createSecond = await Client.PostAsJsonAsync("/api/v1/permission-templates", new + { + tenantId, + roleId, + systemSuiteId + }, ct); + createSecond.StatusCode.Should().Be(HttpStatusCode.Created); + + using var secondPayload = JsonDocument.Parse(await createSecond.Content.ReadAsStringAsync(ct)); + var secondTemplateId = secondPayload.RootElement.GetProperty("templateId").GetGuid(); + secondTemplateId.Should().NotBe(templateId); + + var getSecond = await Client.GetAsync($"/api/v1/permission-templates/{secondTemplateId}", ct); + getSecond.StatusCode.Should().Be(HttpStatusCode.OK); + using var getSecondPayload = JsonDocument.Parse(await getSecond.Content.ReadAsStringAsync(ct)); + getSecondPayload.RootElement.GetProperty("version").GetString().Should().Be("0.2.0"); } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlContainerFixture.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlContainerFixture.cs index 666de69d..03035f12 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlContainerFixture.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlContainerFixture.cs @@ -44,19 +44,19 @@ public async ValueTask InitializeAsync() ConnectionString = _container.GetConnectionString(); - // Bootstrap the UMS platform schema using the same bootstrapper as production. + // Bootstrap the UMS platform schema via EF Core migrations, as production does. var options = new DbContextOptionsBuilder() .UseNpgsql(ConnectionString, sql => sql.EnableRetryOnFailure(3)) .Options; - await using var ctx = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object); - await PostgreSqlSchemaBootstrapper.InitializeAsync(ctx, new PostgreSqlDistributedLockProvider()); + await using var ctx = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + await ctx.Database.MigrateAsync(); IsAvailable = true; } catch (Exception ex) { - System.IO.File.WriteAllText("testcontainers-error.log", "Testcontainers failed: " + ex.ToString()); + await System.IO.File.WriteAllTextAsync("testcontainers-error.log", "Testcontainers failed: " + ex.ToString()); IsAvailable = false; throw; // Fail the test run immediately if the DB cannot start } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlUserAccountRepositoryTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlUserAccountRepositoryTests.cs index fa950310..e7cbf87e 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlUserAccountRepositoryTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlUserAccountRepositoryTests.cs @@ -102,7 +102,7 @@ await context.UserAccounts.AddRangeAsync( private static UmsPlatformDbContext CreateContext(DbContextOptions options, Guid tenantId) { var tenantContext = new TestTenantContext(tenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private sealed class TestTenantContext(Guid tenantId) : Ums.Application.Common.Interfaces.ITenantContext diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlWebApplicationFactory.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlWebApplicationFactory.cs index a967af57..f84a1732 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlWebApplicationFactory.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlWebApplicationFactory.cs @@ -2,10 +2,15 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Ums.Infrastructure.MasterData; +using Ums.Infrastructure.Persistence; using Ums.Infrastructure.Persistence.Options; +using Ums.ReadModels; using Ums.Presentation; using Ums.Domain.Configuration; using Ums.Domain.Approvals; @@ -71,17 +76,80 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) options.DefaultChallengeScheme = "Test"; }) .AddScheme("Test", options => { }); + + // G-014 (causa b) — CAUSA REAL: el UmsPlatformDbContext (el sistema bajo prueba) acababa + // conectando a la cadena por defecto de appsettings (Host=localhost;Database=UmsDev), NO a la + // del Testcontainer → "Connection refused" y arranque del host roto (IntegrationTestBase cae a + // un HttpClient vacío ⇒ TODA la clase E2E/PostgreSQL falla). Motivo: AddInfrastructure captura + // ConnectionStrings:DefaultConnection en tiempo de REGISTRO, y el override de este factory + // (ConfigureAppConfiguration) aún no es visible entonces —mismo pitfall de timing que la causa + // (a)—. El host InMemory lo evita re-registrando el contexto; el host PostgreSQL nunca hizo el + // equivalente para la cadena. Fix: re-registrar UmsPlatformDbContext y ReadModelDbContext + // apuntando al Testcontainer real (cuya cadena es el parámetro `connectionString`, ya migrado + // por PostgreSqlContainerFixture). Se re-registra sin interceptores, igual que el host InMemory. + ReplaceNpgsqlDbContext(services, connectionString); + ReplaceNpgsqlDbContext(services, connectionString); + + // Defensa: el TenantProjectionConsumer (registrado SIEMPRE por AddConsumers) consume los + // TenantEvent que publican los E2E y escribe en TenantProjectionDbContext, que en este host + // conserva su fallback Npgsql a localhost:5432 (no hay MasterDataDb). Una vez que el fix de + // arriba revive el flujo E2E, esas publicaciones dispararían el consumer contra localhost:5432. + // Se apunta la proyección MMS a InMemory (el consumer escribe ahí, inofensivo). + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.AddDbContext(options => options.UseInMemoryDatabase("TestProjectionDb")); }); } protected override IHost CreateHost(IHostBuilder builder) { var host = base.CreateHost(builder); - // Seed the database with required configuration and approval aggregates using the real DbContext + // G-014 (causa b, capa 3 — aislamiento): el Testcontainer es de colección (una sola BD para + // todos los tests) y no se reseteaba entre pruebas. Como cada test crea su propio host aquí, + // truncamos toda la data ANTES de re-sembrar la línea base, dando a cada test una pizarra limpia + // y eliminando los choques de clave duplicada (los tests E2E crean entidades con IDs/constraints + // fijos que colisionaban con las del test anterior). La colección xUnit serializa sus tests, así + // que no hay carreras sobre la BD compartida. + ResetDatabase(host.Services); PostgresTestSeeder.SeedConfigurationAggregates(host.Services); PostgresTestSeeder.SeedApprovalAggregates(host.Services); return host; } + + // Vacía toda la data del contenedor (TRUNCATE ... CASCADE de cada tabla, preservando el historial de + // migraciones) para aislar cada test. Dinámico: no hay que mantener la lista de tablas a mano. + private static void ResetDatabase(IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.ExecuteSqlRaw(@" + DO $$ + DECLARE r RECORD; + BEGIN + FOR r IN ( + SELECT schemaname, tablename + FROM pg_tables + WHERE schemaname NOT IN ('pg_catalog', 'information_schema') + AND tablename <> '__EFMigrationsHistory' + ) + LOOP + EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.schemaname) || '.' || quote_ident(r.tablename) || ' RESTART IDENTITY CASCADE'; + END LOOP; + END $$;"); + } + + // Retira el registro de que dejó AddInfrastructure (contexto, + // DbContextOptions y la configuración de opciones específica del proveedor —EF Core 9+—, cuya + // cadena de conexión se capturó de appsettings) y lo re-registra sobre el Npgsql del Testcontainer. + private static void ReplaceNpgsqlDbContext(IServiceCollection services, string connectionString) + where TContext : DbContext + { + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.AddDbContext(options => options.UseNpgsql(connectionString, sql => sql.EnableRetryOnFailure(3))); + } } // Shared seeder for PostgreSQL test container – uses EfCore DbContext directly @@ -185,11 +253,18 @@ public static void SeedConfigurationAggregates(IServiceProvider services) public static void SeedApprovalAggregates(IServiceProvider services) { using var scope = services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + // G-014 (causa b, capa 2): appsettings activa UsePostgreSqlApprovalsStores=true, así que en este + // host los approvals SON PostgreSQL. El sembrado hacía db.Set() sobre el + // UmsPlatformDbContext, pero ApprovalWorkflow es el AGREGADO DE DOMINIO —no está mapeado en el + // modelo (el contexto mapea ApprovalWorkflowRecord)— y EF lanzaba "Cannot create a DbSet for + // 'ApprovalWorkflow' ..." abortando el arranque del host y hundiendo las ~73 clases E2E de la + // colección. Se siembra vía el repositorio de approvals (que mapea el agregado a + // ApprovalWorkflowRecord y persiste vía su UnitOfWork sobre el Testcontainer real). + var workflowRepository = scope.ServiceProvider.GetRequiredService(); var actor = ActorId.Create("00000000-0000-0000-0000-000000000111"); var tenantId = TenantId.Load(Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6")); - if (db.Set().Any()) + if (workflowRepository.GetAllAsync().GetAwaiter().GetResult().Count > 0) return; var manualWorkflow = ApprovalWorkflow.Create( @@ -203,7 +278,7 @@ public static void SeedApprovalAggregates(IServiceProvider services) actor, requiredDocumentCount: 1).Value; SetAggregateId(manualWorkflow.Props, Guid.Parse("88888888-1111-1111-1111-111111111111")); - db.Set().Add(manualWorkflow); + workflowRepository.AddAsync(manualWorkflow).GetAwaiter().GetResult(); var autoWorkflow = ApprovalWorkflow.Create( tenantId, @@ -215,9 +290,9 @@ public static void SeedApprovalAggregates(IServiceProvider services) null, actor).Value; SetAggregateId(autoWorkflow.Props, Guid.Parse("88888888-2222-2222-2222-222222222222")); - db.Set().Add(autoWorkflow); + workflowRepository.AddAsync(autoWorkflow).GetAwaiter().GetResult(); - db.SaveChanges(); + workflowRepository.UnitOfWork.SaveChangesAsync().GetAwaiter().GetResult(); } private static void SetAggregateId(object props, Guid id) diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextAccessorTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextAccessorTests.cs deleted file mode 100644 index af43ca82..00000000 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextAccessorTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Ums.Infrastructure.Services; - -namespace Ums.Presentation.IntegrationTest.Infrastructure; - -public sealed class RequestContextAccessorTests -{ - [Fact] - public void Set_ShouldExposeSnapshotThroughRequestContextProperties() - { - var accessor = new RequestContextAccessor(); - - accessor.Set(new ExecutionContextSnapshot( - CorrelationId: "corr-123", - SessionTrackingId: "session-123", - TraceId: "trace-123", - SpanId: "span-123")); - - accessor.CorrelationId.Should().Be("corr-123"); - accessor.SessionTrackingId.Should().Be("session-123"); - accessor.TraceId.Should().Be("trace-123"); - accessor.SpanId.Should().Be("span-123"); - } - - [Fact] - public void Set_WithEmptySnapshot_ShouldReturnNullProperties() - { - var accessor = new RequestContextAccessor(); - - accessor.Set(ExecutionContextSnapshot.Empty); - - accessor.CorrelationId.Should().BeNull(); - accessor.SessionTrackingId.Should().BeNull(); - accessor.TraceId.Should().BeNull(); - accessor.SpanId.Should().BeNull(); - } - - [Fact] - public void SetClientTimezone_ShouldExposeThroughClientTimezoneProperty() - { - var accessor = new RequestContextAccessor(); - - accessor.SetClientTimezone("America/Lima"); - - accessor.ClientTimezone.Should().Be("America/Lima"); - } - - [Fact] - public void SetClientTimezone_WithNull_ShouldReturnNull() - { - var accessor = new RequestContextAccessor(); - accessor.SetClientTimezone("America/Lima"); - - accessor.SetClientTimezone(null); - - accessor.ClientTimezone.Should().BeNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextTests.cs new file mode 100644 index 00000000..e52fabf0 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextTests.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using Ums.Infrastructure.Observability; + +namespace Ums.Presentation.IntegrationTest.Infrastructure; + +/// +/// Verifica el tras la unificación W3C (ADR-0046): TraceId/SpanId +/// se derivan de y la correlación es el trace_id (no un GUID propio). +/// +public sealed class RequestContextTests +{ + [Fact] + public void SetSessionTrackingId_ShouldExposeThroughProperty() + { + var context = new RequestContext(); + + context.SetSessionTrackingId("session-123"); + + context.SessionTrackingId.Should().Be("session-123"); + } + + [Fact] + public void SetSessionTrackingId_WithWhitespace_ShouldReturnNull() + { + var context = new RequestContext(); + + context.SetSessionTrackingId(" "); + + context.SessionTrackingId.Should().BeNull(); + } + + [Fact] + public void TraceAndSpan_ShouldDeriveFromCurrentW3CActivity() + { + var context = new RequestContext(); + + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = true; + using var activity = new Activity("test-request"); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + + context.TraceId.Should().Be(activity.TraceId.ToString()); + context.SpanId.Should().Be(activity.SpanId.ToString()); + // Unificación W3C: la correlación es el trace_id. + context.CorrelationId.Should().Be(activity.TraceId.ToString()); + } + + [Fact] + public void TraceAndSpan_WithoutActivity_ShouldReturnNull() + { + var context = new RequestContext(); + + // Sin Activity actual no hay contexto de traza W3C. + Activity.Current = null; + + context.TraceId.Should().BeNull(); + context.SpanId.Should().BeNull(); + context.CorrelationId.Should().BeNull(); + } + + [Fact] + public void SetClientTimezone_ShouldExposeThroughClientTimezoneProperty() + { + var context = new RequestContext(); + + context.SetClientTimezone("America/Lima"); + + context.ClientTimezone.Should().Be("America/Lima"); + } + + [Fact] + public void SetClientTimezone_WithNull_ShouldReturnNull() + { + var context = new RequestContext(); + context.SetClientTimezone("America/Lima"); + + context.SetClientTimezone(null); + + context.ClientTimezone.Should().BeNull(); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/SqliteSchemaBootstrapperTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/SqliteSchemaBootstrapperTests.cs deleted file mode 100644 index acd5da96..00000000 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/SqliteSchemaBootstrapperTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System.Reflection; -using FluentAssertions; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Ums.Infrastructure.Persistence; - -namespace Ums.Presentation.IntegrationTest.Infrastructure; - -public sealed class SqliteSchemaBootstrapperTests -{ - [Fact] - public async Task InitializeAsync_WhenTenantsTableMissesManagementOwnerColumn_AddsItWithoutFailing() - { - var ct = TestContext.Current.CancellationToken; - - await using var connection = new SqliteConnection("Data Source=file:ums-bootstrap-test?mode=memory&cache=shared"); - await connection.OpenAsync(ct); - - await using (var setup = connection.CreateCommand()) - { - setup.CommandText = """ - CREATE TABLE IF NOT EXISTS "Tenants" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_Tenants" PRIMARY KEY, - "Code" TEXT NOT NULL, - "Name" TEXT NOT NULL, - "StatusId" INTEGER NOT NULL, - "CreatedBy" TEXT NOT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "AuditTimeSpan" TEXT NOT NULL, - "IsDeleted" INTEGER NOT NULL DEFAULT 0 - ); - """; - await setup.ExecuteNonQueryAsync(ct); - } - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - await using var context = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object); - - var bootstrapper = typeof(SqliteSchemaBootstrapper) - .GetMethod("EnsureTenantManagementOwnerColumnAsync", BindingFlags.NonPublic | BindingFlags.Static) - ?? throw new InvalidOperationException("Bootstrapper helper method was not found."); - - var task = (Task)bootstrapper.Invoke(null, new object[] { context, TestContext.Current.CancellationToken })!; - await task; - - var columnExists = await ColumnExistsAsync(connection, "Tenants", "IsManagementOwner"); - columnExists.Should().BeTrue(); - } - - [Fact] - public async Task InitializeAsync_OnFreshDatabase_CreatesTenantManagementOwnerColumn() - { - var ct = TestContext.Current.CancellationToken; - - await using var connection = new SqliteConnection("Data Source=file:ums-bootstrap-fresh?mode=memory&cache=shared"); - await connection.OpenAsync(ct); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - await using var context = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object); - - await SqliteSchemaBootstrapper.InitializeAsync(context, ct); - - var columnExists = await ColumnExistsAsync(connection, "Tenants", "IsManagementOwner"); - columnExists.Should().BeTrue(); - } - - [Fact] - public async Task InitializeAsync_WhenInternalAdminTenantExistsWithFalseFlag_RepairsItToTrue() - { - var ct = TestContext.Current.CancellationToken; - - await using var connection = new SqliteConnection("Data Source=file:ums-bootstrap-repair?mode=memory&cache=shared"); - await connection.OpenAsync(ct); - - await using (var setup = connection.CreateCommand()) - { - setup.CommandText = """ - CREATE TABLE IF NOT EXISTS "Tenants" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_Tenants" PRIMARY KEY, - "Code" TEXT NOT NULL, - "Name" TEXT NOT NULL, - "StatusId" INTEGER NOT NULL, - "CreatedBy" TEXT NOT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "AuditTimeSpan" TEXT NOT NULL, - "IsDeleted" INTEGER NOT NULL DEFAULT 0, - "IsManagementOwner" INTEGER NOT NULL DEFAULT 0 - ); - INSERT INTO "Tenants" ( - "Id", "Code", "Name", "StatusId", "CreatedBy", "CreatedAtUtc", "AuditTimeSpan", "IsDeleted", "IsManagementOwner" - ) VALUES ( - '11111111-1111-1111-1111-111111111111', - 'INTERNAL_ADMIN', - 'Internal Admin Tenant', - 1, - '00000000-0000-0000-0000-000000000001', - '2026-06-02T00:00:00Z', - '0:00:00', - 0, - 0 - ); - """; - await setup.ExecuteNonQueryAsync(ct); - } - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - await using var context = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object); - - await SqliteSchemaBootstrapper.InitializeAsync(context, ct); - - await using var verify = connection.CreateCommand(); - verify.CommandText = """ - SELECT "IsManagementOwner" - FROM "Tenants" - WHERE upper("Code") = 'INTERNAL_ADMIN' - LIMIT 1; - """; - - var value = await verify.ExecuteScalarAsync(ct); - value.Should().NotBeNull(); - Convert.ToInt32(value).Should().Be(1); - } - - private static async Task ColumnExistsAsync( - SqliteConnection connection, - string tableName, - string columnName) - { - var ct = TestContext.Current.CancellationToken; - - await using var command = connection.CreateCommand(); - command.CommandText = $""" - SELECT 1 - FROM pragma_table_info('{tableName}') - WHERE name = '{columnName}' - LIMIT 1; - """; - - var result = await command.ExecuteScalarAsync(ct); - return result is not null; - } - - private sealed class SystemTenantContext : Ums.Application.Common.Interfaces.ITenantContext - { - public Guid? OrganizationId => null; - public Guid? OriginalTenantId => null; - public bool IsInternalAdmin => true; - public void Initialize(Guid userTenantId, bool isInternalAdmin) { } - public void SetOrganizationId(Guid organizationId) { } - public void EnableCrossTenantAccess() { } - public void DisableCrossTenantAccess() { } - } -} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/TestAuthHandler.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/TestAuthHandler.cs index 2ea68f11..9bfaf149 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/TestAuthHandler.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/TestAuthHandler.cs @@ -25,9 +25,20 @@ protected override Task HandleAuthenticateAsync() tenantId = tenantHeader.ToString(); } + // Override opcional del actor autenticado (NameIdentifier). Aditivo y retrocompatible: + // por defecto se mantiene el actor histórico "…0111"; sólo las pruebas que necesitan + // varios actores distintos (p. ej. la segregación de funciones del happy-path IGA, G-052) + // envían este encabezado para conmutar la identidad por petición. + var actorId = "00000000-0000-0000-0000-000000000111"; + if (Request.Headers.TryGetValue("X-Test-Actor-Id", out var actorHeader) + && Guid.TryParse(actorHeader.ToString(), out _)) + { + actorId = actorHeader.ToString(); + } + var claims = new[] { - new Claim(ClaimTypes.NameIdentifier, "00000000-0000-0000-0000-000000000111"), + new Claim(ClaimTypes.NameIdentifier, actorId), new Claim("tenant_id", tenantId), new Claim("org_id", tenantId), new Claim("is_internal_admin", "true"), diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiServiceBootstrappersTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiServiceBootstrappersTests.cs index 0b161f2b..f09e3097 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiServiceBootstrappersTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiServiceBootstrappersTests.cs @@ -36,10 +36,37 @@ public void AddUmsApiServiceBootstrappers_ShouldRegisterPlatformPolicies() services.AddUmsApiServiceBootstrappers(configuration, new FakeHostEnvironment()); - services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IConfigureOptions)); + // G-248: ya NO se registra `RateLimiterOptions`. El limitador de ASP.NET era en proceso + // —con N réplicas el cupo efectivo era N veces el declarado— y corría antes de autenticar, + // así que repartía por IP y dos usuarios distintos compartían cupo. Lo sustituye + // `LimiteDePeticionesMiddleware` sobre `ILimitadorDePeticiones`, que se registra en la capa + // de infraestructura junto al resto del estado compartido. Se comprueba su AUSENCIA: si + // alguien vuelve a añadir `AddRateLimiter`, habrá dos limitadores contando lo mismo. + services.Should().NotContain(descriptor => descriptor.ServiceType == typeof(IConfigureOptions)); services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IConfigureOptions)); } + [Fact] + public void AddUmsApiServiceBootstrappers_SinSecretoDeFirma_FallaAlComponer() + { + // G-191: UMS firma y valida sus propios tokens en HS256. Un host sin `Jwt:Secret` no puede + // autenticar a ningún satélite, así que la composición falla en el arranque en vez de + // arrancar «a medias» y descubrirlo en la primera petición. + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AllowedOrigins"] = "https://localhost:3000", + ["Persistence:Provider"] = "InMemory", + }) + .Build(); + + var componer = () => services.AddUmsApiServiceBootstrappers(configuration, new FakeHostEnvironment()); + + componer.Should().Throw() + .WithMessage("*Jwt:Secret*"); + } + private static IConfiguration CreateConfiguration() { return new ConfigurationBuilder() @@ -47,6 +74,8 @@ private static IConfiguration CreateConfiguration() { ["AllowedOrigins"] = "https://localhost:3000", ["Persistence:Provider"] = "InMemory", + // G-191: la autenticación por portador exige el secreto de firma para componerse. + ["Jwt:Secret"] = "SECRETO_DE_PRUEBA_HS256_CON_MAS_DE_32_CARACTERES", }) .Build(); } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs index 3bc5b5b1..bed3b63d 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs @@ -1,12 +1,13 @@ -using MassTransit; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Ums.Domain.Audit.AuditRecord; using Ums.Domain.Approvals; using Ums.Domain.Enums; +using Ums.Infrastructure.MasterData; using Ums.Infrastructure.Persistence; using Ums.Infrastructure.Persistence.Audit; using Ums.Infrastructure.Persistence.Options; @@ -16,19 +17,12 @@ namespace Ums.Presentation.IntegrationTest.Infrastructure; public sealed class UmsApiWebApplicationFactory : WebApplicationFactory { - static UmsApiWebApplicationFactory() - { -// InMemory overrides removed; test will use configuration from appsettings (e.g., PostgreSQL) - Environment.SetEnvironmentVariable("Persistence__Provider", "InMemory"); - Environment.SetEnvironmentVariable("Persistence__AggregateStoreMode", "InMemory"); - Environment.SetEnvironmentVariable("Persistence__UsePostgreSqlIdentityStores", "false"); - Environment.SetEnvironmentVariable("Persistence__UsePostgreSqlAuthorizationStores", "false"); - Environment.SetEnvironmentVariable("Persistence__UsePostgreSqlConfigurationStores", "false"); - Environment.SetEnvironmentVariable("Persistence__SeedDevData", "true"); - Environment.SetEnvironmentVariable("Persistence__EnableOutbox", "false"); - Environment.SetEnvironmentVariable("Persistence__InitializePlatformStoreOnStartup", "false"); - } - + // Sin overrides globales: cada host de test recibe su configuración de forma + // AISLADA vía ConfigureAppConfiguration (más abajo). Antes, un constructor + // estático fijaba variables de entorno del PROCESO (`Persistence__Provider= + // InMemory`) que se filtraban a los hosts de PostgreSqlWebApplicationFactory y + // dejaban su UmsPlatformDbContext sin registrar — la causa raíz de ~80 fallos + // de integración (G-014). protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Development"); @@ -58,22 +52,40 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { - services.RemoveAll(); - services.AddMassTransitTestHarness(); - services.AddDbContext((serviceProvider, options) => - { - var cfg = serviceProvider.GetRequiredService(); - var provider = cfg["Persistence:Provider"]; - if (provider == PersistenceProvider.InMemory.ToString()) - { - options.UseInMemoryDatabase("TestDb"); - } - else - { - var connStr = cfg.GetConnectionString("DefaultConnection") ?? cfg["ConnectionStrings:DefaultConnection"]; - options.UseNpgsql(connStr, sql => sql.EnableRetryOnFailure(3)); - } - }); + // G-014 (causa a, 2.ª capa): antes se añadía aquí AddMassTransitTestHarness(), que registra + // un SEGUNDO bus de MassTransit y sustituye al de producción SIN su ConfigureJsonSerializerOptions + // (DependencyInjection.ConfigurePayload). Ese saneamiento elimina la propiedad IMetadata + // (BeyondNetCode.Shell.Ddd) del payload; sin él, publicar CUALQUIER evento de dominio lanza + // SerializationException ("Exception creating proxy ... IMetadata ... does not have an + // implementation"), lo que hacía fallar los seeders y devolver 400 en todo endpoint que emite + // eventos —incluido el flujo IGA (G-052)—. Ningún test consume ITestHarness, y la factoría de + // PostgreSQL ya opera sin arnés sobre el bus in-memory de producción; se retira el arnés para + // que el host InMemory use ese mismo bus (con ConfigurePayload) y la publicación no rompa. + + // G-014 (causa a, 1.ª capa): en el host InMemory coexistían DOS proveedores EF Core en el mismo + // ServiceProvider —el InMemory de UmsPlatformDbContext (que registra esta factoría) y el + // Npgsql que AddInfrastructure registra SIEMPRE, de forma incondicional, para + // TenantProjectionDbContext (proyección MMS, ADR-0083/ADR-0107)—. EF Core lo detecta al + // materializar cualquier DbContext y lanza "Only a single database provider can be + // registered", lo que ABORTA todos los seeders (RunSeederAsync traga la excepción) y deja + // la base sin datos semilla: de ahí la cascada de fallos, incluido el E2E de IGA (G-052) + // que depende del seed de RoleMaturityStatus. + // + // Fix (solo test-infra): se retira el conjunto COMPLETO de descriptores de cada DbContext + // —opciones genéricas, la configuración de opciones específica del proveedor (EF Core 9+) y + // el propio contexto— y se re-registran AMBOS sobre InMemory, de modo que NO quede ningún + // servicio del proveedor Npgsql en el contenedor cuando el host es InMemory. + ReplaceDbContextWithInMemory(services, "TestDb"); + ReplaceDbContextWithInMemory(services, "TestProjectionDb"); + // G-014 (causa raíz del CUELGUE en CI): el `ReadModelDbContext` (proyecciones de fase 1) + // seguía registrado con Npgsql apuntando a `127.0.0.1:5433` (appsettings.Development). En + // el host InMemory, cualquier proyección (p. ej. `PermissionTemplatePublishedEvent`) hacía + // que ese contexto intentara CONECTAR a PostgreSQL: en máquinas con un Postgres en 5433 el + // fallo se enmascaraba, pero en un entorno limpio (runner de CI, contenedor) da «Connection + // refused» y, con `EnableRetryOnFailure(3)`, se cuelga en reintentos. Se re-registra también + // sobre InMemory para que el host InMemory NO toque PostgreSQL en absoluto. + ReplaceDbContextWithInMemory(services, "TestReadModelDb"); + services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); @@ -133,9 +145,40 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); + + // G-014 (residual): el health check "postgresql" (AddInfrastructureHealthChecks) se + // registra según el Provider capturado al construir los servicios y no ve el override + // InMemory de esta factoría (mismo pitfall de timing que la causa b.1). En el host + // InMemory no hay PostgreSQL a la escucha en 127.0.0.1:5432, así que ese check queda + // Unhealthy y /health responde 503, rompiendo HealthEndpointTests y los de + // SessionTrackingMiddleware (que consultan /health). Se retira el registro del check + // postgresql —análogo al retiro de los DbContext Npgsql— para que /health refleje solo + // los checks efectivamente aplicables al host InMemory. + services.PostConfigure(options => + { + var postgresqlCheck = options.Registrations + .FirstOrDefault(registration => registration.Name == "postgresql"); + if (postgresqlCheck is not null) + { + options.Registrations.Remove(postgresqlCheck); + } + }); }); } + // Retira todo lo que dejó un AddDbContext previo (incluida la configuración de opciones + // específica del proveedor — IDbContextOptionsConfiguration, EF Core 9+) y re-registra el + // contexto sobre InMemory. Así el proveedor anterior (p. ej. Npgsql) deja de estar presente y no + // colisiona con InMemory en el mismo ServiceProvider (G-014, causa a). + private static void ReplaceDbContextWithInMemory(IServiceCollection services, string databaseName) + where TContext : DbContext + { + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); + } + protected override IHost CreateHost(IHostBuilder builder) { var host = base.CreateHost(builder); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsSerilogLoggerTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsSerilogLoggerTests.cs index 88f723a3..4cbf04c5 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsSerilogLoggerTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsSerilogLoggerTests.cs @@ -1,6 +1,7 @@ +using System.Diagnostics; using Microsoft.Extensions.Logging; using Ums.Infrastructure.Aop; -using Ums.Infrastructure.Services; +using Ums.Infrastructure.Observability; using BeyondNetCode.Shell.Aop; namespace Ums.Presentation.IntegrationTest.Infrastructure; @@ -20,14 +21,11 @@ public void OnEntry_ShouldEmitFullObservabilityEnvelope() builder.AddProvider(provider); }); - var executionContext = new RequestContextAccessor(); - executionContext.Set(new ExecutionContextSnapshot( - CorrelationId: "corr-001", - SessionTrackingId: "session-001", - TraceId: "trace-001", - SpanId: "span-001")); + using var activity = StartW3CActivity(); + var requestContext = new RequestContext(); + requestContext.SetSessionTrackingId("session-001"); - var logger = new UmsSerilogLogger(loggerFactory, new StubUserContext("tenant-001"), executionContext); + var logger = new UmsSerilogLogger(loggerFactory, new StubUserContext("tenant-001"), requestContext); logger.OnEntry( CreateJoinPoint(), @@ -39,10 +37,11 @@ public void OnEntry_ShouldEmitFullObservabilityEnvelope() entry.Level.Should().Be(LogLevel.Information); entry.Properties["TenantId"].Should().Be("tenant-001"); - entry.Properties["CorrelationId"].Should().Be("corr-001"); entry.Properties["SessionTrackingId"].Should().Be("session-001"); - entry.Properties["TraceId"].Should().Be("trace-001"); - entry.Properties["SpanId"].Should().Be("span-001"); + entry.Properties["TraceId"].Should().Be(activity.TraceId.ToString()); + entry.Properties["SpanId"].Should().Be(activity.SpanId.ToString()); + // Unificación W3C: la correlación es el trace_id (no un GUID propio). + entry.Properties["CorrelationId"].Should().Be(activity.TraceId.ToString()); entry.Properties["BoundedContext"].Should().Be("Configuration"); } @@ -56,14 +55,11 @@ public void OnExit_ShouldKeepSessionAndTraceFields() builder.AddProvider(provider); }); - var executionContext = new RequestContextAccessor(); - executionContext.Set(new ExecutionContextSnapshot( - CorrelationId: "corr-002", - SessionTrackingId: "session-002", - TraceId: "trace-002", - SpanId: "span-002")); + using var activity = StartW3CActivity(); + var requestContext = new RequestContext(); + requestContext.SetSessionTrackingId("session-002"); - var logger = new UmsSerilogLogger(loggerFactory, new StubUserContext("tenant-002"), executionContext); + var logger = new UmsSerilogLogger(loggerFactory, new StubUserContext("tenant-002"), requestContext); logger.OnExit(CreateJoinPoint(), requestId: string.Empty, duration: 42L); @@ -71,10 +67,20 @@ public void OnExit_ShouldKeepSessionAndTraceFields() var entry = provider.Entries.Single(); entry.Properties["TenantId"].Should().Be("tenant-002"); - entry.Properties["CorrelationId"].Should().Be("corr-002"); entry.Properties["SessionTrackingId"].Should().Be("session-002"); - entry.Properties["TraceId"].Should().Be("trace-002"); - entry.Properties["SpanId"].Should().Be("span-002"); + entry.Properties["TraceId"].Should().Be(activity.TraceId.ToString()); + entry.Properties["SpanId"].Should().Be(activity.SpanId.ToString()); + entry.Properties["CorrelationId"].Should().Be(activity.TraceId.ToString()); + } + + private static Activity StartW3CActivity() + { + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = true; + var activity = new Activity("test-request"); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + return activity; } private static IJoinPoint CreateJoinPoint() @@ -143,7 +149,7 @@ private sealed class StubUserContext(string? tenantId) : IUserContext public string? TenantId => tenantId; public bool IsAuthenticated => true; - + public bool HasPermission(string permission) => true; } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Presentation/DomainErrorStatusMapperTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Presentation/DomainErrorStatusMapperTests.cs new file mode 100644 index 00000000..bee32fa0 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Presentation/DomainErrorStatusMapperTests.cs @@ -0,0 +1,89 @@ +using Ums.Domain.Kernel; +using Ums.Presentation.Extensions; + +namespace Ums.Presentation.IntegrationTest.Presentation; + +/// +/// G-104 (patrón latente de G-100). Fija, sobre la función pura , +/// la decisión caso-por-caso de la semántica HTTP de los códigos de dominio *_not_found: +/// +/// +/// 404 cuando el código direcciona un recurso por id/clave que no existe +/// (recurso apuntado por la URI/segmento de ruta ausente). +/// 400 cuando el código NO direcciona el recurso de la URI, sino que valida +/// una referencia colgante en el CUERPO de un POST de creación (integridad referencial del payload: +/// un FallbackToId/parentResourceId inexistente). El recurso objetivo del POST sí +/// existe/se crea; forzar 404 sería incorrecto. +/// +/// +/// El mapeador es la ÚNICA pieza que cambia el mapeo código→status; probarlo directamente es la +/// evidencia más fiel y determinista del cambio (SD-05), sin depender del arranque del host. +/// +public sealed class DomainErrorStatusMapperTests +{ + // ── 404: «no encontrado por id/clave» ──────────────────────────────────── + // Los tres primeros son los que G-104 cablea; el resto son regresión (G-100 y previos). + [Theory] + [InlineData("configuration.criteria_not_found")] // DomainErrors.Configuration.CriteriaNotFound (G-104) + [InlineData("user_account.mfa_enrollment_not_found")] // DomainErrors.UserAccount.MfaEnrollmentNotFound (G-104) + [InlineData("tenant_parameter.not_found")] // DomainErrors.TenantParameter.NotFound (G-104) + [InlineData("iga.role_promotion_request_not_found")] // DomainErrors.IGA.RolePromotionRequestNotFound (G-100) + [InlineData("common.not_found")] // DomainErrors.Common.NotFound + [InlineData("system_suite.configuration_key_not_found")] // DomainErrors.SystemSuite.ConfigurationKeyNotFound + public void Map_NotFoundByKeyDomainCodes_ShouldReturn404(string errorCode) + { + var (status, _) = DomainErrorStatusMapper.Map(errorCode); + + status.Should().Be(404, "un código de dominio que direcciona un recurso ausente por id/clave debe resolver 404 Not Found"); + } + + // ── 400: referencia colgante en el cuerpo (NO 404) ─────────────────────── + // Excluidos deliberadamente del brazo 404: validan integridad referencial del payload de un POST. + [Theory] + [InlineData("configuration.idp_fallback_not_found")] // DomainErrors.Configuration.IdpFallbackNotFound + [InlineData("authorization.parent_resource_not_found")] // DomainErrors.Authorization.ParentResourceNotFound + public void Map_DanglingReferenceInBodyDomainCodes_ShouldReturn400_NotFound(string errorCode) + { + var (status, _) = DomainErrorStatusMapper.Map(errorCode); + + status.Should().Be(400, "una referencia colgante en el cuerpo de un POST de creación es validación del payload (400), no un recurso de URI ausente (404)"); + } + + // ── 409: guardias de cascada y estados terminales del borrado lógico ───── + // Solo existe borrado lógico. Intentar eliminar algo con referencias VIVAS —o volver a eliminar + // lo ya eliminado— es un conflicto con el estado actual del recurso, no una validación de payload. + // El TenantParameter no tiene endpoint DELETE hoy; el mapeo se fija aquí para que el 409 sea el + // contrato desde el primer día en que se cablee, y no un 400 heredado del brazo por defecto. + [Theory] + [InlineData("TEMPLATE_HAS_ACTIVE_PROFILES")] // DomainErrors.Authorization.TemplateHasActiveProfiles + [InlineData("authorization.template_already_deleted")] // DomainErrors.Authorization.TemplateAlreadyDeleted + [InlineData("TENANT_PARAMETER_HAS_ACTIVE_BINDING")] // DomainErrors.TenantParameter.HasActiveBinding + [InlineData("tenant_parameter.already_deleted")] // DomainErrors.TenantParameter.AlreadyDeleted + public void Map_SoftDeleteGuardCodes_ShouldReturn409(string errorCode) + { + var (status, _) = DomainErrorStatusMapper.Map(errorCode); + + status.Should().Be(409, "una referencia viva que impide el borrado lógico es un conflicto con el estado del recurso"); + } + + // ── Anclas explícitas: los símbolos coinciden con las constantes de dominio ─ + // Evita que un renombrado del literal en DomainErrors deje huérfano el mapeo probado arriba. + [Fact] + public void SoftDeleteGuardConstants_ShouldMatchTheStringsUnderTest() + { + DomainErrors.Authorization.TemplateHasActiveProfiles.Should().Be("TEMPLATE_HAS_ACTIVE_PROFILES"); + DomainErrors.Authorization.TemplateAlreadyDeleted.Should().Be("authorization.template_already_deleted"); + DomainErrors.TenantParameter.HasActiveBinding.Should().Be("TENANT_PARAMETER_HAS_ACTIVE_BINDING"); + DomainErrors.TenantParameter.AlreadyDeleted.Should().Be("tenant_parameter.already_deleted"); + } + + [Fact] + public void DomainErrorConstants_ShouldMatchTheStringsUnderTest() + { + DomainErrors.Configuration.CriteriaNotFound.Should().Be("configuration.criteria_not_found"); + DomainErrors.UserAccount.MfaEnrollmentNotFound.Should().Be("user_account.mfa_enrollment_not_found"); + DomainErrors.TenantParameter.NotFound.Should().Be("tenant_parameter.not_found"); + DomainErrors.Configuration.IdpFallbackNotFound.Should().Be("configuration.idp_fallback_not_found"); + DomainErrors.Authorization.ParentResourceNotFound.Should().Be("authorization.parent_resource_not_found"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/OutboxBusUnavailabilityIntegrationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/OutboxBusUnavailabilityIntegrationTests.cs new file mode 100644 index 00000000..193ef440 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/OutboxBusUnavailabilityIntegrationTests.cs @@ -0,0 +1,252 @@ +using MassTransit; +using MassTransit.EntityFrameworkCoreIntegration; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Ums.Application.Common.Interfaces; +using Ums.Domain.Events; +using Ums.Infrastructure.Persistence; +using Ums.Infrastructure.Persistence.Configuration.Entities; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Reliability; + +/// +/// G-003 (sub-caso «corte del bus / backpressure»): prueba de FALLO del Transactional Outbox de +/// MassTransit cuando el bróker / servicio de entrega está INDISPONIBLE. +/// +/// Verifica el invariante de resiliencia del outbox transaccional (ADR-0098 D4/D7, G-066): +/// 1. Con el bus CAÍDO, publicar un evento de integración dentro de la transacción del agregado +/// ESTACIONA el mensaje en el outbox (tabla OutboxMessage) y NO se pierde. +/// 2. El cambio del agregado quedó CONSISTENTE (commit atómico: agregado + fila de outbox). +/// 3. Cuando el bus VUELVE, el servicio de entrega (BusOutboxDeliveryService) drena el +/// outbox: el mensaje se entrega EXACTAMENTE una vez al consumidor y la fila del outbox +/// desaparece. +/// +/// A diferencia de IntegrationEventOutboxDispatchTests (dobles en memoria, nivel aplicación), +/// esta prueba ejercita el MECANISMO REAL: EF Core bus-outbox (AddEntityFrameworkOutbox + +/// UseBusOutbox) sobre un PostgreSQL de Testcontainers, con las tablas +/// OutboxMessage/OutboxState reales. La «indisponibilidad del bus» se modela NO +/// arrancando el host (el transporte y el servicio de entrega quedan detenidos); la «vuelta del bus» +/// se modela arrancándolo. +/// +[Collection("PostgreSql")] +public sealed class OutboxBusUnavailabilityIntegrationTests +{ + private static readonly Guid SeededTenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + + private readonly PostgreSqlContainerFixture _fixture; + + public OutboxBusUnavailabilityIntegrationTests(PostgreSqlContainerFixture fixture) + => _fixture = fixture; + + [Fact] + public async Task BusDown_OutboxRetainsMessage_AndDeliversWhenBusReturns() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var capture = new IntegrationEventCapture(); + + using var host = BuildHost(_fixture.ConnectionString, capture); + + // Línea base limpia del outbox (la colección PostgreSql es secuencial; otras pruebas truncan + // al crear su host, así que partimos de una pizarra conocida para este contenedor). + await CleanOutboxAsync(host.Services, ct); + + var configId = Guid.NewGuid(); + var evt = new RolePromotionExecutedIntegrationEvent( + SeededTenantId, RequestId: Guid.NewGuid(), TargetUserId: Guid.NewGuid(), + CurrentRoleId: Guid.NewGuid(), TargetRoleId: Guid.NewGuid(), ExecutorId: Guid.NewGuid()); + + // ── Fase 1: BUS CAÍDO (host sin arrancar → sin transporte ni servicio de entrega) ─────────── + // Publicar dentro de la transacción del agregado: el evento se estaciona en el outbox junto al + // cambio del agregado y ambos se confirman atómicamente al guardar. + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var publishEndpoint = scope.ServiceProvider.GetRequiredService(); + + db.Set().Add(NewConfigRecord(configId)); + await publishEndpoint.Publish(evt, ct); + await db.SaveChangesAsync(ct); + } + + // (1) El mensaje quedó RETENIDO en el outbox (no se perdió pese a no haber bus). + // (2) El agregado quedó CONSISTENTE (commit atómico agregado + fila de outbox). + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + (await db.Set().CountAsync(ct)).Should().BeGreaterThan(0, + "con el bus caído el evento debe permanecer estacionado en el outbox — no se pierde"); + (await db.Set().AnyAsync(r => r.Id == configId, ct)).Should().BeTrue( + "el cambio del agregado se confirmó atómicamente con la fila del outbox (consistencia)"); + } + + // (3) Con el bus caído no puede haberse entregado nada al consumidor. + capture.Count.Should().Be(0, "con el bus caído no puede haberse entregado nada al consumidor"); + + // ── Fase 2: EL BUS VUELVE (se arranca el host → transporte + BusOutboxDeliveryService) ─────── + await host.StartAsync(ct); + try + { + // El servicio de entrega drena el outbox y publica al transporte; el consumidor lo recibe. + await WaitUntilAsync( + async () => capture.Count >= 1 && await OutboxMessageCountAsync(host.Services, ct) == 0, + timeout: TimeSpan.FromSeconds(60), ct); + + capture.Count.Should().Be(1, + "al volver el bus, el outbox entrega el evento EXACTAMENTE una vez (sin pérdida ni duplicado)"); + capture.Last.Should().NotBeNull(); + capture.Last!.RequestId.Should().Be(evt.RequestId, + "el evento entregado es el mismo que se estacionó con el cambio del agregado"); + + (await OutboxMessageCountAsync(host.Services, ct)).Should().Be(0, + "tras la entrega el outbox queda drenado"); + + // El agregado sigue presente e íntegro tras la entrega. + using var scope = host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Set().AnyAsync(r => r.Id == configId, ct)).Should().BeTrue( + "la entrega del evento no altera el estado del agregado ya confirmado"); + } + finally + { + await host.StopAsync(ct); + } + } + + // ── Infraestructura del test ──────────────────────────────────────────────────────────────────── + + private static IHost BuildHost(string connectionString, IntegrationEventCapture capture) => + Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddSingleton(capture); + services.AddScoped(); + services.AddScoped(); + + services.AddDbContext(o => + o.UseNpgsql(connectionString, sql => sql.EnableRetryOnFailure(3))); + + services.AddMassTransit(x => + { + x.AddConsumer(); + + // Bus-outbox EF real: los mensajes publicados por IPublishEndpoint se estacionan en el + // outbox con el cambio del agregado y se entregan POST-commit (UseBusOutbox()). + x.AddEntityFrameworkOutbox(o => + { + o.UsePostgres(); + o.QueryDelay = TimeSpan.FromSeconds(1); // barrido rápido del servicio de entrega + o.UseBusOutbox(); + }); + + x.UsingInMemory((context, cfg) => cfg.ConfigureEndpoints(context)); + }); + }) + .Build(); + + private static AppConfigurationRecord NewConfigRecord(Guid id) => new() + { + Id = id, + TenantId = SeededTenantId, + SystemSuiteId = Guid.NewGuid(), + ModuleId = Guid.NewGuid(), + Code = $"OUTBOX_G003_{id:N}"[..40], + Value = "outbox-bus-down", + Description = "Agregado sembrado por OutboxBusUnavailabilityIntegrationTests (G-003).", + ScopeId = 1, + IsInheritable = false, + IsEncrypted = false, + IsNonOverridable = false, + Version = "1", + StatusId = 1, + CreatedBy = "00000000-0000-0000-0000-000000000123", + CreatedAtUtc = DateTime.UtcNow, + AuditTimeSpan = string.Empty, + // RowVersion la genera PostgreSQL en el INSERT (gen_random_bytes(8), ValueGenerated.OnAdd). + }; + + private static async Task CleanOutboxAsync(IServiceProvider services, CancellationToken ct) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Orden por las FKs: OutboxMessage → OutboxState / InboxState. + await db.Set().ExecuteDeleteAsync(ct); + await db.Set().ExecuteDeleteAsync(ct); + await db.Set().ExecuteDeleteAsync(ct); + } + + private static async Task OutboxMessageCountAsync(IServiceProvider services, CancellationToken ct) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Set().CountAsync(ct); + } + + private static async Task WaitUntilAsync(Func> condition, TimeSpan timeout, CancellationToken ct) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (await condition()) + return; + await Task.Delay(250, ct); + } + // Última evaluación: si expira, falla con el estado real observado. + (await condition()).Should().BeTrue( + "el outbox debió entregar el evento y drenarse dentro del tiempo límite tras volver el bus"); + } + + private sealed class IntegrationEventCapture + { + private int _count; + public int Count => Volatile.Read(ref _count); + public RolePromotionExecutedIntegrationEvent? Last { get; private set; } + + public void Record(RolePromotionExecutedIntegrationEvent evt) + { + Last = evt; + Interlocked.Increment(ref _count); + } + } + + private sealed class CapturingConsumer(IntegrationEventCapture capture) + : IConsumer + { + public Task Consume(ConsumeContext context) + { + capture.Record(context.Message); + return Task.CompletedTask; + } + } + + private sealed class NoOpPublisher : IPublisher + { + public Task Publish(object notification, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification => Task.CompletedTask; + } + + private sealed class SystemTenantContext : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/ReliabilityIntegrationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/ReliabilityIntegrationTests.cs index bdb935a1..6195be5e 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/ReliabilityIntegrationTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/ReliabilityIntegrationTests.cs @@ -315,13 +315,81 @@ public async Task T18_DuplicateRapidPost_BothSucceed_DocumentsIdempotencyGap() idA.Should().NotBe(idB, "RISK-05: two distinct resources were created — idempotency enforcement would return same id for same Idempotency-Key"); - // DESIRED behaviour after FIX-06 (IdempotencyMiddleware): - // When both requests carry the same X-Idempotency-Key header, - // the second response should return the cached first response body, - // and both ids should be equal: - // - // idA.Should().Be(idB, - // "after FIX-06: repeated requests with same Idempotency-Key return the same resource"); + // La conducta DESEADA tras FIX-06 (repetir la MISMA Idempotency-Key devuelve la respuesta + // cacheada, mismo id, sin doble efecto) ya NO es un gap: se verifica activamente en + // T18b (G-003). Este T18 conserva el escenario base «sin clave ⇒ sin deduplicación». + } + + // ========================================================================= + // T18b — [G-003] Idempotencia bajo reintento: misma Idempotency-Key → sin doble efecto + // ========================================================================= + + /// + /// T18b — Idempotencia VERIFICADA (cierra el gap que T18 solo documentaba — G-003 / RISK-05 / FIX-06). + /// + /// Simula el reintento de un cliente que no recibió la respuesta del primer POST (timeout, corte de + /// red) y reenvía la MISMA petición con la MISMA Idempotency-Key. El + /// debe: + /// · devolver la respuesta CACHEADA del primer intento (mismo 201, mismo id), + /// · marcar la repetición con X-Idempotency-Replayed: true, + /// · NO re-ejecutar el manejador → NO crea un segundo recurso (efecto único), y en particular + /// NO devuelve 409 pese a repetir el mismo (scope, code) —el reintento se resuelve desde caché + /// ANTES de llegar a la regla de unicidad de dominio—. + /// + /// Es la garantía de resiliencia «reintento seguro»: reprocesar el mismo mensaje/petición no + /// duplica el efecto. + /// + [Fact] + public async Task T18b_RetryWithSameIdempotencyKey_ReturnsCachedResponse_NoDuplicateEffect() + { + var code = UniqueCode("t18b"); + var idempotencyKey = Guid.NewGuid().ToString(); + var client = BuildClientWithIdempotencyKey(idempotencyKey); + + // 1.er intento — crea el recurso y cachea la respuesta bajo la Idempotency-Key. + var first = await client.PostAsJsonAsync( + "/api/v1/app-configurations", BuildCreatePayload(code), + TestContext.Current.CancellationToken); + first.StatusCode.Should().Be(HttpStatusCode.Created); + + using var firstPayload = JsonDocument.Parse( + await first.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var idFirst = firstPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + // 2.º intento — MISMA clave, mismo cuerpo: el reintento del cliente. + var retry = await client.PostAsJsonAsync( + "/api/v1/app-configurations", BuildCreatePayload(code), + TestContext.Current.CancellationToken); + + // Se devuelve la respuesta cacheada del primer intento, NO un 409 por (scope, code) duplicado. + retry.StatusCode.Should().Be(HttpStatusCode.Created, + "el reintento con la misma Idempotency-Key reproduce la respuesta original, no re-ejecuta el manejador ni choca con la unicidad de dominio"); + retry.Headers.TryGetValues("X-Idempotency-Replayed", out var replayed).Should().BeTrue( + "el middleware marca la respuesta reproducida desde caché"); + replayed!.Should().ContainSingle().Which.Should().Be("true"); + + using var retryPayload = JsonDocument.Parse( + await retry.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var idRetry = retryPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + idRetry.Should().Be(idFirst, + "el reintento idempotente devuelve el MISMO recurso — sin doble creación"); + + // Efecto ÚNICO: existe exactamente un recurso con ese code (no se duplicó). + var listResponse = await _client.GetAsync( + "/api/v1/app-configurations?page=1&pageSize=200", + TestContext.Current.CancellationToken); + listResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var listPayload = JsonDocument.Parse( + await listResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var items = listPayload.RootElement.GetProperty("items"); + var matches = Enumerable.Range(0, items.GetArrayLength()) + .Count(i => string.Equals( + items[i].GetProperty("code").GetString(), + code.ToUpperInvariant(), + StringComparison.OrdinalIgnoreCase)); + matches.Should().Be(1, + "el reintento idempotente no debe crear un segundo recurso (efecto único bajo reintento)"); } // ========================================================================= @@ -403,6 +471,7 @@ public async Task T19_ConcurrentUpdates_BothSucceed_DocumentsOptimisticConcurren "updated-by-request-1", "updated-by-request-2", "RISK-02: one value survived but the other was silently lost — no conflict detected"); +#pragma warning disable S125 // Commented out code explains desired behaviour // DESIRED behaviour after FIX-03 (RowVersion/ETag): // The second concurrent PUT (without the updated ETag from the first response) // should return 409 Conflict: @@ -414,6 +483,7 @@ public async Task T19_ConcurrentUpdates_BothSucceed_DocumentsOptimisticConcurren // }; // conflictingStatuses.Should().Contain(HttpStatusCode.Conflict, // "after FIX-03: concurrent update without matching ETag must return 409"); +#pragma warning restore S125 } // ========================================================================= @@ -478,12 +548,14 @@ public async Task T20_TenantIsolation_GetAllWithoutFilter_ReturnsAllTenantsData_ "SESSION_TIMEOUT_MINUTES", "Seeded config for SeededTenantId is also visible — confirms cross-tenant leakage"); +#pragma warning disable S125 // Commented out code explains desired behaviour // DESIRED behaviour after FIX-05 (HasQueryFilter + mandatory tenant scope): // When no tenantId filter is provided, only the caller's own tenant's resources // should be returned (or a 400 Bad Request if tenantId is required): // // codes.Should().NotContain(otherTenantCode.ToUpperInvariant(), // "after FIX-05: cross-tenant resources must not appear in unfiltered query"); +#pragma warning restore S125 } // ========================================================================= @@ -502,6 +574,17 @@ private HttpClient BuildClient(string userId, string userName) return client; } + /// + /// Cliente autenticado que adjunta la misma Idempotency-Key en TODAS sus peticiones —modela + /// un cliente que reintenta la misma operación lógica bajo una única clave de idempotencia. + /// + private HttpClient BuildClientWithIdempotencyKey(string idempotencyKey) + { + var client = BuildClient(ActorUserId, ActorUserName); + client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + return client; + } + /// /// Produces a unique, deterministic code string per test run. /// Uses a short prefix + 8 hex chars to stay within domain code length limits. diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AutenticacionPorPortadorTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AutenticacionPorPortadorTests.cs new file mode 100644 index 00000000..81cc65c3 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AutenticacionPorPortadorTests.cs @@ -0,0 +1,284 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-191 — UMS valida sus PROPIOS tokens y la superficie de satélites autentica por portador. +/// +/// Antes, `GET /api/v1/client/graph` con un `Authorization: Bearer` legítimo respondía 302: en +/// desarrollo no existía manejador de portador y el esquema por defecto era la cookie, así que el +/// reto redirigía al formulario de acceso. Estas pruebas fijan el contrato que consume cualquier +/// sistema satélite: +/// +/// portador válido → 200 +/// sin portador → 401 (nunca 3xx) +/// portador inválido, caducado, de otra firma o de otro emisor → 401 +/// cookie `ums.session` → sigue autenticando al portal, y NO sirve para la superficie de satélite +/// +public sealed class AutenticacionPorPortadorTests : IClassFixture +{ + // El mismo secreto que inyecta UmsApiWebApplicationFactory: el host firma y valida con él. + private const string SecretoDePrueba = "INTEGRATION_TEST_JWT_SECRET_KEY_CHANGE_ME_MIN_32_CHARS"; + private const string EmisorDePrueba = "ums-api"; + private const string AudienciaDePrueba = "ums-web-app"; + + private const string InquilinoCliente = "COMEX_ANDINA"; + private const string UsuarioCliente = "usuario.impo@comexandina.com.pe"; + + private readonly UmsApiWebApplicationFactory _factory; + + public AutenticacionPorPortadorTests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ClientGraph_ConPortadorValido_Devuelve200YElGrafoVigente() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var token = await ObtenerPortadorAsync(cliente, ct); + + cliente.DefaultRequestHeaders.Authorization = new("Bearer", token); + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + var cuerpo = await respuesta.Content.ReadAsStringAsync(ct); + respuesta.StatusCode.Should().Be(HttpStatusCode.OK, + because: $"un satélite con portador válido debe recibir su grafo, no una redirección (G-191). Cuerpo: {cuerpo}"); + + using var payload = JsonDocument.Parse(cuerpo); + payload.RootElement.TryGetProperty("context", out _).Should().BeTrue( + "el grafo se reconstruye en el momento y trae su contexto de usuario e inquilino"); + } + + [Fact] + public async Task ClientGraph_SinPortador_Devuelve401YNoRedirige() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "un cliente de API no sigue un `Location`: la ausencia de credencial es 401, nunca 302"); + respuesta.Headers.Location.Should().BeNull("ninguna ruta /api/** puede responder con redirección"); + } + + [Fact] + public async Task ClientGraph_ConPortadorMalformado_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", "no-es-un-jwt"); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ClientGraph_ConLaFirmaAlterada_Devuelve401() + { + // El token es legítimo salvo por la firma: prueba que la comprobación criptográfica + // ocurre de verdad y no basta con presentar algo con forma de JWT. + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var token = await ObtenerPortadorAsync(cliente, ct); + + cliente.DefaultRequestHeaders.Authorization = new("Bearer", token + "xyz"); + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ClientGraph_ConPortadorCaducado_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", ForjarToken( + SecretoDePrueba, EmisorDePrueba, DateTime.UtcNow.AddHours(-1))); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "la vida del token se valida: un portador caducado no sirve aunque su firma sea correcta"); + } + + [Fact] + public async Task ClientGraph_ConPortadorFirmadoConOtroSecreto_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", ForjarToken( + "OTRO_SECRETO_QUE_NO_ES_EL_DE_UMS_Y_MIDE_MAS_DE_32", EmisorDePrueba, DateTime.UtcNow.AddHours(1))); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "UMS valida la firma HS256 con su propio secreto: un token ajeno no entra"); + } + + [Fact] + public async Task ClientGraph_ConPortadorDeOtroEmisor_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", ForjarToken( + SecretoDePrueba, "emisor-ajeno", DateTime.UtcNow.AddHours(1))); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "el emisor se valida: un token con `iss` distinto no es de esta instalación"); + } + + [Fact] + public async Task ClientGraph_ConSoloLaCookieDelPortal_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var cookie = await IniciarSesionWebAsync(cliente, ct); + + cliente.DefaultRequestHeaders.Remove("Cookie"); + cliente.DefaultRequestHeaders.Add("Cookie", cookie); + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "la política `Satelite` fija el esquema portador: la sesión de navegador no habilita la superficie servidor a servidor"); + respuesta.Headers.Location.Should().BeNull("tampoco con cookie puede redirigir una ruta /api/**"); + } + + [Fact] + public async Task Session_ConPortador_Devuelve200YNoRedirige() + { + // G-187 (parte del 302): preguntar «¿esta sesión sigue viva?» con portador ya no redirige. + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var token = await ObtenerPortadorAsync(cliente, ct); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", token); + + var respuesta = await cliente.GetAsync("/api/v1/auth/session", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK); + using var payload = JsonDocument.Parse(await respuesta.Content.ReadAsStringAsync(ct)); + payload.RootElement.GetProperty("tenantCode").GetString().Should().Be(InquilinoCliente); + } + + [Fact] + public async Task Session_SinCredencial_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + + var respuesta = await cliente.GetAsync("/api/v1/auth/session", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + respuesta.Headers.Location.Should().BeNull(); + } + + [Fact] + public async Task PortalWeb_LoginYSesionPorCookie_SiguenFuncionando() + { + // El portal no debe notar ningún cambio: la cookie `ums.session` sigue siendo su credencial. + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var cookie = await IniciarSesionWebAsync(cliente, ct); + + cliente.DefaultRequestHeaders.Remove("Cookie"); + cliente.DefaultRequestHeaders.Add("Cookie", cookie); + + var sesion = await cliente.GetAsync("/api/v1/auth/session", ct); + sesion.StatusCode.Should().Be(HttpStatusCode.OK, + "la sesión de cookie del portal debe seguir resolviéndose por el esquema de cookie"); + + using var payload = JsonDocument.Parse(await sesion.Content.ReadAsStringAsync(ct)); + payload.RootElement.GetProperty("tenantCode").GetString().Should().Be(InquilinoCliente); + payload.RootElement.GetProperty("email").GetString().Should().Be(UsuarioCliente); + } + + [Fact] + public async Task EndpointDeNegocio_ConPortadorInvalido_NoCaeEnLasClaimsDeDesarrollo() + { + // El host de pruebas corre en «Development», donde DevAuthMiddleware inyecta identidad de + // conveniencia. Si esa inyección tapara el rechazo del portador, un token falsificado + // acabaría respondiendo 200: la exención por cabecera `Authorization` lo impide (G-191). + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", ForjarToken( + "OTRO_SECRETO_QUE_NO_ES_EL_DE_UMS_Y_MIDE_MAS_DE_32", EmisorDePrueba, DateTime.UtcNow.AddHours(1))); + + var respuesta = await cliente.GetAsync("/api/v1/user-accounts?page=1&pageSize=1", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + // ── Auxiliares ─────────────────────────────────────────────────────────────── + + private HttpClient CrearCliente() => _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + // Sin seguir redirecciones: si algo respondiera 302, la prueba tiene que verlo. + AllowAutoRedirect = false, + HandleCookies = false, + }); + + private static async Task ObtenerPortadorAsync(HttpClient cliente, CancellationToken ct) + { + var respuesta = await cliente.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode = InquilinoCliente, + username = UsuarioCliente, + password = CoreDevDataSeeder.BeyondNetDevPassword, + }, ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK); + using var payload = JsonDocument.Parse(await respuesta.Content.ReadAsStringAsync(ct)); + var token = payload.RootElement.GetProperty("token").GetString(); + token.Should().NotBeNullOrWhiteSpace(); + return token!; + } + + private static async Task IniciarSesionWebAsync(HttpClient cliente, CancellationToken ct) + { + var respuesta = await cliente.PostAsJsonAsync("/api/v1/auth/login", new + { + tenantCode = InquilinoCliente, + username = UsuarioCliente, + password = CoreDevDataSeeder.BeyondNetDevPassword, + rememberMe = false, + }, ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK, "el login del portal debe seguir intacto"); + var setCookie = respuesta.Headers.GetValues("Set-Cookie").FirstOrDefault(); + setCookie.Should().NotBeNullOrEmpty("el portal se autentica con la cookie ums.session"); + setCookie.Should().Contain("ums.session"); + return setCookie!.Split(';')[0]; + } + + private static string ForjarToken(string secreto, string emisor, DateTime expiraEnUtc) + { + var credenciales = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secreto)), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: emisor, + audience: AudienciaDePrueba, + claims: + [ + new Claim(JwtRegisteredClaimNames.Sub, UsuarioCliente), + new Claim(JwtRegisteredClaimNames.Email, UsuarioCliente), + new Claim("tenant_code", InquilinoCliente), + ], + notBefore: expiraEnUtc.AddHours(-1), + expires: expiraEnUtc, + signingCredentials: credenciales); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AuthenticationFlowTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AuthenticationFlowTests.cs index eb0ad6fd..618750d6 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AuthenticationFlowTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AuthenticationFlowTests.cs @@ -1,19 +1,31 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity.Auth; using Ums.Infrastructure.Persistence.Seeders; using Ums.Presentation.IntegrationTest.Infrastructure; namespace Ums.Presentation.IntegrationTest.Security; +/// +/// Pruebas del refresh deslizante por cookie de sesión (POST /api/v1/auth/refresh). +/// +/// D-019 / ADR-UMS-091: el refresh por cookie debe espejar el login — regenerar el grafo +/// de autorización vigente y emitir un graph JWT que porte el modelo de permisos actual —, en +/// vez de re-firmar un JWT con permisos vacíos. Estas pruebas endurecen el contrato para exigir +/// que el token/respuesta refrescados lleven el conjunto de permisos vigente (no vacío) y que un +/// cambio de permisos aplicado tras el login se refleje en la renovación. +/// public sealed class AuthenticationFlowTests : IClassFixture { + private readonly UmsApiWebApplicationFactory _factory; private readonly HttpClient _client; public AuthenticationFlowTests(UmsApiWebApplicationFactory factory) { + _factory = factory; _client = factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("https://localhost"), @@ -24,41 +36,170 @@ public AuthenticationFlowTests(UmsApiWebApplicationFactory factory) [Fact] public async Task RefreshToken_WithValidSessionCookie_ShouldReturnNewToken() { - // 1. Iniciar sesión y capturar la cookie de sesión + var ct = TestContext.Current.CancellationToken; + + // 1. Iniciar sesión, capturar la cookie de sesión y los permisos del login. + var login = await LoginAsync(ct); + login.Permissions.Should().NotBeEmpty( + "el usuario de prueba debe tener permisos para que la aserción de 'permisos vigentes' sea significativa"); + + // 2. Refresh usando la cookie ([RequireAuthorization] valida la cookie). + var refreshResponse = await _client.PostAsync("/api/v1/auth/refresh", null, ct); + refreshResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var refreshPayload = JsonDocument.Parse(await refreshResponse.Content.ReadAsStringAsync(ct)); + var newToken = refreshPayload.RootElement.GetProperty("token").GetString(); + + // 3. El token refrescado es nuevo… + newToken.Should().NotBeNullOrEmpty(); + newToken.Should().NotBe(login.Token, "El refresh debe generar un JWT completamente nuevo."); + + // 4. …y —el fix real de D-019— porta el modelo de permisos VIGENTE, no un grafo vacío. + var refreshPermissions = ReadPermissions(refreshPayload.RootElement); + refreshPermissions.Should().NotBeEmpty( + "el token refrescado debe portar el conjunto de permisos vigente, no Array.Empty"); + refreshPermissions.Should().BeEquivalentTo(login.Permissions, + "el refresh espeja el login: el mismo grafo vigente ⇒ el mismo conjunto de permisos"); + + // 5. El token en sí (no solo el cuerpo) sigue siendo un graph JWT como el del login. + // + // G-172 cambió QUÉ lleva ese token: los claims `perm` —uno por cada par opción-acción, + // 16-24 KB en la cabecera de cada petición— se retiraron; el grafo viaja en el cuerpo y el + // cliente lo cachea. Lo que la autorización del servidor lee son los `scope`, así que es + // sobre ellos, y no sobre `perm`, sobre lo que hay que exigir contenido vigente. + using var tokenClaims = DecodeJwtPayload(newToken!); + tokenClaims.RootElement.TryGetProperty("scope", out var scopeClaims).Should().BeTrue( + "el JWT refrescado debe embeber los ámbitos vigentes (graph JWT), igual que el login"); + CountClaimValues(scopeClaims).Should().BeGreaterThan(0); + tokenClaims.RootElement.TryGetProperty("graph_valid_until", out _).Should().BeTrue( + "el JWT refrescado debe ser un graph JWT (espejo del login), no el JWT plano heredado"); + } + + [Fact] + public async Task RefreshToken_AfterPermissionChange_ReflectsCurrentPermissions_NotLoginSnapshot() + { + var ct = TestContext.Current.CancellationToken; + + // 1. Login: capturamos el conjunto de permisos vigente en ese momento. + var login = await LoginAsync(ct); + login.Permissions.Length.Should().BeGreaterThan(1, + "el usuario de prueba debe tener ≥2 permisos para revocar uno y conservar el resto"); + + // 2. Reconstruimos el grafo vigente EN PROCESO para localizar una opción Allow y su acción, + // y así saber qué permiso concreto revocar (los ids de nodo no viajan en el JSON). + using var scope = _factory.Services.CreateScope(); + var userRepo = scope.ServiceProvider.GetRequiredService(); + var profileRepo = scope.ServiceProvider.GetRequiredService(); + var graphBuilder = scope.ServiceProvider.GetRequiredService(); + + var user = await userRepo.GetByIdAsync(login.UserId, ct); + user.Should().NotBeNull(); + + var graphResult = await graphBuilder.BuildAsync(user!, login.TenantId, AuthMethod.Local(), systemCode: null, ct); + graphResult.IsSuccess.Should().BeTrue(); + var graph = graphResult.Value; + + // G-171: la navegación dejó de ser la cadena rígida Módulo→Menú→Submenú→Opción y pasó a ser + // un árbol de profundidad libre; el recorrido canónico es `GraphNavigation`. Esta prueba + // seguía con los tres bucles literales y dejó de compilar con el cambio de modelo. + var allowNode = GraphNavigation.AllNodes(graph) + .First(n => n.Actions.Any(a => a.Effect == AccessEffect.Allow)); + var allowAction = allowNode.Actions.First(a => a.Effect == AccessEffect.Allow); + var actionId = graph.Actions.First(a => a.Code == allowAction.ActionCode).Id; + var revokedPermission = $"{allowNode.Code}:{allowAction.ActionCode}"; + login.Permissions.Should().Contain(revokedPermission); + + // 3. Aplicamos un CAMBIO DE PERMISOS al principal: revocamos ese permiso en su perfil activo. + var profiles = await profileRepo.GetByUserIdAsync(login.UserId, ct); + var profile = profiles.First(p => p.Props.TenantId.GetValue() == login.TenantId && p.IsActive); + var permission = profile.Permissions.First(p => + p.IsActive && p.IsAllowed && !p.IsDenied && + p.TargetId.GetValue() == allowNode.Id && + p.ActionId.GetValue() == actionId); + // El aggregate localiza el permiso por su Id canónico (Props.Id == GetId()), no por el Id + // base de Entity<> (que se regenera aleatorio en construcción) — G-116. + var deny = profile.OverridePermissionDeny(permission.GetId(), ActorId.Create("integration-test")); + deny.IsSuccess.Should().BeTrue(); + await profileRepo.UpdateAsync(profile, ct); + + // 4. Refresh con la misma cookie. + var refreshResponse = await _client.PostAsync("/api/v1/auth/refresh", null, ct); + refreshResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var refreshPayload = JsonDocument.Parse(await refreshResponse.Content.ReadAsStringAsync(ct)); + var refreshPermissions = ReadPermissions(refreshPayload.RootElement); + + // 5. El refresh regenera el grafo: refleja los permisos ACTUALES, no la foto del login. + refreshPermissions.Should().NotBeEmpty( + "el token refrescado debe portar el modelo de permisos vigente, no un grafo vacío"); + refreshPermissions.Should().NotContain(revokedPermission, + "el permiso revocado tras el login NO debe seguir presente en el token refrescado"); + refreshPermissions.Length.Should().Be(login.Permissions.Length - 1, + "exactamente el permiso revocado debe desaparecer del conjunto vigente"); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private sealed record LoginOutcome(Guid UserId, Guid TenantId, string? Token, string[] Permissions); + + private async Task LoginAsync(CancellationToken ct) + { var loginResponse = await _client.PostAsJsonAsync("/api/v1/auth/login", new { tenantCode = "RANSA_PERU", username = "gerente.operaciones@ransa.pe", password = CoreDevDataSeeder.SuperAdminPassword, rememberMe = false, - }, TestContext.Current.CancellationToken); + }, ct); loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); - // Capturar las cookies (la CookieAuthentication) var setCookieHeader = loginResponse.Headers.GetValues("Set-Cookie").FirstOrDefault(); setCookieHeader.Should().NotBeNullOrEmpty(); - - // 2. Extraer la cookie y configurar el HttpClient para la siguiente solicitud var cookieValue = setCookieHeader!.Split(';')[0]; + _client.DefaultRequestHeaders.Remove("Cookie"); _client.DefaultRequestHeaders.Add("Cookie", cookieValue); - // Opcional: obtener el Bearer Token para validar la respuesta original - using var payload = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - var initialToken = payload.RootElement.GetProperty("token").GetString(); - initialToken.Should().NotBeNullOrEmpty(); + using var payload = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync(ct)); + var root = payload.RootElement; + var token = root.GetProperty("token").GetString(); + token.Should().NotBeNullOrEmpty(); - // 3. Realizar el request de Refresh (usando la cookie) - // El endpoint /refresh requiere [RequireAuthorization], por lo que valida la cookie - var refreshResponse = await _client.PostAsync("/api/v1/auth/refresh", null, TestContext.Current.CancellationToken); - - refreshResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var userId = Guid.Parse(root.GetProperty("userId").GetString()!); + var tenantId = Guid.Parse(root.GetProperty("tenantId").GetString()!); + var permissions = ReadPermissions(root); - // 4. Validar el nuevo token generado - using var refreshPayload = JsonDocument.Parse(await refreshResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - var newToken = refreshPayload.RootElement.GetProperty("token").GetString(); - - newToken.Should().NotBeNullOrEmpty(); - newToken.Should().NotBe(initialToken, "El refresh token debe generar un JWT completamente nuevo."); + return new LoginOutcome(userId, tenantId, token, permissions); } + + private static string[] ReadPermissions(JsonElement root) + { + if (!root.TryGetProperty("permissions", out var permissions) || permissions.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + return permissions.EnumerateArray() + .Select(p => p.GetString() ?? string.Empty) + .Where(s => s.Length > 0) + .ToArray(); + } + + private static JsonDocument DecodeJwtPayload(string token) + { + var segments = token.Split('.'); + segments.Length.Should().BeGreaterThanOrEqualTo(2, "un JWT tiene header.payload.signature"); + var payload = segments[1]; + var padded = payload.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + var bytes = Convert.FromBase64String(padded); + return JsonDocument.Parse(bytes); + } + + private static int CountClaimValues(JsonElement claim) + => claim.ValueKind == JsonValueKind.Array ? claim.GetArrayLength() : 1; } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/CierreDeSesionPorDispositivoTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/CierreDeSesionPorDispositivoTests.cs new file mode 100644 index 00000000..4e7cb7d2 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/CierreDeSesionPorDispositivoTests.cs @@ -0,0 +1,105 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-247 — cerrar sesión cierra ESTE dispositivo, y solo este. +/// +/// Antes no cerraba nada: POST /auth/logout respondía 200 y la misma cookie seguía +/// devolviendo 200 en /auth/session. Se midió en vivo contra un despliegue de dos réplicas y +/// también contra una sola, así que no era un problema de reparto de carga: SignOutAsync +/// solo le pide al navegador que borre la cookie, y el portador seguía siendo válido hasta +/// caducar. +/// +/// La decisión de producto fue cerrar solo el dispositivo actual, no la cuenta entera. Estas +/// pruebas fijan las dos mitades de esa decisión —la que cierra y la que NO debe cerrar—, porque +/// una sola de ellas se puede satisfacer por accidente: revocar por usuario cerraría la sesión +/// actual igual de bien, y se llevaría por delante las demás. +/// +/// Las cookies se manejan a mano, no con un contenedor: el logout borra la del contenedor, y +/// lo que hay que comprobar es justo lo contrario —que una COPIA conservada del portador tampoco +/// sirva—. +/// +public sealed class CierreDeSesionPorDispositivoTests : IClassFixture +{ + private readonly UmsApiWebApplicationFactory _factory; + + public CierreDeSesionPorDispositivoTests(UmsApiWebApplicationFactory factory) => _factory = factory; + + private HttpClient NuevoCliente() => _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + + /// Inicia sesión y devuelve la cookie emitida: eso es «un dispositivo». + private async Task AbrirSesionAsync(CancellationToken ct) + { + var cliente = NuevoCliente(); + var respuesta = await cliente.PostAsJsonAsync("/api/v1/auth/login", new + { + tenantCode = "BEYONDNET", + username = "admin@beyondnet.com.pe", + password = CoreDevDataSeeder.BeyondNetDevPassword, + rememberMe = false, + }, ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK, + because: await respuesta.Content.ReadAsStringAsync(ct)); + + var cookie = respuesta.Headers.TryGetValues("Set-Cookie", out var valores) + ? valores.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("ums.session", StringComparison.Ordinal)) + : null; + + cookie.Should().NotBeNullOrWhiteSpace(because: "el login debe emitir la cookie de sesión"); + return cookie!; + } + + private HttpClient ClienteCon(string cookie) + { + var cliente = NuevoCliente(); + cliente.DefaultRequestHeaders.Add("Cookie", cookie); + return cliente; + } + + [Fact] + public async Task Cerrar_Sesion_Invalida_Una_Copia_Del_Portador() + { + var ct = TestContext.Current.CancellationToken; + var cookie = await AbrirSesionAsync(ct); + + (await ClienteCon(cookie).GetAsync("/api/v1/auth/session", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK, because: "la sesión recién abierta debe servir"); + + (await ClienteCon(cookie).PostAsync("/api/v1/auth/logout", content: null, ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + (await ClienteCon(cookie).GetAsync("/api/v1/auth/session", ct)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized, + because: "una copia del portador de una sesión cerrada no debe seguir abriendo la puerta"); + } + + [Fact] + public async Task Cerrar_Sesion_En_Un_Dispositivo_No_Cierra_El_Otro() + { + var ct = TestContext.Current.CancellationToken; + + // Mismo usuario, dos sesiones: el portátil y el móvil. + var portatil = await AbrirSesionAsync(ct); + var movil = await AbrirSesionAsync(ct); + portatil.Should().NotBe(movil, because: "cada login abre una sesión distinta"); + + (await ClienteCon(portatil).PostAsync("/api/v1/auth/logout", content: null, ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + // ESTA es la mitad que se rompe si alguien «simplifica» revocando por usuario. + (await ClienteCon(movil).GetAsync("/api/v1/auth/session", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK, + because: "cerrar sesión en el portátil no debe echar al mismo usuario de su móvil (G-247)"); + + // Y el portátil sigue cerrado: la comprobación anterior no vale si esta no se cumple. + (await ClienteCon(portatil).GetAsync("/api/v1/auth/session", ct)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ClientAuthenticationBehavioralTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ClientAuthenticationBehavioralTests.cs new file mode 100644 index 00000000..7a0e91e3 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ClientAuthenticationBehavioralTests.cs @@ -0,0 +1,90 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-042 — E2E conductual de autenticación de sistemas cliente (RoboSoft/SDK). +/// +/// Verifica que DevAuthMiddleware, activo en el host de desarrollo, ya NO rompe el punto de +/// integración documentado POST /api/v1/client/authenticate: los inquilinos CLIENT +/// (COMEX_ANDINA, AGRONORTE) autentican por su TenantCode sin que el contexto se fije a BEYONDNET. +/// +/// Además ancla el endurecimiento del bypass X-Disable-Dev-Auth: sin credencial real +/// deja de ser un pase anónimo (fail-closed 401), pero con credencial (cookie de sesión) sigue +/// permitiendo ejercer la autenticación real. +/// +public sealed class ClientAuthenticationBehavioralTests : IClassFixture +{ + private readonly UmsApiWebApplicationFactory _factory; + private readonly HttpClient _client; + + public ClientAuthenticationBehavioralTests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + } + + [Theory] + [InlineData("COMEX_ANDINA", "usuario.impo@comexandina.com.pe")] + [InlineData("AGRONORTE", "usuario.expo@agronorte.com.pe")] + public async Task ClientAuthenticate_WithSeededClientTenant_ReturnsTokenAndGraph(string tenantCode, string username) + { + var ct = TestContext.Current.CancellationToken; + + var response = await _client.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode, + username, + password = CoreDevDataSeeder.BeyondNetDevPassword, + }, ct); + + var body = await response.Content.ReadAsStringAsync(ct); + response.StatusCode.Should().Be(HttpStatusCode.OK, + because: $"el inquilino CLIENT {tenantCode} debe autenticar por su TenantCode y no colapsar a BEYONDNET (G-042). Cuerpo: {body}"); + + using var payload = JsonDocument.Parse(body); + payload.RootElement.GetProperty("token").GetString().Should().NotBeNullOrWhiteSpace(); + payload.RootElement.GetProperty("tokenType").GetString().Should().Be("Bearer"); + payload.RootElement.GetProperty("graph").GetString().Should().NotBeNull(); + } + + [Fact] + public async Task ClientAuthenticate_WithWrongPassword_ReturnsUnauthorized() + { + var ct = TestContext.Current.CancellationToken; + + var response = await _client.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode = "COMEX_ANDINA", + username = "usuario.impo@comexandina.com.pe", + password = "contraseña-incorrecta", + }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task DisableDevAuth_WithoutCredential_OnProtectedEndpoint_IsFailClosed401() + { + // G-042 (endurecimiento): X-Disable-Dev-Auth:true sin credencial real ya NO permite + // saltar la autenticación (antes → 200 anónimo). El middleware corta con 401. + var ct = TestContext.Current.CancellationToken; + + var anonymous = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + anonymous.DefaultRequestHeaders.Add("X-Disable-Dev-Auth", "true"); + + var response = await anonymous.GetAsync("/api/v1/user-accounts?page=1&pageSize=1", ct); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + because: "sin cabecera Authorization ni cookie ums.session, el bypass debe fallar cerrado"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ContratoDeClientAuthenticateTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ContratoDeClientAuthenticateTests.cs new file mode 100644 index 00000000..929669ff --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ContratoDeClientAuthenticateTests.cs @@ -0,0 +1,220 @@ +using System.Text.Json.Nodes; +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-207 — el sobre que devuelve POST /api/v1/client/authenticate, capturado de la API real. +/// +/// Los dos SDK de cliente tipaban graph como objeto. La API lo devuelve como +/// cadena serializada, porque el formato lo elige el inquilino (JSON, XML, YAML o CSV) y un +/// objeto no puede transportar XML. Consecuencia: parsed.graph?.schemaVersion daba +/// undefined, el cliente devolvía AUTH_204 y cualquier login a través del SDK +/// fallaba. Ningún SDK había ejercido nunca el endpoint real; sus pruebas usaban objetos +/// escritos a mano con la forma que el SDK creía correcta. +/// +/// Por eso este fichero no comprueba la forma contra una expectativa escrita a mano: la +/// captura de la API viva y la deja en un fixture que consumen las pruebas de los dos SDK. +/// Si la API cambia el sobre, el fixture deja de cuadrar y esta prueba falla — que es justo lo que +/// no ocurrió cuando el sobre y los SDK divergieron. +/// +public sealed class ContratoDeClientAuthenticateTests : IClassFixture +{ + private readonly HttpClient _client; + + public ContratoDeClientAuthenticateTests(UmsApiWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + } + + /// + /// Campos que cambian en cada ejecución, con el valor fijo que los sustituye. + /// + /// Sin fijarlos, el fixture cambiaría en cada corrida y la comprobación de deriva no + /// diría nada. El sustituto conserva el TIPO —una fecha sigue siendo una fecha— porque el + /// fixture no es una foto para mirar: lo deserializan los dos SDK, y un marcador en prosa donde + /// va una fecha los haría fallar por el fixture, no por el contrato. + /// + /// El token se sustituye además por higiene: un portador real firmado no tiene por qué + /// quedar publicado en el repositorio. + /// + private static readonly Dictionary CamposVolatiles = new() + { + ["token"] = "PORTADOR.FIJADO.EN.EL.FIXTURE", + ["issuedAt"] = "2026-01-01T00:00:00.0000000+00:00", + ["requestId"] = "0HN7QMV8KJLDT:00000001", + }; + + [Fact] + public async Task El_Sobre_De_Client_Authenticate_Coincide_Con_El_Fixture_Capturado() + { + var ct = TestContext.Current.CancellationToken; + + var response = await _client.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode = "COMEX_ANDINA", + username = "usuario.impo@comexandina.com.pe", + password = CoreDevDataSeeder.BeyondNetDevPassword, + }, ct); + + var cuerpo = await response.Content.ReadAsStringAsync(ct); + response.StatusCode.Should().Be(HttpStatusCode.OK, because: $"Cuerpo: {cuerpo}"); + + var capturado = Normalizar(cuerpo); + var ruta = RutaDelFixture(); + + if (!File.Exists(ruta)) + { + await File.WriteAllTextAsync(ruta, capturado, ct); + Assert.Fail($"No existía el fixture del sobre; se ha generado en {ruta}. Revísalo y publícalo."); + } + + var comprometido = await File.ReadAllTextAsync(ruta, ct); + capturado.Should().Be(comprometido, + because: "el sobre de /client/authenticate cambió respecto al fixture que consumen los SDK. " + + "Si el cambio es intencionado, borra el fichero y vuelve a ejecutar esta prueba para regenerarlo, " + + "y comprueba que los clientes de los dos SDK lo siguen entendiendo (G-207)."); + } + + /// + /// Comprobación explícita del hecho que rompía a los SDK, aparte de la comparación con el + /// fixture: un cambio de tipo aquí debe leerse en el nombre de la prueba que falla, no + /// deducirse de un diff de 10 000 caracteres. + /// + [Fact] + public async Task El_Grafo_Viaja_Como_Cadena_Serializada_No_Como_Objeto() + { + var ct = TestContext.Current.CancellationToken; + + var response = await _client.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode = "COMEX_ANDINA", + username = "usuario.impo@comexandina.com.pe", + password = CoreDevDataSeeder.BeyondNetDevPassword, + }, ct); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var grafo = payload.RootElement.GetProperty("graph"); + + grafo.ValueKind.Should().Be(JsonValueKind.String, + because: "el formato lo elige el inquilino (JSON/XML/YAML/CSV) y un objeto no puede transportar XML"); + + // `requestId` es el TraceIdentifier de ASP.NET Core (`0HN...:00000001`), no un GUID. El + // cliente .NET lo tipaba como Guid y reventaba al deserializar antes siquiera de mirar el + // grafo: dos defectos distintos que se manifestaban como el mismo fallo. + payload.RootElement.GetProperty("requestId").ValueKind.Should().Be(JsonValueKind.String); + Guid.TryParse(payload.RootElement.GetProperty("requestId").GetString(), out _) + .Should().BeFalse(because: "es un identificador de traza, no un GUID; tiparlo como Guid rompe el cliente"); + } + + /// + /// Lo volátil de DENTRO del grafo, que viaja serializado en el campo `graph`. + /// + /// Normalizar solo el sobre no basta: el grafo lleva cuatro marcas de tiempo y el + /// identificador del perfil, que cambian en cada ejecución (y el id, además, en cada + /// recreación de la base sembrada). Sin fijarlos, el fixture derivaba siempre y la + /// comprobación no distinguía un cambio de contrato de un simple reloj distinto. + /// + private static readonly Dictionary CamposVolatilesDelGrafo = new() + { + ["generatedAt"] = "2026-01-01T00:00:00.0000000Z", + // Lejano a propósito: un `validUntil` fijo en el pasado haría que cualquier consumidor del + // fixture lo rechazara por caducado, y el fixture existe para probar el contrato, no la + // caducidad — para eso está `expired-graph.json`. + ["validUntil"] = "2099-12-31T23:59:59.0000000Z", + ["issuedAt"] = "2026-01-01T00:00:00.0000000Z", + ["sessionExpiresAt"] = "2099-12-31T23:59:59.0000000Z", + ["id"] = "00000000-0000-0000-0000-000000000001", + }; + + /// Recorre el grafo y fija lo volátil, conservando el resto intacto. + private static string NormalizarGrafo(string json) + { + var raiz = JsonNode.Parse(json); + Recorrer(raiz); + + return raiz!.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + static void Recorrer(JsonNode? nodo) + { + switch (nodo) + { + case JsonObject obj: + foreach (var clave in obj.Select(par => par.Key).ToList()) + { + if (CamposVolatilesDelGrafo.TryGetValue(clave, out var fijo) && + obj[clave] is JsonValue) + { + obj[clave] = fijo; + } + else + { + Recorrer(obj[clave]); + } + } + break; + + case JsonArray arr: + foreach (var hijo in arr) Recorrer(hijo); + break; + } + } + } + + /// Sustituye lo volátil por un marcador fijo y reindenta, para que el diff sea legible. + private static string Normalizar(string cuerpo) + { + using var doc = JsonDocument.Parse(cuerpo); + var campos = new Dictionary(); + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (CamposVolatiles.TryGetValue(prop.Name, out var fijo)) + { + campos[prop.Name] = fijo; + } + else if (prop.Name == "graph") + { + campos[prop.Name] = NormalizarGrafo(prop.Value.GetString()!); + } + else + { + campos[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.GetInt64(), + _ => prop.Value.ToString() + }; + } + } + + return JsonSerializer.Serialize(campos, new JsonSerializerOptions + { + WriteIndented = true, + // Sin esto cada tilde y cada comilla del grafo salen como \uXXXX y el fixture es + // ilegible: un fixture que nadie puede leer no se revisa, se acepta. + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + } + + private static string RutaDelFixture() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + dir = dir.Parent; + + if (dir is null) throw new InvalidOperationException("No se encontró la raíz del repositorio."); + + return Path.Combine(dir.FullName, "src", "libs", "sdk", "contracts", "fixtures", + "client-authenticate.envelope.json"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/LimiteDePeticionesTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/LimiteDePeticionesTests.cs new file mode 100644 index 00000000..4c14b375 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/LimiteDePeticionesTests.cs @@ -0,0 +1,109 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-248 — el cupo de peticiones se cuenta por QUIEN llama, no por la IP de la que vienen todos. +/// +/// El limitador anterior corría antes de UseAuthentication, así que +/// HttpContext.User venía vacío y la clave de reparto caía siempre a la IP. Medido el +/// 2026-08-04 con el cupo en 6: tres peticiones de una cuenta y tres de otra bastaban para que +/// AMBAS recibieran 429. Detrás de un Ingress —donde la IP que ve el proceso es la del +/// controlador— eso significaba un único cubo para todo el tráfico. +/// +/// Estas pruebas comprueban las dos mitades: que el cupo se aplica, y que NO se comparte +/// entre usuarios distintos. La primera sola pasaría también con el limitador roto. +/// +public sealed class LimiteDePeticionesTests : IClassFixture +{ + private readonly UmsApiWebApplicationFactory _factory; + + public LimiteDePeticionesTests(UmsApiWebApplicationFactory factory) => _factory = factory; + + private HttpClient NuevoCliente() => _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + + private async Task AbrirSesionAsync(string tenantCode, string usuario, CancellationToken ct) + { + var respuesta = await NuevoCliente().PostAsJsonAsync("/api/v1/auth/login", new + { + tenantCode, + username = usuario, + password = CoreDevDataSeeder.BeyondNetDevPassword, + rememberMe = false, + }, ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK, + because: await respuesta.Content.ReadAsStringAsync(ct)); + + var cookie = respuesta.Headers.TryGetValues("Set-Cookie", out var valores) + ? valores.Select(v => v.Split(';')[0]) + .FirstOrDefault(v => v.StartsWith("ums.session", StringComparison.Ordinal)) + : null; + + cookie.Should().NotBeNullOrWhiteSpace(); + return cookie!; + } + + private HttpClient ClienteCon(string cookie) + { + var cliente = NuevoCliente(); + cliente.DefaultRequestHeaders.Add("Cookie", cookie); + return cliente; + } + + [Fact] + public async Task Dos_Usuarios_Distintos_No_Comparten_El_Cupo() + { + var ct = TestContext.Current.CancellationToken; + + // Dos cuentas de inquilinos distintos, desde la MISMA dirección: es el caso que el + // limitador anterior confundía en un solo cubo. + var uno = await AbrirSesionAsync("BEYONDNET", "admin@beyondnet.com.pe", ct); + var otro = await AbrirSesionAsync("COMEX_ANDINA", "usuario.impo@comexandina.com.pe", ct); + + // Se gastan bastantes peticiones con la PRIMERA cuenta. Con el cupo por defecto (100/min) + // no se llega a agotar; lo que se comprueba es que lo que gaste una no se le descuenta a la + // otra, que es la propiedad que estaba rota. + for (var i = 0; i < 40; i++) + { + var r = await ClienteCon(uno).GetAsync("/api/v1/auth/session", ct); + r.StatusCode.Should().NotBe(HttpStatusCode.TooManyRequests, + because: "40 peticiones no deben agotar un cupo de 100"); + } + + var deLaOtraCuenta = await ClienteCon(otro).GetAsync("/api/v1/auth/session", ct); + deLaOtraCuenta.StatusCode.Should().Be(HttpStatusCode.OK, + because: "lo que gasta una cuenta no puede descontarse del cupo de otra (G-248)"); + } + + [Fact] + public async Task El_Cupo_Se_Aplica_Y_Responde_429_Con_Retry_After() + { + var ct = TestContext.Current.CancellationToken; + + // Sin sesión: el cupo se cuenta por IP. Una cabecera `X-Forwarded-For` propia aísla esta + // prueba de las demás — si compartiera cubo con ellas, el resultado dependería del orden + // de ejecución, que es la clase de prueba que falla los martes. + var cliente = NuevoCliente(); + cliente.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.77"); + + HttpResponseMessage? rechazada = null; + for (var i = 0; i < 130 && rechazada is null; i++) + { + var r = await cliente.GetAsync("/api/v1/auth/session", ct); + if (r.StatusCode == HttpStatusCode.TooManyRequests) rechazada = r; + } + + rechazada.Should().NotBeNull(because: "pasado el cupo de 100 por ventana debe llegar un 429"); + rechazada!.Headers.RetryAfter.Should().NotBeNull( + because: "un 429 sin `Retry-After` obliga a quien llama a adivinar cuándo reintentar"); + + var cuerpo = await rechazada.Content.ReadAsStringAsync(ct); + cuerpo.Should().Contain("Too Many Requests"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/NormalizacionRedisTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/NormalizacionRedisTests.cs new file mode 100644 index 00000000..31303fd8 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/NormalizacionRedisTests.cs @@ -0,0 +1,49 @@ +namespace Ums.Presentation.IntegrationTest.Security; + +using FluentAssertions; +using Ums.Infrastructure.Configuration; + +/// +/// La forma del valor de Redis, que tumbó el arranque en el clúster. +/// +/// El manifiesto declara `REDIS_CONNECTION` como URI —`redis://ums-redis:6379`, que es la +/// convención razonable— y ese valor se pasaba tal cual a `ConnectionMultiplexer.Connect`, que no +/// entiende el esquema. Medido el 2026-08-02 al desplegar una imagen actual: el proceso no +/// arrancaba, con una excepción que acusaba a Redis —vivo y sano— en vez de a la notación. +/// +/// El defecto llevaba latente desde que se escribió el camino de código: el clúster corría una +/// imagen anterior, así que nunca se ejecutaba. Estas pruebas existen para que no vuelva a hacer +/// falta un despliegue para descubrirlo. +/// +public sealed class NormalizacionRedisTests +{ + [Theory] + [InlineData("redis://ums-redis:6379", "ums-redis:6379")] + [InlineData("rediss://ums-redis:6380", "ums-redis:6380")] + [InlineData("REDIS://UMS-REDIS:6379", "UMS-REDIS:6379")] + [InlineData(" redis://ums-redis:6379 ", "ums-redis:6379")] + public void Una_uri_pierde_el_esquema_y_conserva_todo_lo_demas(string entrada, string esperado) + { + Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar(entrada).Should().Be(esperado); + } + + [Theory] + [InlineData("ums-redis:6379")] + [InlineData("localhost:6379")] + // Las opciones van detrás del host y NO se tocan: recortar más de lo necesario cambiaría la + // conexión en vez de solo su notación. + [InlineData("ums-redis:6379,abortConnect=false,ssl=true")] + public void Lo_que_ya_viene_en_la_forma_correcta_no_se_altera(string entrada) + { + Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar(entrada).Should().Be(entrada); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Sin_valor_se_devuelve_tal_cual_para_que_el_llamante_caiga_al_anillo_en_memoria(string? entrada) + { + Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar(entrada).Should().Be(entrada); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/RefreshTokenPerTenantE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/RefreshTokenPerTenantE2ETests.cs new file mode 100644 index 00000000..a1bcf7e4 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/RefreshTokenPerTenantE2ETests.cs @@ -0,0 +1,169 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; +using AppConfigProvider = Ums.Application.Configuration.Services.IConfigurationProvider; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// E2E HTTP del refresh token opaco configurable por inquilino (ADR-UMS-091 / FR-015/016, G-034). +/// +/// Cierra el residual de G-034: los 12 tests unitarios de RefreshAuthenticationCommandHandler +/// ejercen el handler con colaboradores mockeados; aquí se recorre el flujo HTTP completo +/// sobre el host de integración (, login real con la data +/// semilla) con el flag AUTH_REFRESH_TOKEN_ENABLED activado para un inquilino concreto: +/// +/// 1. Fail-closed: con el flag apagado (default de ADR-UMS-088) el login no emite refresh token. +/// 2. Activado el flag para el inquilino (config de alcance Tenant + recarga del proveedor), el +/// login emite un refresh token opaco. +/// 3. Ese token renueva la sesión por POST /api/v1/auth/refresh-token (200 + rotación: +/// devuelve un refresh token nuevo, distinto del presentado). +/// 4. Revocación: tras POST /api/v1/auth/logout (logout real, ADR-UMS-091/FR-016) el +/// token ya rotado/vivo deja de renovar (401): un token revocado no renueva. +/// +/// El flag se activa por inquilino (no global): honra la cascada Global>Suite>Tenant>Module +/// y la naturaleza multi-inquilino de la capacidad. +/// +public sealed class RefreshTokenPerTenantE2ETests : IClassFixture +{ + private const string TenantCode = "RANSA_PERU"; + private const string Username = "gerente.operaciones@ransa.pe"; + + private readonly UmsApiWebApplicationFactory _factory; + private readonly HttpClient _client; + + public RefreshTokenPerTenantE2ETests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + } + + [Fact] + public async Task RefreshToken_PerTenantFlag_IssuesRenewsAndRevocationStopsRenewal() + { + var ct = TestContext.Current.CancellationToken; + + // 1. Login con el flag APAGADO (default): fail-closed ⇒ no se emite refresh token. + // De paso descubrimos el TenantId real del inquilino semilla para activarle el flag. + var preFlag = await LoginAsync(ct); + preFlag.RefreshToken.Should().BeNullOrEmpty( + "con AUTH_REFRESH_TOKEN_ENABLED apagado el login no emite refresh token (fail-closed, ADR-UMS-088)"); + + // 2. Activar la capacidad SOLO para ese inquilino (config de alcance Tenant) y recargar el + // proveedor de configuración (singleton) para que la resuelva en caliente. + await EnableRefreshTokenForTenantAsync(preFlag.TenantId, ct); + + // 3. Login con el flag ENCENDIDO para el inquilino ⇒ ahora el login emite el refresh token opaco. + var login = await LoginAsync(ct); + login.RefreshToken.Should().NotBeNullOrEmpty( + "activada la capacidad para el inquilino, el login debe emitir un refresh token opaco (FR-015)"); + + // 4. El refresh token RENUEVA la sesión: 200 + rotación (nuevo refresh distinto del presentado). + var (renewStatus, rotatedRefreshToken) = await PostRefreshTokenAsync(login.RefreshToken!, ct); + renewStatus.Should().Be(HttpStatusCode.OK, + "un refresh token vivo debe renovar la sesión mientras el inquilino tenga la capacidad activa"); + rotatedRefreshToken.Should().NotBeNullOrEmpty("la política rota por defecto ⇒ se emite un refresh token nuevo"); + rotatedRefreshToken.Should().NotBe(login.RefreshToken, + "la rotación debe entregar un refresh token distinto del presentado (detección de reuso)"); + + // 5. REVOCACIÓN: logout real (ADR-UMS-091/FR-016) revoca todas las familias vivas del usuario. + // El cliente lleva la cookie de sesión del último login, que identifica al principal. + var logout = await _client.PostAsync("/api/v1/auth/logout", content: null, ct); + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // 6. Un token REVOCADO ya no renueva: presentar el refresh token vivo (rotado) tras el logout + // debe fallar (401), demostrando que la revocación es efectiva end-to-end. + var (afterRevokeStatus, _) = await PostRefreshTokenAsync(rotatedRefreshToken!, ct); + afterRevokeStatus.Should().Be(HttpStatusCode.Unauthorized, + "tras la revocación, el refresh token de la familia ya no debe renovar la sesión"); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private sealed record LoginOutcome(Guid UserId, Guid TenantId, string? RefreshToken); + + /// + /// Login real contra la data semilla; captura la cookie de sesión (para el logout) y el + /// refresh token opaco (null cuando el inquilino no activó la capacidad). + /// + private async Task LoginAsync(CancellationToken ct) + { + var loginResponse = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + tenantCode = TenantCode, + username = Username, + password = CoreDevDataSeeder.SuperAdminPassword, + rememberMe = false, + }, ct); + + loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var setCookieHeader = loginResponse.Headers.GetValues("Set-Cookie").FirstOrDefault(); + setCookieHeader.Should().NotBeNullOrEmpty(); + var cookieValue = setCookieHeader!.Split(';')[0]; + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", cookieValue); + + using var payload = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync(ct)); + var root = payload.RootElement; + + var userId = Guid.Parse(root.GetProperty("userId").GetString()!); + var tenantId = Guid.Parse(root.GetProperty("tenantId").GetString()!); + var refreshToken = root.TryGetProperty("refreshToken", out var rt) && rt.ValueKind == JsonValueKind.String + ? rt.GetString() + : null; + + return new LoginOutcome(userId, tenantId, refreshToken); + } + + private async Task<(HttpStatusCode Status, string? RefreshToken)> PostRefreshTokenAsync( + string refreshToken, CancellationToken ct) + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/refresh-token", new + { + refreshToken, + }, ct); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (response.StatusCode, null); + } + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var newToken = payload.RootElement.TryGetProperty("refreshToken", out var rt) && rt.ValueKind == JsonValueKind.String + ? rt.GetString() + : null; + return (response.StatusCode, newToken); + } + + /// + /// Siembra AUTH_REFRESH_TOKEN_ENABLED = true con alcance Tenant para el inquilino dado y + /// recarga el proveedor de configuración (singleton) para que la política se resuelva en caliente. + /// + private async Task EnableRefreshTokenForTenantAsync(Guid tenantId, CancellationToken ct) + { + var actor = ActorId.Create("g034-e2e"); + + var config = AppConfiguration.Create( + TenantId.Load(tenantId), + null, + null, + Code.Create(AppConfigurationCodes.AuthRefreshTokenEnabled), + ConfigurationValue.Create("true"), + Description.Create("G-034 E2E: refresh token opaco activado por inquilino."), + isInheritable: true, + isEncrypted: false, + actor).Value; + config.Publish(actor); + + using var scope = _factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + repository.Seed(config); + + var provider = _factory.Services.GetRequiredService(); + await provider.ReloadAsync(ct); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/TenantIsolationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/TenantIsolationTests.cs index 68e274c4..4260c7ea 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/TenantIsolationTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/TenantIsolationTests.cs @@ -36,8 +36,8 @@ public async Task ZeroDataLeakage_QueryingOtherTenantUsers_ShouldBeRejected() loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); using var payload = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - var ransaTenantId = payload.RootElement.GetProperty("tenantId").GetString(); + if (loginResponse.Headers.TryGetValues("Set-Cookie", out var setCookies)) { var cookie = setCookies.FirstOrDefault(c => c.StartsWith("ums.session=")); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Ums.Presentation.IntegrationTest.csproj b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Ums.Presentation.IntegrationTest.csproj index b7f5e4fd..8509c750 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Ums.Presentation.IntegrationTest.csproj +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Ums.Presentation.IntegrationTest.csproj @@ -23,6 +23,7 @@ all + @@ -30,6 +31,11 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/apps/ums.api/Ums.Presentation/Bootstrapping/LanguageHeaderOperationFilter.cs b/src/apps/ums.api/Ums.Presentation/Bootstrapping/LanguageHeaderOperationFilter.cs new file mode 100644 index 00000000..3bf19dcc --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Bootstrapping/LanguageHeaderOperationFilter.cs @@ -0,0 +1,25 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Ums.Presentation.Bootstrapping; + +/// +/// Filtro de operación de Swagger que añade la cabecera opcional X-Language +/// a cada endpoint documentado (selección de idioma; cae a Accept-Language y luego 'en'). +/// +internal sealed class LanguageHeaderOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + operation.Parameters ??= new List(); + operation.Parameters.Add(new OpenApiParameter + { + Name = "X-Language", + In = ParameterLocation.Header, + Required = false, + Description = "Language code (e.g. 'en', 'es'). Falls back to Accept-Language then 'en'.", + Schema = new OpenApiSchema { Type = "string", Default = new OpenApiString("en") }, + }); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs b/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs index fb7919cc..e7c4ff56 100644 --- a/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs +++ b/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs @@ -1,7 +1,6 @@ using Asp.Versioning; using Asp.Versioning.Builder; using System.Diagnostics; -using System.Threading.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; @@ -16,6 +15,7 @@ using Ums.Infrastructure.Persistence.Options; using Ums.Infrastructure.Persistence.Seeders; using Ums.Presentation.Endpoints; +using Ums.Presentation.GraphQL; using Ums.Presentation.Services; using Ums.Presentation.Endpoints.Approvals.AccessEnforcementPolicy; using Ums.Presentation.Endpoints.Approvals.AccessEnforcementPolicy.Queries; @@ -56,7 +56,9 @@ using Ums.Presentation.Endpoints.Identity.UserManagementDelegation; using Ums.Presentation.Endpoints.Identity.UserManagementDelegation.Queries; using Ums.Presentation.Endpoints.Identity.Onboarding; -using Ums.Presentation.GraphQL; +using Ums.Presentation.Endpoints.Iga.RolePromotionRequest; +using Ums.Presentation.Endpoints.Iga.RolePromotionRequest.Queries; +using Ums.Presentation.Endpoints.Iga.RoleMaturityStatus.Queries; using Ums.Presentation.Middleware; using Ums.Presentation.Bootstrapping.Bootstrappers; using BeyondNetCode.Shell.Bootstrapper.Impl; @@ -75,7 +77,6 @@ public static IServiceCollection AddUmsApiServiceBootstrappers( new CompositeBootstrapper() .Add(new UmsCoreApplicationBootstrapper(services, configuration, environment)) .Add(new UmsApiPlatformBootstrapper(services, configuration)) - .Add(new UmsApiRateLimitingBootstrapper(services, configuration)) .Add(new UmsApiDocumentationBootstrapper(services, configuration)) .Add(new ConfigurationBootstrapper(services)) .Run(); @@ -108,14 +109,19 @@ public void Run() Result.AddApplication(); Result.AddInfrastructure(_configuration, _environment); Result.AddScoped(); + // GraphQL: transporte de consulta propio del satélite (ausente en la plataforma de origen). Result.AddUmsGraphQl(_environment); Result.AddMemoryCache(); // required by IdempotencyMiddleware (FIX-06) // HARDENING-02: JWT Bearer authentication. Disabled in dev (DevAuthMiddleware handles it). // Production: set Authentication:Enabled=true and configure Authority + Audience. - Result.AddUmsAuthentication(_configuration); + Result.AddUmsAuthentication(_configuration, _environment); // JWT Token Service for session management + // El material de firma es unico y de por vida: la clave se carga y valida una sola + // vez, al arrancar. Si falta o es debil, UMS no arranca (ADR-0157 §4.7) en vez de + // descubrirlo al primer login. + Result.AddSingleton(); Result.AddSingleton(); } } @@ -147,6 +153,19 @@ public void Run() Result.AddProblemDetails(); Result.AddEndpointsApiExplorer(); + // G-175: compresión de respuesta. `EnableForHttps` va explícito porque el valor por + // defecto es false: sin él, en producción (que es HTTPS) no comprimiría nada. + // El riesgo BREACH que motiva ese default se acota aquí porque la API no emite + // formularios HTML ni refleja secretos en el cuerpo, y los tokens viajan en cabecera. + Result.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes + .Concat(["application/problem+json", "application/xml", "text/yaml", "text/csv"]); + }); + // REC-02: Real health checks — liveness + readiness + outbox backlog Result.AddInfrastructureHealthChecks(_configuration); @@ -155,108 +174,12 @@ public void Run() } } -internal sealed class UmsApiRateLimitingBootstrapper : IBootstrapper -{ - private readonly IConfiguration _configuration; - - public UmsApiRateLimitingBootstrapper(IServiceCollection services, IConfiguration configuration) - { - Result = services; - _configuration = configuration; - } - - public IServiceCollection? Result { get; private set; } - - public void Run() - { - ArgumentNullException.ThrowIfNull(Result); - - var rateLimit = _configuration.GetSection("ApiSettings:RateLimiting"); - var permitLimit = rateLimit.GetValue("PermitLimit", 100); - var windowMinutes = rateLimit.GetValue("WindowMinutes", 1); - - Result.AddRateLimiter(options => - { - static string ResolvePartitionKey(HttpContext ctx, string prefix = "") - { - var tenantId = ctx.User.FindFirst("tenant_id")?.Value - ?? ctx.User.FindFirst("org_id")?.Value; - - var sub = ctx.User.FindFirst("sub")?.Value - ?? ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - - if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(sub)) - return $"{prefix}tenant:{tenantId}:user:{sub}"; - - if (!string.IsNullOrEmpty(sub)) - return $"{prefix}user:{sub}"; - - var apiKey = ctx.Request.Headers["X-Api-Key"].FirstOrDefault(); - if (!string.IsNullOrEmpty(apiKey)) - return $"{prefix}apikey:{apiKey}"; - - var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - return $"{prefix}ip:{ip}"; - } - - options.AddPolicy("graphql", context => - { - var key = ResolvePartitionKey(context, "gql:"); - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = Math.Max(1, permitLimit / 2), - Window = TimeSpan.FromMinutes(windowMinutes), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0, - }); - }); - - options.GlobalLimiter = PartitionedRateLimiter.Create(context => - { - var key = ResolvePartitionKey(context); - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = permitLimit, - Window = TimeSpan.FromMinutes(windowMinutes), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0, - }); - }); - - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - options.OnRejected = async (context, token) => - { - var problemDetails = new ProblemDetails - { - Title = "Too Many Requests", - Detail = "Rate limit exceeded. Please try again later.", - Status = StatusCodes.Status429TooManyRequests, - Type = "https://httpstatuses.io/429", - Extensions = - { - ["retryAfter"] = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry) - ? retry.ToString() - : "60", - }, - }; - - context.HttpContext.Response.ContentType = "application/problem+json"; - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - - await context.HttpContext.Response.WriteAsJsonAsync(problemDetails, token); - }; - }); - } -} - public static class UmsApiApplicationBuilderExtensions { public static async Task InitializeUmsPlatformAsync(this WebApplication app) { + DeclararEstadoDistribuido(app); + var persistence = app.Services.GetRequiredService>().Value; if (persistence.InitializePlatformStoreOnStartup) @@ -264,18 +187,31 @@ public static async Task InitializeUmsPlatformAsync(this WebAppl using var scope = app.Services.CreateScope(); var platformDbContext = scope.ServiceProvider.GetRequiredService(); - if (persistence.Provider == PersistenceProvider.Sqlite) - { - await SqliteSchemaBootstrapper.InitializeAsync(platformDbContext); - } - else if (persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { - // Usamos EF Core Migrations nativas para PostgreSQL + // EF Core native migrations for PostgreSQL. await platformDbContext.Database.MigrateAsync(); + + // G-072: el read model (proyecciones de fase 1) vive en la MISMA base + // pero en su propio DbContext con sus propias migraciones. Sin migrarlo + // aquí, su tabla (`Authorization.PermissionTemplateReadModels`) no existe + // en un despliegue desde cero, y la primera proyección + // (`PermissionTemplatePublishedEvent`, disparada por el seed) falla con + // 42P01. Se migra en el mismo arranque, junto al contexto de plataforma. + var readModelDbContext = scope.ServiceProvider + .GetRequiredService(); + await readModelDbContext.Database.MigrateAsync(); } } - if (app.Environment.IsDevelopment() && persistence.SeedDevData) + // G-127: la siembra del dataset FS-25 (datos de referencia + demo) se gobierna por el + // flag SeedDevData DESACOPLADO del entorno, para habilitar el stage UAT (usuarios + // personas reales que ingresan y encuentran datos visibles). Guarda de defensa en + // profundidad: NUNCA se siembra en Production, aunque el flag venga en true por una + // config errónea. Los backdoors propios de Development —DevAuth, el endpoint Pact + // `/_pact/provider-states` y Swagger— siguen gated a IsDevelopment() por separado (ver + // UseUmsApiPipeline / MapUmsApiSurface): UAT obtiene los datos SIN exponer esos backdoors. + if (persistence.SeedDevData && !app.Environment.IsProduction()) { await app.SeedDevelopmentDataAsync(); } @@ -285,7 +221,11 @@ public static async Task InitializeUmsPlatformAsync(this WebAppl public static WebApplication UseUmsApiPipeline(this WebApplication app) { - app.UseCorrelationId(); + // G-175: primero de la cadena, para comprimir también lo que escriban los middlewares + // posteriores. El grafo de autorización que devuelve el login son 43-50 KB de JSON muy + // repetitivo: medido sobre capturas reales, Brotli lo deja en 3,7-5,3 KB (9-12x). + app.UseResponseCompression(); + app.UseSessionTracking(); app.UseSerilogRequestLogging(opts => { @@ -293,7 +233,6 @@ public static WebApplication UseUmsApiPipeline(this WebApplication app) { var requestContext = httpContext.RequestServices.GetRequiredService(); diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value ?? string.Empty); - diagnosticContext.Set("CorrelationId", httpContext.TraceIdentifier); diagnosticContext.Set("SessionTrackingId", requestContext.SessionTrackingId ?? string.Empty); diagnosticContext.Set("TraceId", requestContext.TraceId ?? Activity.Current?.TraceId.ToString() ?? string.Empty); diagnosticContext.Set("SpanId", requestContext.SpanId ?? Activity.Current?.SpanId.ToString() ?? string.Empty); @@ -306,8 +245,15 @@ public static WebApplication UseUmsApiPipeline(this WebApplication app) }); app.UseCulture(); + // ADR-0096 / ADR-UMS-085: abre la transacción funcional por fuera del manejador global + // para garantizar el desenlace y que el localizador legible viaje en la respuesta de error. + app.UseFunctionalTransaction(); app.UseGlobalExceptionHandler(); - app.UseRateLimiter(); + // G-248: aquí estaba `UseRateLimiter()`, y estaba mal por dos motivos a la vez. Contaba en + // proceso —con N réplicas el cupo efectivo era N veces el declarado— y corría ANTES de + // `UseAuthentication`, así que `HttpContext.User` venía vacío y la clave de reparto caía + // siempre a la IP: dos usuarios distintos compartían cupo. El límite vive ahora más abajo, + // después de autenticar, que es el único punto donde se sabe a quién se limita. if (app.Environment.IsDevelopment()) { @@ -326,6 +272,12 @@ public static WebApplication UseUmsApiPipeline(this WebApplication app) app.UseDevAuth(); } app.UseAuthentication(); + // G-248: DESPUÉS de autenticar —para que el cupo sea de quien llama y no de una IP que + // comparten todos— y ANTES de autorizar. El orden importa en los dos lados. Puesto antes de + // autenticar, la identidad aún no está resuelta y dos usuarios distintos comparten cupo. + // Puesto después de autorizar, las peticiones rechazadas con 401 no llegan a contarse, y + // son justo las que más interesa contar cuando alguien prueba credenciales a ciegas. + app.UseLimiteDePeticiones(app.Configuration); app.UseAuthorization(); app.UseIdempotency(); // FIX-07: Must run after auth so cached responses are never served to unauthenticated callers. app.UseTenantContext(); @@ -346,19 +298,51 @@ public static WebApplication MapUmsApiSurface(this WebApplication app) { var versionedGroup = app.CreateVersionedApiGroup(); - app.MapGraphQlSurface(versionedGroup); app.MapHealthSurface(); app.MapAuthEndpoints(); app.MapClientAuthEndpoints(); - app.MapPactProviderStateEndpoints(); + // El material publico con el que los satelites verifican (ADR-0157 §4.1). Va + // fuera de /api y sin version: `.well-known` es una ruta reservada por RFC 8615 + // y los validadores de .NET y de Node la buscan ahi, no donde nos convenga. + app.MapJwksEndpoints(); + + // G-101: el endpoint de estados de proveedor de Pact (`POST /_pact/provider-states`) + // siembra datos de dominio arbitrarios (inquilinos, cuentas, aprobaciones, feature + // flags…) de forma anónima. Sólo puede existir en desarrollo/contract-test, jamás en + // producción. La factoría de contract-test arranca en «Development» + // (ContractTestWebApplicationFactory), por lo que esta guarda —consistente con el + // patrón «solo dev» de Swagger y DevAuth— mantiene verde al proveedor de Pact sin + // exponer la superficie en entornos productivos. + if (app.Environment.IsDevelopment()) + { + app.MapPactProviderStateEndpoints(); + } versionedGroup.MapUmsCommandEndpoints(); versionedGroup.MapUmsQueryEndpoints(); + // Transporte GraphQL de solo lectura: superficie propia del satélite. + app.MapGraphQlSurface(versionedGroup); + return app; } + /// + /// GraphQL es el transporte de consulta propio del satélite (no existe en la plataforma de + /// origen). Se expone en la raíz y bajo el grupo versionado para que un cliente pueda fijar + /// la versión igual que hace con REST. + /// + internal static IEndpointRouteBuilder MapGraphQlSurface( + this IEndpointRouteBuilder endpoints, + RouteGroupBuilder versionedGroup) + { + endpoints.MapGraphQL("/graphql").WithTags("GraphQL - Queries"); + versionedGroup.MapGraphQL("/graphql").WithTags("GraphQL - Queries"); + + return endpoints; + } + internal static RouteGroupBuilder CreateVersionedApiGroup(this WebApplication app) { var versionSet = app.NewApiVersionSet() @@ -370,21 +354,6 @@ internal static RouteGroupBuilder CreateVersionedApiGroup(this WebApplication ap .WithApiVersionSet(versionSet); } - internal static IEndpointRouteBuilder MapGraphQlSurface( - this IEndpointRouteBuilder endpoints, - RouteGroupBuilder versionedGroup) - { - endpoints.MapGraphQL("/graphql") - .WithTags("GraphQL - Queries") - .RequireRateLimiting("graphql"); - - versionedGroup.MapGraphQL("/graphql") - .WithTags("GraphQL - Queries") - .RequireRateLimiting("graphql"); - - return endpoints; - } - internal static IEndpointRouteBuilder MapHealthSurface(this IEndpointRouteBuilder endpoints) { endpoints.MapGet("/health/live", () => Results.Ok(new @@ -422,6 +391,7 @@ internal static RouteGroupBuilder MapUmsCommandEndpoints(this RouteGroupBuilder .MapAuthorizationCommandEndpoints() .MapAuditCommandEndpoints() .MapApprovalsCommandEndpoints() + .MapIgaCommandEndpoints() .MapConfigurationCommandEndpoints(); return versionedGroup; @@ -434,6 +404,7 @@ internal static RouteGroupBuilder MapUmsQueryEndpoints(this RouteGroupBuilder ve .MapAuthorizationQueryEndpoints() .MapAuditQueryEndpoints() .MapApprovalsQueryEndpoints() + .MapIgaQueryEndpoints() .MapConfigurationQueryEndpoints(); return versionedGroup; @@ -444,6 +415,7 @@ internal static RouteGroupBuilder MapIdentityCommandEndpoints(this RouteGroupBui versionedGroup.MapTenantEndpoints(); versionedGroup.MapTenantBranchEndpoints(); versionedGroup.MapTenantIdentityProviderEndpoints(); + // Branding por inquilino: superficie propia del satélite. versionedGroup.MapTenantBrandingEndpoints(); versionedGroup.MapUserAccountEndpoints(); versionedGroup.MapDelegationEndpoints(); @@ -456,6 +428,7 @@ internal static RouteGroupBuilder MapIdentityQueryEndpoints(this RouteGroupBuild { versionedGroup.MapTenantQueryEndpoints(); versionedGroup.MapBranchQueryEndpoints(); + // Branding por inquilino: superficie propia del satélite. versionedGroup.MapBrandingQueryEndpoints(); versionedGroup.MapIdentityProviderQueryEndpoints(); versionedGroup.MapUserAccountQueryEndpoints(); @@ -520,6 +493,21 @@ internal static RouteGroupBuilder MapApprovalsQueryEndpoints(this RouteGroupBuil return versionedGroup; } + internal static RouteGroupBuilder MapIgaCommandEndpoints(this RouteGroupBuilder versionedGroup) + { + versionedGroup.MapRolePromotionRequestEndpoints(); + + return versionedGroup; + } + + internal static RouteGroupBuilder MapIgaQueryEndpoints(this RouteGroupBuilder versionedGroup) + { + versionedGroup.MapRolePromotionRequestQueryEndpoints(); + versionedGroup.MapRoleMaturityStatusQueryEndpoints(); + + return versionedGroup; + } + internal static RouteGroupBuilder MapConfigurationCommandEndpoints(this RouteGroupBuilder versionedGroup) { versionedGroup.MapAppConfigurationEndpoints(); @@ -540,6 +528,38 @@ internal static RouteGroupBuilder MapConfigurationQueryEndpoints(this RouteGroup return versionedGroup; } + /// + /// Declara en el arranque qué implementación de estado compartido quedó activa. + /// + /// Existe porque el modo degradado era indistinguible del correcto: si la cadena de Redis no + /// resolvía, la API arrancaba igual de contenta con las implementaciones en memoria y el + /// clúster se comportaba mal solo bajo carga y con más de una réplica (G-169). Un despliegue + /// multi-réplica que lea `InMemory` en esta línea sabe que va a servir sesiones incoherentes + /// ANTES de que se lo diga un usuario. + /// + private static void DeclararEstadoDistribuido(WebApplication app) + { + var logger = app.Services.GetRequiredService().CreateLogger("Ums.Startup"); + + var revocacion = app.Services.GetRequiredService().GetType().Name; + var configuracion = app.Services.GetRequiredService().GetType().Name; + var distribuido = !revocacion.StartsWith("InMemory", StringComparison.Ordinal); + + if (distribuido) + { + logger.LogInformation( + "Estado compartido: DISTRIBUIDO. Revocación={Revocacion}, Configuración={Configuracion}. Apto para múltiples réplicas.", + revocacion, configuracion); + return; + } + + logger.LogWarning( + "Estado compartido: EN MEMORIA (sin Redis). Revocación={Revocacion}, Configuración={Configuracion}. " + + "Válido solo con UNA réplica: con más de una, la revocación de tokens y la configuración no se propagan entre pods. " + + "Configure `Redis:Connection` o la variable `REDIS_CONNECTION` para habilitar el modo distribuido (G-169).", + revocacion, configuracion); + } + } internal sealed class UmsApiDocumentationBootstrapper : IBootstrapper @@ -564,7 +584,7 @@ public void Run() { Title = "UMS Tenant API", Version = "v1", - Description = "User Management System — modular monolith API with REST commands and GraphQL queries, prepared for SQL Server platform persistence.", + Description = "User Management System — modular monolith API with REST commands and queries, prepared for SQL Server platform persistence.", }); options.AddSecurityDefinition("DevUserId", new OpenApiSecurityScheme @@ -591,8 +611,8 @@ public void Run() .AllowAnyHeader() .AllowCredentials() .WithExposedHeaders( - ObservabilityHeaders.CorrelationId, ObservabilityHeaders.SessionTrackingId, + "traceparent", "api-supported-versions", "api-deprecated-versions"); }); diff --git a/src/apps/ums.api/Ums.Presentation/Dockerfile b/src/apps/ums.api/Ums.Presentation/Dockerfile deleted file mode 100644 index 329feaa1..00000000 --- a/src/apps/ums.api/Ums.Presentation/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# ========================================================= -# Phase 1: Build Stage -# ========================================================= -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src - -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* - -# Copy main application projects -COPY src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj apps/ums.api/Ums.Presentation/ -COPY src/apps/ums.api/Ums.Application/Ums.Application.csproj apps/ums.api/Ums.Application/ -COPY src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj apps/ums.api/Ums.Infrastructure/ -COPY src/apps/ums.api/Ums.Domain/Ums.Domain.csproj apps/ums.api/Ums.Domain/ -COPY src/apps/ums.api/Ums.Globalization/Ums.Globalization.csproj apps/ums.api/Ums.Globalization/ -COPY src/Ums.ReadModels/Ums.ReadModels.csproj Ums.ReadModels/ -COPY src/libs/ libs/ - -# Restore dependencies -RUN dotnet restore apps/ums.api/Ums.Presentation/Ums.Presentation.csproj - -# Copy everything else -COPY src/apps/ums.api/ apps/ums.api/ -COPY src/Ums.ReadModels/ Ums.ReadModels/ - -# Build and publish -WORKDIR /src/apps/ums.api/Ums.Presentation -RUN dotnet publish -c Release -o /app/publish - -# ========================================================= -# Phase 2: Runtime Stage -# ========================================================= -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime -WORKDIR /app - -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=build /app/publish . - -EXPOSE 8080 -ENV ASPNETCORE_URLS=http://+:8080 -ENV ASPNETCORE_ENVIRONMENT=Production - -ENTRYPOINT ["dotnet", "Ums.Presentation.dll"] diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/AuditRecordEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/AuditRecordEndpoints.cs index 8a010fab..2b2ae060 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/AuditRecordEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/AuditRecordEndpoints.cs @@ -9,8 +9,11 @@ public static class AuditRecordEndpoints { public static IEndpointRouteBuilder MapAuditRecordEndpoints(this IEndpointRouteBuilder app) { + // G-040 (SEGURIDAD): la auditoría es sensible; todos los endpoints exigen + // autenticación. Sin esto la lectura y el registro eran anónimos. var group = app.MapGroup("/audit-records") - .WithTags("AuditRecords"); + .WithTags("AuditRecords") + .RequireAuthorization(); group.MapGet("/", async ( [FromQuery] int page, diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/Queries/AuditRecordQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/Queries/AuditRecordQueryEndpoints.cs index 02baf875..4aa6f5a6 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/Queries/AuditRecordQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/Queries/AuditRecordQueryEndpoints.cs @@ -7,8 +7,10 @@ public static class AuditRecordQueryEndpoints { public static IEndpointRouteBuilder MapAuditRecordQueryEndpoints(this IEndpointRouteBuilder app) { + // G-040 (SEGURIDAD): lectura de auditoría solo para usuarios autenticados. var group = app.MapGroup("/audit-records") - .WithTags("AuditRecords - Queries"); + .WithTags("AuditRecords - Queries") + .RequireAuthorization(); group.MapGet("/{auditRecordId:guid}", async (Guid auditRecordId, IMediator mediator, HttpContext context, CancellationToken ct) => { diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/SystemSuite/SystemSuiteEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/SystemSuite/SystemSuiteEndpoints.cs index 6414eda3..79180e75 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/SystemSuite/SystemSuiteEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/SystemSuite/SystemSuiteEndpoints.cs @@ -73,27 +73,31 @@ public static IEndpointRouteBuilder MapSystemSuiteEndpoints(this IEndpointRouteB .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); + // Eliminación LÓGICA del sistema (G-246). El archivado sigue siendo el PUT /{id}/status → + // Deprecated; esto elimina lo ya archivado y sin referencias vivas. La fila NO se borra: se + // marca `Deleted` y desaparece de las lecturas. 409 si el sistema sigue vigente o si algo + // vivo lo apunta —la respuesta enumera qué—. + group.MapDelete("/{systemSuiteId:guid}", async (Guid systemSuiteId, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new DeleteSystemSuiteCommand(systemSuiteId), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteSystemSuite") + .WithSummary("Logically delete a deprecated system suite that has no live dependents") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + // ── Module lifecycle ───────────────────────────────────────────────── group.MapPost("/{systemSuiteId:guid}/modules", async (Guid systemSuiteId, AddModuleCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/modules", result); - // Map domain error to proper problem response - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); + // 201 con el id del módulo creado (G-053): el cliente ya no necesita un GET para obtenerlo. + return result.ToCreated(r => $"/system-suites/{systemSuiteId}/modules/{r.ModuleId}", context); }).WithName("AddModule") .WithSummary("Add a module to the system suite") - .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); @@ -137,131 +141,55 @@ public static IEndpointRouteBuilder MapSystemSuiteEndpoints(this IEndpointRouteB .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); - // ── Menu lifecycle ──────────────────────────────────────────────────── + // ── Node lifecycle (árbol recursivo, ADR-0090) ─────────────────────── - group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus", async (Guid systemSuiteId, Guid moduleId, AddMenuCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes", async (Guid systemSuiteId, Guid moduleId, AddNodeCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/modules/{moduleId}/menus", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); - }).WithName("AddMenu") - .WithSummary("Add a menu to a module") - .Produces(StatusCodes.Status201Created) + // 201 con el id del nodo creado (G-053): DELETE/GET de nodo se hacen por id, ya no se fuerza un GET. + return result.ToCreated(r => $"/system-suites/{systemSuiteId}/modules/{moduleId}/nodes/{r.NodeId}", context); + }).WithName("AddNode") + .WithSummary("Add a node (root when ParentNodeId is null, else child) to a module tree") + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); - group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, UpdateMenuCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, UpdateNodeCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId }, ct); + var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, NodeId = nodeId }, ct); return result.ToNoContent(context); - }).WithName("UpdateMenu") - .WithSummary("Update menu label, description, or sort order") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); + }).WithName("UpdateNode").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveMenuCommand(systemSuiteId, moduleId, menuId), ct); + var result = await mediator.Send(new RemoveNodeCommand(systemSuiteId, moduleId, nodeId), ct); return result.ToNoContent(context); - }).WithName("RemoveMenu") - .WithSummary("Remove a menu from a module") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); - - // ── SubMenu lifecycle ───────────────────────────────────────────────── + }).WithName("RemoveNode").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus", async (Guid systemSuiteId, Guid moduleId, Guid menuId, AddSubMenuCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}/status", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, SetNodeStatusCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/modules/{moduleId}/menus/{menuId}/submenus", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); - }).WithName("AddSubMenu") - .WithSummary("Add a submenu to a menu") - .Produces(StatusCodes.Status201Created) - .ProducesProblem(StatusCodes.Status400BadRequest) - .ProducesProblem(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status409Conflict); - - group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, UpdateSubMenuCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => - { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId, SubMenuId = subMenuId }, ct); + var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, NodeId = nodeId }, ct); return result.ToNoContent(context); - }).WithName("UpdateSubMenu") - .WithSummary("Update submenu label, description, or sort order") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); + }).WithName("SetNodeStatus").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}/actions", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, LinkNodeActionCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveSubMenuCommand(systemSuiteId, moduleId, menuId, subMenuId), ct); + var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, NodeId = nodeId }, ct); return result.ToNoContent(context); - }).WithName("RemoveSubMenu") - .WithSummary("Remove a submenu from a menu") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); - - // ── Option lifecycle ────────────────────────────────────────────────── - - group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}/options", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, AddOptionCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => - { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId, SubMenuId = subMenuId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/modules/{moduleId}/menus/{menuId}/submenus/{subMenuId}/options", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); - }).WithName("AddOption") - .WithSummary("Add an option to a submenu") - .Produces(StatusCodes.Status201Created) - .ProducesProblem(StatusCodes.Status400BadRequest) - .ProducesProblem(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status409Conflict); + }).WithName("LinkNodeAction").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}/options/{optionId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, Guid optionId, UpdateOptionCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}/actions/{actionCode}", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, string actionCode, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId, SubMenuId = subMenuId, OptionId = optionId }, ct); + var result = await mediator.Send(new UnlinkNodeActionCommand(systemSuiteId, moduleId, nodeId, actionCode), ct); return result.ToNoContent(context); - }).WithName("UpdateOption") - .WithSummary("Update option label, description, action code, or sort order") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); + }).WithName("UnlinkNodeAction").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}/options/{optionId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, Guid optionId, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}/metadata", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, SetNodeMetadataCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveOptionCommand(systemSuiteId, moduleId, menuId, subMenuId, optionId), ct); + var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, NodeId = nodeId }, ct); return result.ToNoContent(context); - }).WithName("RemoveOption") - .WithSummary("Remove an option from a submenu") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); + }).WithName("SetNodeMetadata").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); // ── App settings ───────────────────────────────────────────────────── @@ -310,21 +238,11 @@ public static IEndpointRouteBuilder MapSystemSuiteEndpoints(this IEndpointRouteB group.MapPost("/{systemSuiteId:guid}/actions", async (Guid systemSuiteId, RegisterActionCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/actions", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); + // 201 con el id de la acción creada (G-053). La ruta usa el code, clave de negocio del recurso. + return result.ToCreated(r => $"/system-suites/{systemSuiteId}/actions/{r.Code}", context); }).WithName("RegisterAction") .WithSummary("Register a new action code that can be used in permission templates") - .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); @@ -354,21 +272,11 @@ public static IEndpointRouteBuilder MapSystemSuiteEndpoints(this IEndpointRouteB group.MapPost("/{systemSuiteId:guid}/domain-resources", async (Guid systemSuiteId, AddDomainResourceCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/domain-resources", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); + // 201 con el id del recurso de dominio creado (G-053): DELETE/PUT se hacen por id. + return result.ToCreated(r => $"/system-suites/{systemSuiteId}/domain-resources/{r.DomainResourceId}", context); }).WithName("AddDomainResource") .WithSummary("Add a domain resource (Aggregate or Entity) to the system suite") - .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Template/PermissionTemplateEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Template/PermissionTemplateEndpoints.cs index 849c6f3c..31416314 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Template/PermissionTemplateEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Template/PermissionTemplateEndpoints.cs @@ -89,20 +89,25 @@ public static IEndpointRouteBuilder MapPermissionTemplateEndpoints(this IEndpoin group.MapPost("/{templateId:guid}/items", async (Guid templateId, AddTemplateItemCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { TemplateId = templateId }, ct); - return result.ToNoContent(context); + // 201 con el id del ítem creado (G-053): DELETE/PUT del ítem se hacen por itemId. + return result.ToCreated(r => $"/permission-templates/{templateId}/items/{r.ItemId}", context); }).WithName("AddTemplateItem") .WithSummary("Add a permission item (target + action + effect) to a draft template") - .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + // DELETE de un ítem = RETIRADA LÓGICA (ADR-0164). La ruta y su contrato —204/404/409— se + // conservan porque el cliente ya la invoca, pero por debajo ejecuta el mismo verbo que + // `/deactivate`: la concesión se marca, la fila no se toca. El borrado físico no se deja + // «por si acaso» en otra ruta; simplemente ya no existe en ninguna capa. group.MapDelete("/{templateId:guid}/items/{itemId:guid}", async (Guid templateId, Guid itemId, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveTemplateItemCommand(templateId, itemId), ct); + var result = await mediator.Send(new DeactivateTemplateItemCommand(templateId, itemId), ct); return result.ToNoContent(context); - }).WithName("RemoveTemplateItem") - .WithSummary("Remove a permission item from a draft template") + }).WithName("RetireTemplateItem") + .WithSummary("Retire a permission item from a draft template — logical retirement, the row survives") .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/AppConfiguration/AppConfigurationEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/AppConfiguration/AppConfigurationEndpoints.cs index e5dd956e..833b2049 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/AppConfiguration/AppConfigurationEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/AppConfiguration/AppConfigurationEndpoints.cs @@ -210,6 +210,47 @@ public static IEndpointRouteBuilder MapAppConfigurationEndpoints(this IEndpointR .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + // G-143: borrado duro (DELETE). Replica la guarda de autorización por ámbito del archive. + group.MapDelete("/{appConfigurationId:guid}", async ( + Guid appConfigurationId, + IMediator mediator, + ITenantContext tenantContext, + HttpContext context, + CancellationToken ct) => + { + // Get the existing config to check scope + var getResult = await mediator.Send(new GetAppConfigurationByIdQuery(appConfigurationId), ct); + if (getResult.IsFailure) + { + return Results.NotFound(); + } + + var existingConfig = getResult.Value; + + // Authorization: Check if user can delete this config + var isGlobalScope = existingConfig.Scope == "Global"; + var isOtherTenant = existingConfig.TenantId.HasValue && + existingConfig.TenantId != tenantContext.OrganizationId; + + if (isGlobalScope && !tenantContext.IsInternalAdmin) + { + return Results.Json(new { error = "Only internal administrators can delete global configurations." }, statusCode: 403); + } + + if (isOtherTenant && !tenantContext.IsInternalAdmin) + { + return Results.Json(new { error = "You do not have permission to delete this tenant's configurations." }, statusCode: 403); + } + + var result = await mediator.Send(new DeleteAppConfigurationCommand(appConfigurationId), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteAppConfiguration") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + // DEFERRED: Rollback and version-comparison endpoints require a persisted // configuration history (audit table) — not yet implemented in the storage layer. return app; diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/Parameter/ParameterEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/Parameter/ParameterEndpoints.cs index 9dd4f78c..2e0e7b6b 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/Parameter/ParameterEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/Parameter/ParameterEndpoints.cs @@ -55,6 +55,24 @@ public static IEndpointRouteBuilder MapParameterEndpoints(this IEndpointRouteBui .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound); + // Borrado LÓGICO (el contrato HTTP no cambia: 204 en éxito). 409 si la definición todavía + // tiene valores globales/de inquilino VIVOS; el actor debe eliminarlos antes. + defs.MapDelete("/{id:guid}", async ( + Guid id, IMediator mediator, ITenantContext tenantContext, + HttpContext context, CancellationToken ct) => + { + if (!tenantContext.IsInternalAdmin) + return Results.Json(new { error = "Only internal administrators can delete parameter definitions." }, statusCode: 403); + + var result = await mediator.Send(new DeleteParameterDefinitionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteParameterDefinition") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + // ── ParameterGlobalValue mutations ─────────────────────────────────── var gv = app.MapGroup("/parameter-definitions/{definitionId:guid}/global-values") .WithTags("Parameter Global Values"); @@ -119,6 +137,23 @@ public static IEndpointRouteBuilder MapParameterEndpoints(this IEndpointRouteBui .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound); + // Borrado LÓGICO del valor global: libera la referencia que bloquea el borrado de su + // definición (archivarlo no la libera — una fila archivada sigue siendo una referencia real). + gv.MapDelete("/{id:guid}", async ( + Guid definitionId, Guid id, IMediator mediator, + ITenantContext tenantContext, HttpContext context, CancellationToken ct) => + { + if (!tenantContext.IsInternalAdmin) + return Results.Json(new { error = "Only internal administrators can delete parameter values." }, statusCode: 403); + + var result = await mediator.Send(new DeleteParameterGlobalValueCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteParameterGlobalValue") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound); + // ── ParameterTenantValue mutations ─────────────────────────────────── var tv = app.MapGroup("/parameter-definitions/{definitionId:guid}/tenant-values") .WithTags("Parameter Tenant Values"); @@ -150,6 +185,25 @@ public static IEndpointRouteBuilder MapParameterEndpoints(this IEndpointRouteBui .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound); + // Borrado LÓGICO del override de inquilino. Guarda deliberadamente estricta (solo + // administrador interno, como el resto de la gestión del catálogo de parámetros): el + // endpoint no conoce el inquilino del valor sin releerlo, y ante la duda no se abre + // una vía por la que un inquilino pudiera retirar el override de otro. + tv.MapDelete("/{id:guid}", async ( + Guid definitionId, Guid id, IMediator mediator, + ITenantContext tenantContext, HttpContext context, CancellationToken ct) => + { + if (!tenantContext.IsInternalAdmin) + return Results.Json(new { error = "Only internal administrators can delete parameter values." }, statusCode: 403); + + var result = await mediator.Send(new DeleteParameterTenantValueCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteParameterTenantValue") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound); + return app; } } diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/ParameterCatalog/Queries/ParameterCatalogQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/ParameterCatalog/Queries/ParameterCatalogQueryEndpoints.cs index 018d67b9..2d73c267 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/ParameterCatalog/Queries/ParameterCatalogQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/ParameterCatalog/Queries/ParameterCatalogQueryEndpoints.cs @@ -5,6 +5,7 @@ namespace Ums.Presentation.Endpoints.Configuration.ParameterCatalog.Queries; using Microsoft.EntityFrameworkCore; using Ums.Application.Common; using Ums.Application.Configuration.ParameterCatalog.DTOs; +using Ums.Domain.Enums; using Ums.Infrastructure.Persistence; using Ums.Infrastructure.Persistence.Configuration.Entities; @@ -102,9 +103,13 @@ public static IEndpointRouteBuilder MapParameterCatalogQueryEndpoints(this IEndp return Results.BadRequest(new { error = "Parameter code is required." }); var normalizedCode = code.Trim().ToUpperInvariant(); + // Las definiciones eliminadas lógicamente ya las descarta el filtro global de consulta; + // los valores globales eliminados hay que descartarlos aquí: un valor retirado no puede + // seguir resolviendo, la resolución debe caer al DefaultValue de la definición. + var deletedStatusId = ConfigStatus.Deleted.Id; var resolved = await ( from definition in dbContext.ParameterDefinitions - join globalValue in dbContext.ParameterGlobalValues + join globalValue in dbContext.ParameterGlobalValues.Where(v => v.StatusId != deletedStatusId) on definition.Id equals globalValue.ParameterDefinitionId into globalValues from globalValue in globalValues.DefaultIfEmpty() where definition.Code == normalizedCode && definition.IsActive diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs index e72fe9f4..c3382ef5 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs @@ -1,5 +1,7 @@ namespace Ums.Presentation.Endpoints.Identity.Auth; +#pragma warning disable S125 + using Ums.Application.Identity.Auth.Commands; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; @@ -16,6 +18,7 @@ namespace Ums.Presentation.Endpoints.Identity.Auth; using MsConfigProvider = Microsoft.Extensions.Configuration.IConfigurationProvider; using Ums.Domain.Authorization; using Ums.Domain.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; using Ums.Domain.Identity; using Ums.Domain.Kernel.ValueObjects; using Ums.Presentation.Services; @@ -43,6 +46,15 @@ public static void MapAuthEndpoints(this WebApplication app) .WithSummary("Refresh access token using session cookie") .RequireAuthorization(); + // Renovación por refresh token opaco (ADR-UMS-091 / FR-015). Anónimo a propósito: + // el refresh token ES la credencial y se usa cuando el access token ya expiró, + // por lo que no puede exigir una sesión viva. Solo funciona si el inquilino + // activó la capacidad (fail-closed en el handler). + group.MapPost("/refresh-token", HandleRefreshTokenGrantAsync) + .WithName("RefreshTokenGrant") + .WithSummary("Renew the session with an opaque refresh token (ADR-UMS-091, tenant opt-in)") + .AllowAnonymous(); + // Get current session info group.MapGet("/session", HandleGetSessionAsync) .WithName("GetSession") @@ -50,12 +62,24 @@ public static void MapAuthEndpoints(this WebApplication app) .RequireAuthorization(); // Switch tenant context (internal admins only) + group.MapPost("/switch-profile", HandleSwitchProfileAsync) + .WithName("SwitchProfile") + .WithSummary("Cambia el perfil vigente de la sesión sin volver a autenticarse") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + // Sin RequireAuthorization(): el token se valida en el manejador, igual que en + // switch-tenant, para no depender de que el esquema JWT esté configurado. + group.MapPost("/switch-tenant", HandleSwitchTenantAsync) .WithName("SwitchTenant") .WithSummary("Switch current tenant context (internal admins only)"); // No RequireAuthorization() - we validate the JWT token directly in the handler - // Forgot password — public, no auth required + // Solicitud de restablecimiento — pública. NO cambia la contraseña ni devuelve credencial + // alguna (G-188): solo emite un token de un solo uso hacia el buzón del titular. La + // respuesta es idéntica exista o no la cuenta, en cuerpo, código y tiempo. group.MapPost("/forgot-password", async ( ForgotPasswordCommand command, IMediator mediator, @@ -66,10 +90,20 @@ public static void MapAuthEndpoints(this WebApplication app) return result.ToOk(context); }) .WithName("ForgotPassword") - .WithSummary("Request a password reset. Always returns 200 to prevent user enumeration.") + .WithSummary("Solicita un restablecimiento de contraseña. Siempre 200 y siempre la misma respuesta.") .AllowAnonymous() .Produces(StatusCodes.Status200OK); + // Canje del token — pública porque el token ES la credencial: quien lo presenta acaba de + // probar la posesión del buzón y por definición no tiene sesión. Aquí, y solo aquí, + // cambia la contraseña (G-188). + group.MapPost("/reset-password", HandleResetPasswordAsync) + .WithName("ResetPassword") + .WithSummary("Canjea el token de restablecimiento y fija la nueva contraseña.") + .AllowAnonymous() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest); + group.MapPost("/user-signup", async ( SignupUserCommand command, IMediator mediator, @@ -115,6 +149,9 @@ private static async Task HandleLoginAsync( IMediator mediator, IJwtTokenService jwtTokenService, Ums.Application.Configuration.Services.IConfigurationProvider configProvider, + Ums.Application.Identity.Auth.IRefreshTokenPolicyProvider refreshPolicyProvider, + Ums.Application.Identity.Auth.IRefreshTokenStore refreshTokenStore, + Ums.Application.Common.Interfaces.IFunctionalTransaction functionalTransaction, HttpContext httpContext, CancellationToken cancellationToken) { @@ -124,10 +161,12 @@ private static async Task HandleLoginAsync( string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { + // ADR-0096 §2.2 / ADR-UMS-084: al usuario se le entrega el localizador legible, + // no el TraceIdentifier crudo. return Results.BadRequest(new LoginErrorResponse( ErrorCodes.ValidationError, "Tenant code, username and password are required.", - SupportReferenceId: supportReferenceId)); + SupportReferenceId: await functionalTransaction.GetOrMintLocatorAsync(cancellationToken))); } var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; @@ -137,22 +176,28 @@ private static async Task HandleLoginAsync( Username: request.Username.Trim(), Password: request.Password, ClientIp: clientIp, - AccessScope: Ums.Domain.Identity.Auth.AuthAccessScope.PortalManagement, + AccessScope: Ums.Domain.Identity.Auth.AuthAccessScope.ExternalApi, RememberMe: request.RememberMe); var result = await mediator.Send(command, cancellationToken); if (result.IsFailure) { - return MapAuthError(result.Error, supportReferenceId); + return MapAuthError(result.Error, await functionalTransaction.GetOrMintLocatorAsync(cancellationToken)); } var authResult = result.Value; var graph = authResult.Graph; var ctx = graph.Context; + // G-247: la sesión se acuña UNA vez y viaja en los dos portadores —cookie y JWT—, porque + // un mismo dispositivo puede hablar por cualquiera de los dos y cerrar sesión debe cerrarlo + // entero. Es lo que permite que el logout cierre este dispositivo y solo este: la clave de + // cierre es la sesión, no el usuario. + var sessionId = Guid.NewGuid().ToString("N"); + // Generate graph JWT - var jwtToken = jwtTokenService.GenerateGraphToken(graph); + var jwtToken = jwtTokenService.GenerateGraphToken(graph, sessionId); // Set session cookie for web frontend var isInternalAdmin = ctx.Tenant.IsManagementOwner; @@ -163,13 +208,22 @@ private static async Task HandleLoginAsync( new("tenant_id", ctx.Tenant.Id.ToString()), new("tenant_code", ctx.Tenant.Code), new("username", ctx.User.Username), - new(ClaimTypes.Role, ctx.Role.Code), - new("role_name", ctx.Role.Name), - new("profile_id", ctx.Profile.Id.ToString()), - new("sys_suite", ctx.SystemSuite.Code), new("auth_method", graph.Authentication.Method), new("is_internal_admin", isInternalAdmin ? "true" : "false"), + new("sid", sessionId), }; + // G-122: en el grafo lobby (onboarding pendiente, cuenta activa SIN perfil) Role/Profile/ + // SystemSuite son null; añadir esos claims incondicionalmente provocaba NullReferenceException + // → 500 en el login. Se agregan solo si existen (igual que Branch), preservando el flujo lobby. + if (ctx.Role is not null) + { + cookieClaims.Add(new Claim(ClaimTypes.Role, ctx.Role.Code)); + cookieClaims.Add(new Claim("role_name", ctx.Role.Name)); + } + if (ctx.Profile is not null) + cookieClaims.Add(new Claim("profile_id", ctx.Profile.Id.ToString())); + if (ctx.SystemSuite is not null) + cookieClaims.Add(new Claim("sys_suite", ctx.SystemSuite.Code)); if (ctx.Branch is not null) cookieClaims.Add(new Claim("branch_id", ctx.Branch.Id.ToString())); @@ -204,14 +258,30 @@ await httpContext.SignInAsync( DefaultTimezone: cfg.DefaultTimezone); // Build enriched permissions array from Allow options for backward compat - var permissions = graph.MenuAccess - .SelectMany(m => m.Menus) - .SelectMany(m => m.SubMenus) - .SelectMany(s => s.Options) - .Where(o => o.Effect == AccessEffect.Allow) - .Select(o => $"{o.Code}:{o.ActionCode}") + var permissions = GraphNavigation.AllowedPairs(graph) + .Select(p => $"{p.Code}:{p.ActionCode}") .ToArray(); + // Refresh token (ADR-UMS-091/FR-015): solo si el inquilino activó la capacidad. + // Fail-closed: política deshabilitada ⇒ no se emite (modelo de ADR-UMS-088 intacto). + string? refreshTokenPlaintext = null; + var refreshExpiresIn = request.RememberMe ? 604800 : 86400; + var refreshPolicy = refreshPolicyProvider.Resolve(tenantId); + if (refreshPolicy.Enabled) + { + refreshTokenPlaintext = jwtTokenService.GenerateRefreshToken(); + var issuedAtUtc = DateTime.UtcNow; + await refreshTokenStore.IssueAsync( + tenantId, + ctx.User.Id, + Guid.NewGuid(), + Ums.Application.Identity.Auth.RefreshTokenHasher.Hash(refreshTokenPlaintext), + issuedAtUtc, + issuedAtUtc.AddMinutes(refreshPolicy.LifetimeMinutes), + cancellationToken); + refreshExpiresIn = refreshPolicy.LifetimeMinutes * 60; + } + return Results.Ok(new LoginSuccessResponse( SessionId: supportReferenceId, SessionTrackingId: $"{supportReferenceId}-session", @@ -221,19 +291,52 @@ await httpContext.SignInAsync( TenantId: tenantId.ToString(), TenantCode: ctx.Tenant.Code, TenantName: ctx.Tenant.Name, - Role: ctx.Role.Code, - RoleName: ctx.Role.Name, - ProfileId: ctx.Profile.Id.ToString(), + // G-122: en el grafo lobby (onboarding pendiente) Role/Profile son null; se serializan como + // null y el cliente reacciona a AuthorizationGraph.onboardingPending (antes NRE → 500). + Role: ctx.Role?.Code, + RoleName: ctx.Role?.Name, + ProfileId: ctx.Profile?.Id.ToString(), Permissions: permissions, Language: sessionParams.DefaultLanguage, Token: jwtToken, TokenType: "Bearer", ExpiresIn: authResult.ExpiresIn, - RefreshExpiresIn: request.RememberMe ? 604800 : 86400, + RefreshExpiresIn: refreshExpiresIn, IsInternalAdmin: isInternalAdmin, SessionParameters: sessionParams, - AuthorizationGraph: graph, - GraphFormat: authResult.GraphFormat)); + AuthorizationGraph: AuthGraphPayload.Build(graph), + GraphFormat: authResult.GraphFormat, + RefreshToken: refreshTokenPlaintext)); + } + + /// + /// Canje del token de restablecimiento. Todo fallo del canje se traduce al MISMO 400 con el + /// mismo texto: el detalle interno (token inexistente, vencido, ya gastado o cuenta + /// inhabilitada) diría al atacante qué tokens y qué cuentas existen, que es justo lo que el + /// flujo anónimo oculta. Se exceptúan los fallos de validación de entrada —la política de + /// contraseña— porque hablan de lo que el llamante acaba de escribir, no del estado del + /// servidor, y ocultarlos dejaría al usuario legítimo sin saber por qué se le rechaza. + /// + private static async Task HandleResetPasswordAsync( + ResetPasswordCommand command, + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + { + return Results.Ok(result.Value); + } + + var esFalloDeValidacion = result.Error.StartsWith("Validation.Failed", StringComparison.Ordinal); + + return Results.Json(new LoginErrorResponse( + esFalloDeValidacion ? ErrorCodes.ValidationError : ErrorCodes.InvalidResetToken, + esFalloDeValidacion + ? result.Error["Validation.Failed:".Length..].Trim() + : "El código de restablecimiento no es válido o ha expirado. Solicite uno nuevo.", + SupportReferenceId: null), statusCode: StatusCodes.Status400BadRequest); } private static IResult MapAuthError(string error, string supportReferenceId) => error switch @@ -250,12 +353,101 @@ var e when e.StartsWith("AUTH_006") => Results.Json(new LoginErrorResponse( ErrorCodes.InvalidCredentials, "No pudimos iniciar sesión. Verifique sus credenciales.", supportReferenceId), statusCode: 401), var e when e.StartsWith("AUTH_011") => Results.Json(new LoginErrorResponse( ErrorCodes.MfaEnrollmentRequired, "Se requiere MFA. Registre y verifique un método MFA para continuar.", supportReferenceId), statusCode: 403), + // ADR-UMS-095 (UMS-066): cuenta bloqueada temporalmente por intentos fallidos → 423 Locked. + // Mensaje accionable en español, sin revelar la validez de las credenciales. + // AUTH_017 (AUTH_012 ya está tomado con el significado «no IDP adapter registered»). + var e when e.StartsWith("AUTH_017") => Results.Json(new LoginErrorResponse( + ErrorCodes.AccountLocked, "Su cuenta fue bloqueada temporalmente por varios intentos fallidos. Espere unos minutos e intente nuevamente, o contacte al administrador.", supportReferenceId), statusCode: 423), + // FR-042 (ADR-UMS-097 §2.3): cadena de fallback de IdP agotada por indisponibilidad de infraestructura + // → 503 Service Unavailable (NO 401): el servicio de auth federada no está disponible, las credenciales + // no se pusieron en duda. AUTH_012 (sin adaptador) comparte la misma semántica de indisponibilidad. + // G-108: AUTH_035 (token endpoint OIDC 5xx/timeout/transporte) es INFRA → 503; el 4xx invalid_grant + // sigue en AUTH_021 (credencial), que colapsa al 401 por defecto y jamás llega aquí. + var e when e.StartsWith("AUTH_018") || e.StartsWith("AUTH_012") || e.StartsWith("AUTH_035") => Results.Json(new LoginErrorResponse( + "AUTH_018", "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", supportReferenceId), statusCode: 503), _ => Results.Json(new LoginErrorResponse("AUTH_000", "No pudimos iniciar sesión. Intente nuevamente.", supportReferenceId), statusCode: 401), }; + /// + /// Renovación por refresh token opaco (ADR-UMS-091/FR-015). Valida el token, regenera + /// el grafo COMPLETO, rota el refresh y devuelve el nuevo access token + grafo, para + /// que el frontend actualice su modelo de permisos sin re-login. + /// + private static async Task HandleRefreshTokenGrantAsync( + RefreshTokenRequest request, + IMediator mediator, + IJwtTokenService jwtTokenService, + Ums.Application.Common.Interfaces.IFunctionalTransaction functionalTransaction, + HttpContext httpContext, + CancellationToken cancellationToken) + { + if (request is null || string.IsNullOrWhiteSpace(request.RefreshToken)) + { + // ADR-0096 §2.2 / ADR-UMS-084: referencia legible para el usuario final. + return Results.BadRequest(new LoginErrorResponse( + ErrorCodes.ValidationError, + "Refresh token is required.", + SupportReferenceId: await functionalTransaction.GetOrMintLocatorAsync(cancellationToken))); + } + + var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; + + var result = await mediator.Send( + new RefreshAuthenticationCommand(request.RefreshToken, clientIp), + cancellationToken); + + if (result.IsFailure) + { + return MapRefreshError(result.Error, await functionalTransaction.GetOrMintLocatorAsync(cancellationToken)); + } + + var value = result.Value; + var graph = value.Graph; + var jwtToken = jwtTokenService.GenerateGraphToken(graph); + + var permissions = GraphNavigation.AllowedPairs(graph) + .Select(p => $"{p.Code}:{p.ActionCode}") + .ToArray(); + + return Results.Ok(new RefreshTokenGrantResponse( + Token: jwtToken, + TokenType: "Bearer", + ExpiresIn: value.ExpiresIn, + RefreshToken: value.NewRefreshToken, + RefreshExpiresIn: value.RefreshExpiresIn, + Permissions: permissions, + AuthorizationGraph: AuthGraphPayload.Build(graph), + GraphFormat: value.GraphFormat)); + } + + private static IResult MapRefreshError(string error, string supportReferenceId) + { + // La capacidad apagada es una decisión de política ⇒ 403; el resto son + // credenciales de renovación inválidas ⇒ 401. + var code = error.Split(':', 2)[0]; + var statusCode = code == RefreshErrorCodes.Disabled ? 403 : 401; + var clientCode = code == RefreshErrorCodes.Disabled + ? ErrorCodes.AccessDenied + : ErrorCodes.SessionExpired; + return Results.Json( + new LoginErrorResponse(clientCode, "No se pudo renovar la sesión. Inicie sesión nuevamente.", supportReferenceId), + statusCode: statusCode); + } + + /// + /// Refresh deslizante por cookie de sesión (D-019 / ADR-UMS-091). Espeja el login: + /// resuelve el principal autenticado desde la cookie, regenera el grafo de autorización + /// vigente (RefreshSessionCommand → IAuthorizationGraphBuilder) y emite un GRAPH JWT que + /// porta los permisos actuales, en vez de re-firmar un JWT con permisos vacíos. Cierra la + /// brecha de latencia de permisos del refresh (los permisos ya no quedan estancados hasta el + /// próximo re-login). No es el flujo de refresh token opaco (/refresh-token), que sigue intacto. + /// private static async Task HandleRefreshTokenAsync( + IMediator mediator, IJwtTokenService jwtTokenService, - HttpContext httpContext) + Ums.Application.Configuration.Services.IConfigurationProvider configProvider, + HttpContext httpContext, + CancellationToken cancellationToken) { if (!httpContext.User.Identity?.IsAuthenticated ?? true) { @@ -265,51 +457,108 @@ private static async Task HandleRefreshTokenAsync( SupportReferenceId: null), statusCode: 401); } - var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); - var email = httpContext.User.FindFirstValue(ClaimTypes.Name); - var username = httpContext.User.FindFirstValue("username") ?? email ?? ""; + var userIdStr = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); var tenantIdStr = httpContext.User.FindFirstValue("tenant_id"); - var tenantCode = httpContext.User.FindFirstValue("tenant_code") ?? ""; - var role = httpContext.User.FindFirstValue(ClaimTypes.Role); - var roleName = httpContext.User.FindFirstValue("role_name"); - var profileId = httpContext.User.FindFirstValue("profile_id"); - if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(tenantIdStr) || !Guid.TryParse(tenantIdStr, out var tenantId)) + if (!Guid.TryParse(userIdStr, out var userId) || !Guid.TryParse(tenantIdStr, out var tenantId)) { return Results.Unauthorized(); } - var newToken = jwtTokenService.GenerateToken(new TokenGenerationRequest( - UserId: userId, - Email: email ?? "", - Username: username, - TenantId: tenantId, - TenantCode: tenantCode, - Role: role, - RoleName: roleName, - ProfileId: profileId, - Permissions: Array.Empty(), - Language: "en" - )); + var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; + + // Regenera el grafo vigente (espejo del login) en vez de re-firmar claims vacíos. + var result = await mediator.Send( + new RefreshSessionCommand(userId, tenantId, clientIp), + cancellationToken); + + if (result.IsFailure) + { + // El principal ya no puede renovar (inquilino/usuario inactivo o grafo irreconstruible). + return Results.Json(new LoginErrorResponse( + ErrorCodes.SessionExpired, + "Session expired or invalid", + SupportReferenceId: null), statusCode: 401); + } + + var value = result.Value; + var graph = value.Graph; + + // Graph JWT: el token ES el grafo, portando los permisos actuales (igual que el login). + var newToken = jwtTokenService.GenerateGraphToken(graph); + + var permissions = GraphNavigation.AllowedPairs(graph) + .Select(p => $"{p.Code}:{p.ActionCode}") + .ToArray(); + + // Valores derivados de la config efectiva del inquilino, como en el login (no hardcodeados). + var cfg = configProvider.ForTenant(tenantId); + var refreshExpiresIn = cfg.RefreshTokenDurationMs / 1000; var sessionTrackingId = httpContext.User.FindFirstValue("session_tracking_id") ?? httpContext.TraceIdentifier; return Results.Ok(new RefreshTokenResponse( Token: newToken, TokenType: "Bearer", - ExpiresIn: 3600, - RefreshExpiresIn: 604800, - SessionTrackingId: sessionTrackingId - )); + ExpiresIn: value.ExpiresIn, + RefreshExpiresIn: refreshExpiresIn, + SessionTrackingId: sessionTrackingId, + Permissions: permissions, + Language: cfg.DefaultLanguage, + AuthorizationGraph: AuthGraphPayload.Build(graph), + GraphFormat: value.GraphFormat)); } - private static async Task HandleLogoutAsync(HttpContext httpContext) + /// + /// Cierra la sesión ACTUAL, y solo esa. + /// + /// Hasta G-247 esto no cerraba nada: revocaba las familias de refresh y llamaba a + /// SignOutAsync, que solo le pide al navegador que borre la cookie. El portador seguía + /// siendo criptográficamente válido, así que quien tuviera una copia —una máquina compartida, + /// una captura de red— conservaba el acceso hasta que caducara solo. Se midió en vivo: tras el + /// logout, la misma cookie seguía devolviendo 200, también contra el mismo pod. + /// + /// La sesión se anota como cerrada en un almacén compartido, y + /// TokenRevocationMiddleware la consulta en cada petición autenticada. La clave es la + /// SESIÓN y no el usuario: cerrar sesión en el portátil no debe echar a nadie del móvil. Para + /// «esta cuenta queda fuera de todas partes» está ITokenRevocationStore, que es por + /// usuario y lo usan el bloqueo, el borrado y el cambio de contraseña. + /// + private static async Task HandleLogoutAsync( + HttpContext httpContext, + Ums.Application.Identity.Auth.IRefreshTokenStore refreshTokenStore, + ISessionRevocationStore sessionRevocationStore, + CancellationToken cancellationToken) { + // El plazo: hasta donde puede llegar el portador más largo que emite el login + // (`RememberMe` = 7 días). Recordar la sesión más allá no protege de nada —el portador ya + // no valdría— y solo haría crecer la lista. + var sessionId = httpContext.User.FindFirstValue("sid"); + if (!string.IsNullOrWhiteSpace(sessionId)) + { + await sessionRevocationStore.RevocarAsync( + sessionId, DateTime.UtcNow.AddDays(7), cancellationToken); + } + + // Logout real (ADR-UMS-091/FR-016): además de cerrar la cookie, revoca todas las + // familias vivas de refresh del usuario para que ningún refresh token siga sirviendo. + var userIdStr = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); + var tenantIdStr = httpContext.User.FindFirstValue("tenant_id"); + if (Guid.TryParse(userIdStr, out var userId) && Guid.TryParse(tenantIdStr, out var tenantId)) + { + await refreshTokenStore.RevokeAllForUserAsync( + tenantId, + userId, + reason: "logout", + revokedAtUtc: DateTime.UtcNow, + cancellationToken); + } + await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Results.Ok(new { message = "Logged out successfully" }); } - private static IResult HandleGetSessionAsync(HttpContext httpContext, ITenantContext tenantContext) + private static IResult HandleGetSessionAsync(HttpContext httpContext) { if (!httpContext.User.Identity?.IsAuthenticated ?? true) { @@ -317,7 +566,14 @@ private static IResult HandleGetSessionAsync(HttpContext httpContext, ITenantCon } var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); - var email = httpContext.User.FindFirstValue(ClaimTypes.Name); + + // G-191/G-187: este endpoint ya no lo consume solo el portal. Con portador, el principal + // viene del JWT, donde el correo viaja en `email` y el nombre en `name` —no en el claim + // `ClaimTypes.Name` que estampa la cookie—, así que sin estos respaldos la sesión de un + // satélite se respondía con usuario y correo vacíos. + var email = httpContext.User.FindFirstValue(ClaimTypes.Name) + ?? httpContext.User.FindFirstValue(ClaimTypes.Email) + ?? httpContext.User.FindFirstValue("email"); var tenantId = httpContext.User.FindFirstValue("tenant_id"); var tenantCode = httpContext.User.FindFirstValue("tenant_code"); var tenantName = httpContext.User.FindFirstValue("tenant_name"); @@ -331,7 +587,9 @@ private static IResult HandleGetSessionAsync(HttpContext httpContext, ITenantCon SessionId: httpContext.TraceIdentifier, SessionTrackingId: sessionTrackingId, UserId: userId ?? "", - Username: httpContext.User.FindFirstValue("username") ?? email ?? "", + Username: httpContext.User.FindFirstValue("username") + ?? httpContext.User.FindFirstValue("name") + ?? email ?? "", Email: email ?? "", TenantId: tenantId ?? "", TenantCode: tenantCode ?? "", @@ -349,6 +607,169 @@ private static IResult HandleGetSessionAsync(HttpContext httpContext, ITenantCon )); } + /// + /// Cambia el perfil vigente y devuelve la MISMA forma que el login, para que el cliente + /// reutilice tal cual su código de inicialización: grafo nuevo, token nuevo y cookie reescrita. + /// + /// Nota deliberada sobre el token anterior: NO se revoca. `ITokenRevocationStore` revoca por + /// usuario y ventana de tiempo, no por token, así que revocar aquí dejaría al usuario fuera de + /// la aplicación inmediatamente después de cambiarse de perfil —incluido el token que se acaba + /// de emitir—. Y no hay escalada: el usuario poseía legítimamente ambos perfiles, de modo que + /// mantener el token viejo vivo hasta que expire equivale a dos sesiones con dos sombreros. + /// Un solo perfil activo a la vez exigiría revocación por `jti`, que hoy no existe. + /// + private static async Task HandleSwitchProfileAsync( + SwitchProfileRequest request, + IMediator mediator, + IJwtTokenService jwtTokenService, + Ums.Application.Configuration.Services.IConfigurationProvider configProvider, + IConfiguration configuration, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var principal = LeerTokenDeGrafo(httpContext, configuration); + if (principal is null) return Results.Unauthorized(); + + var (userId, tenantId) = principal.Value; + + if (!Guid.TryParse(request.ProfileId, out var profileId)) + { + return Results.BadRequest(new LoginErrorResponse( + ErrorCodes.ValidationError, "Identificador de perfil inválido.", SupportReferenceId: null)); + } + + var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; + var result = await mediator.Send( + new SwitchProfileCommand(profileId, userId, tenantId, clientIp), cancellationToken); + + if (result.IsFailure) + { + var codigo = result.Error switch + { + var e when e.StartsWith("AUTH_020") => StatusCodes.Status404NotFound, + var e when e.StartsWith("AUTH_021") => StatusCodes.Status409Conflict, + var e when e.StartsWith("AUTH_005") => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status400BadRequest, + }; + + return Results.Json(new LoginErrorResponse( + ErrorCodes.AccessDenied, + codigo == StatusCodes.Status404NotFound + ? "El perfil solicitado no existe o no le pertenece." + : "No se pudo cambiar de perfil.", + SupportReferenceId: null), statusCode: codigo); + } + + var graph = result.Value.Graph; + var ctx = graph.Context; + var token = jwtTokenService.GenerateGraphToken(graph); + + // La cookie se reescribe con los claims del perfil NUEVO: si no, el rol de la cookie y el + // del token discreparían y la autorización dependería de cuál mirase cada middleware. + await ReescribirCookieDeSesionAsync(httpContext, ctx, graph); + + var cfg = configProvider.ForTenant(ctx.Tenant.Id); + var permissions = GraphNavigation.AllowedPairs(graph) + .Select(p => $"{p.Code}:{p.ActionCode}") + .ToArray(); + + return Results.Ok(new LoginSuccessResponse( + SessionId: httpContext.TraceIdentifier, + SessionTrackingId: $"{httpContext.TraceIdentifier}-session", + UserId: ctx.User.Id.ToString(), + Username: ctx.User.Username, + Email: ctx.User.Email, + TenantId: ctx.Tenant.Id.ToString(), + TenantCode: ctx.Tenant.Code, + TenantName: ctx.Tenant.Name, + Role: ctx.Role?.Code, + RoleName: ctx.Role?.Name, + ProfileId: ctx.Profile?.Id.ToString(), + Permissions: permissions, + Language: cfg.DefaultLanguage, + Token: token, + TokenType: "Bearer", + ExpiresIn: result.Value.ExpiresIn, + RefreshExpiresIn: null, // el refresh token vigente no se rota: la sesión es la misma + IsInternalAdmin: ctx.Tenant.IsManagementOwner, + SessionParameters: null, + AuthorizationGraph: AuthGraphPayload.Build(graph), + GraphFormat: "JSON", + RefreshToken: null)); + } + + /// + /// Valida el token de grafo del encabezado y devuelve (usuario, inquilino). Mismo criterio que + /// `HandleSwitchTenantAsync`: se valida aquí y no con `RequireAuthorization` para no depender + /// de que el esquema JWT esté configurado en el entorno. + /// + private static (Guid UserId, Guid TenantId)? LeerTokenDeGrafo(HttpContext httpContext, IConfiguration configuration) + { + var authHeader = httpContext.Request.Headers["Authorization"].FirstOrDefault(); + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + + var tokenString = authHeader["Bearer ".Length..].Trim(); + var secret = configuration["Jwt:Secret"] ?? throw new InvalidOperationException("Jwt:Secret is not configured"); + + var handler = new JwtSecurityTokenHandler(); + try + { + handler.ValidateToken(tokenString, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)), + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + }, out _); + } + catch (SecurityTokenException) + { + return null; + } + + var jwt = handler.ReadJwtToken(tokenString); + var sub = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value; + var ten = jwt.Claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value; + + return Guid.TryParse(sub, out var userId) && Guid.TryParse(ten, out var tenantId) + ? (userId, tenantId) + : null; + } + + private static async Task ReescribirCookieDeSesionAsync( + HttpContext httpContext, + GraphContext ctx, + AuthorizationGraph graph) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, ctx.User.Id.ToString()), + new(ClaimTypes.Name, ctx.User.Email), + new("tenant_id", ctx.Tenant.Id.ToString()), + new("tenant_code", ctx.Tenant.Code), + new("username", ctx.User.Username), + new("auth_method", graph.Authentication.Method), + new("is_internal_admin", ctx.Tenant.IsManagementOwner ? "true" : "false"), + }; + + if (ctx.Role is not null) + { + claims.Add(new Claim(ClaimTypes.Role, ctx.Role.Code)); + claims.Add(new Claim("role_name", ctx.Role.Name)); + } + if (ctx.Profile is not null) claims.Add(new Claim("profile_id", ctx.Profile.Id.ToString())); + if (ctx.SystemSuite is not null) claims.Add(new Claim("sys_suite", ctx.SystemSuite.Code)); + if (ctx.Branch is not null) claims.Add(new Claim("branch_id", ctx.Branch.Id.ToString())); + + await httpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)), + new AuthenticationProperties { IsPersistent = false }); + } + private static async Task HandleSwitchTenantAsync( SwitchTenantRequest request, ITenantContext tenantContext, @@ -380,7 +801,7 @@ private static async Task HandleSwitchTenantAsync( try { - tokenHandler.ValidateToken(tokenString, validationParameters, out var validatedToken); + tokenHandler.ValidateToken(tokenString, validationParameters, out _); } catch (SecurityTokenException) { @@ -515,20 +936,48 @@ public record LoginSuccessResponse( bool IsInternalAdmin = false, SessionParameters? SessionParameters = null, // ── Graph fields (null when called from refresh/session endpoints) ────── - Ums.Domain.Authorization.Graph.AuthorizationGraph? AuthorizationGraph = null, - string? GraphFormat = null); + IReadOnlyDictionary? AuthorizationGraph = null, + string? GraphFormat = null, + // Refresh token en claro — solo cuando el inquilino activa la capacidad (ADR-UMS-091/FR-015); + // null en caso contrario. Se devuelve una única vez y nunca se persiste en claro. + string? RefreshToken = null); public record LoginErrorResponse( string Code, string Message, string? SupportReferenceId); +// ── Renovación por refresh token opaco (ADR-UMS-091 / FR-015) ────────────────────── +public record RefreshTokenRequest(string RefreshToken); + +public record RefreshTokenGrantResponse( + string Token, + string TokenType, + int ExpiresIn, + // Nuevo refresh en claro tras la rotación; null si la política no rota (el cliente + // conserva el suyo). Se devuelve una única vez y nunca se persiste en claro. + string? RefreshToken, + int RefreshExpiresIn, + string[] Permissions, + // Grafo recién regenerado (completo) para que la UI actualice su modelo de permisos. + IReadOnlyDictionary? AuthorizationGraph = null, + string? GraphFormat = null); + public record RefreshTokenResponse( string Token, string TokenType, int ExpiresIn, int RefreshExpiresIn, - string SessionTrackingId); + string SessionTrackingId, + // ── D-019 / ADR-UMS-091: el refresh deslizante ahora espeja el login y regenera el grafo, + // así que el token/respuesta portan el modelo de permisos VIGENTE. Campos aditivos y + // opcionales: el front actual (auth.store.ts::refreshSession) los ignora sin romperse. ── + string[]? Permissions = null, + string? Language = null, + IReadOnlyDictionary? AuthorizationGraph = null, + string? GraphFormat = null); + +public record SwitchProfileRequest(string ProfileId); public record SwitchTenantRequest( string TenantId, @@ -557,10 +1006,20 @@ public static class ErrorCodes public const string AdminLacksPermission = "AUTH_009"; public const string TargetUserOutsideScope = "AUTH_010"; public const string MfaEnrollmentRequired = "AUTH_011"; + // NOTA: AUTH_012 está reservado para «no IDP adapter registered» (UmsErrorCodes.NoIdpAdapterRegistered). + // ADR-UMS-095: cuenta bloqueada temporalmente por intentos fallidos (HTTP 423 Locked) usa AUTH_017. + public const string AccountLocked = "AUTH_017"; + // G-188: único desenlace de error del canje de restablecimiento. Deliberadamente indistinguible + // entre token inexistente, vencido, ya usado y cuenta inhabilitada. + public const string InvalidResetToken = "AUTH_019"; } public static class UserAccountErrorCodes { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", "S2068:Hard-coded credentials are security-sensitive", + Justification = "Falso positivo: es un CÓDIGO DE ERROR ('USER_015'), no una credencial. El analizador " + + "casa por el término 'Password' en el nombre del símbolo.")] public const string FederatedUserPasswordReset = "USER_015"; } diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs index 75bb49e5..8352bb20 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs @@ -6,6 +6,10 @@ namespace Ums.Presentation.Endpoints.Identity.Auth; using Ums.Domain.Authorization.Graph; using Ums.Presentation.Services; using BeyondNetCode.Shell.Factory.Interfaces; +using System.Security.Claims; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using UserAccountAgregado = Ums.Domain.Identity.UserAccount.UserAccount; /// /// Public external API for client system authentication. @@ -34,6 +38,336 @@ public static void MapClientAuthEndpoints(this WebApplication app) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); + + // El grafo, aparte de la autenticación y con el portador que ya tiene el satélite. + // + // Existe porque el grafo NO viaja dentro del JWT (D-031): meterlo ahí serían decenas de KB + // en cada cabecera y, sobre todo, un grafo irrevocable —un permiso retirado seguiría + // valiendo hasta que expirara el token, porque el token sería la fuente. Aquí la fuente + // sigue siendo UMS: el satélite revalida cuando su copia caduca, sin volver a pedir + // credenciales al usuario. + // + // Sirve además para responder «¿esta sesión sigue viva?»: un 401 aquí es la respuesta. + // G-191: la política `Satelite` fija el esquema PORTADOR. Con `RequireAuthorization()` a + // secas se autenticaba por el esquema por defecto —la cookie—, y un satélite con un + // portador válido recibía 302 al formulario de acceso en vez de 200 o 401. + group.MapGet("/graph", HandleClientGraphAsync) + .WithName("ClientGraph") + .WithSummary("Devuelve el grafo de autorización vigente del portador") + .RequireAuthorization(UmsAuthPolicies.Satelite) + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + // Cambio de perfil por el carril de satélite (ADR-0156 §8, G-206). + // + // El grafo publica `profiles[]` con su `id` desde 2.4.0, es decir, invita al cliente a + // cambiarse de perfil. Hasta ahora la única operación que lo hacía era + // `POST /api/v1/auth/switch-profile`, que el portador semántico NO puede usar: valida el + // token a mano exigiendo un `sub` GUID y un claim `tenant_id` que ese token no lleva. + // + // NO se extiende aquel endpoint, y no es por comodidad. Valida el token a mano con + // `ValidateIssuer=false` y `ValidateAudience=false` (G-201): encaminar el carril de + // satélite por ahí lo haría entrar por la puerta más floja de la API justo cuando lo que + // se quiere es acotarlo. Además devuelve la forma del portal y reescribe una cookie de + // sesión que un satélite no tiene ni debe recibir. + // + // Lo único nuevo es este adaptador HTTP: `SwitchProfileCommand` y `BuildForProfileAsync` + // se reutilizan sin tocarlos, así que las comprobaciones de pertenencia —las que impiden + // que enviar el id del perfil de un administrador sea una escalada de una línea— son + // exactamente las mismas que ya protegen al portal. + group.MapPost("/switch-profile", HandleClientSwitchProfileAsync) + .WithName("ClientSwitchProfile") + .WithSummary("Cambia el perfil vigente del portador y devuelve el grafo del perfil nuevo") + .RequireAuthorization(UmsAuthPolicies.Satelite) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + } + + private static async Task HandleClientGraphAsync( + IUserAccountRepository userAccounts, + ITenantRepository tenants, + IAuthorizationGraphBuilder graphBuilder, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var supportReferenceId = httpContext.TraceIdentifier; + var principal = httpContext.User; + + // G-191: el token que entrega `/client/authenticate` es el SEMÁNTICO — lleva el inquilino + // por CÓDIGO y su `sub` es el nombre de usuario, no un GUID, porque el token para sistemas + // externos evita identificadores internos a propósito. Exigir un GUID aquí devolvía 401 + // incluso con un portador válido. + // + // La resolución vive en un solo sitio y la comparten los dos endpoints de portador: si + // cada uno la escribiera, acabarían divergiendo en el status o en el mensaje, y esa + // diferencia es precisamente lo que un atacante mide. + var (tenant, user, errorDeToken) = await ResolverContextoDelPortadorAsync( + userAccounts, tenants, principal, supportReferenceId, cancellationToken); + + if (errorDeToken is not null) return errorDeToken; + + // Se RECONSTRUYE, no se devuelve una copia guardada: el sentido de pedirlo aparte es que + // refleje los permisos de ahora, no los de cuando el usuario entró. + // El método de autenticación solo describe CÓMO entró el usuario y no altera qué permisos + // tiene, que es lo único que se pide aquí. Reconstruir el proveedor IdP exacto exigiría un + // claim que el token no lleva, así que no se finge: se declara local. + // + // El sistema sale del claim `sys_suite` del propio portador, que es el que se emitió al + // autenticar. Sin él, un satélite que entró pidiendo `SDLC` recibiría al revalidar un + // grafo de otro alcance —y por tanto otros menús y otros permisos— sin haber pedido nada + // distinto. No se acepta el sistema por parámetro aquí a propósito: el portador ya dice de + // qué sistema es, y dejar que el llamante lo contradiga abriría un camino para pedir el + // grafo de un sistema distinto con un token acotado a otro. + // + // Un token de `NoProfileInSystem` NO lleva `sys_suite` (ADR-0156 §5.4). Ahí el eco se + // pierde y la revalidación devuelve el grafo sin acotar, que es lo correcto: en cuanto un + // administrador le asigne el perfil, el satélite lo verá sin volver a pedir credenciales. + var suiteDelToken = principal.FindFirst("sys_suite")?.Value; + + // El PERFIL VIGENTE también sale del token (ADR-0156 §8). Reconstruir por desempate + // ignoraba la conmutación: un usuario que acababa de cambiarse a PMO revalidaba y recibía + // DIRECTORIO otra vez, porque el desempate no sabe que hubo una elección. Con el claim, la + // conmutación sobrevive a la revalidación, que es lo único que la hace útil. + // + // El id NO se toma al pie de la letra: `BuildForProfileAsync` vuelve a comprobar que el + // perfil pertenezca a este usuario y a este inquilino y que esté activo. Un token cuyo + // perfil se desactivó desde que se emitió cae al desempate, que es el comportamiento + // correcto —tenía acceso, se le retiró ese sombrero, le quedan los demás— y no un 500. + var perfilDelToken = principal.FindFirst("profile_id")?.Value; + + var porPerfil = Guid.TryParse(perfilDelToken, out var perfilId) + ? await graphBuilder.BuildForProfileAsync( + user, tenant.Props.Id.GetValue(), perfilId, AuthMethod.Local(), + suiteDelToken, cancellationToken) + : null; + + // Sin perfil en el token —o con uno que ya no resuelve— se cae al desempate, UNA vez. No + // es un atajo: un token emitido antes de que existiera el claim, o cuyo perfil se desactivó + // desde entonces, debe seguir sirviendo el grafo que le corresponda hoy, no un 500. + var grafo = porPerfil is { IsSuccess: true } + ? porPerfil + : await graphBuilder.BuildAsync( + user, tenant.Props.Id.GetValue(), AuthMethod.Local(), suiteDelToken, cancellationToken); + + if (grafo.IsFailure) + { + return Results.Json(new ClientAuthErrorResponse("AUTH_021", + "No se pudo construir el grafo de autorización.", supportReferenceId), + statusCode: StatusCodes.Status500InternalServerError); + } + + return Results.Ok(AuthGraphPayload.Build(grafo.Value)); + } + + private static async Task HandleClientSwitchProfileAsync( + ClientSwitchProfileRequest request, + IUserAccountRepository userAccounts, + ITenantRepository tenants, + IMediator mediator, + IJwtTokenService jwtService, + IAuthGraphFormatProvider formatProvider, + IFactory factory, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var supportReferenceId = httpContext.TraceIdentifier; + + if (request.ProfileId == Guid.Empty) + { + return Results.Json(new ClientAuthErrorResponse("AUTH_001", + "ProfileId es obligatorio.", supportReferenceId), + statusCode: StatusCodes.Status400BadRequest); + } + + var (tenant, user, errorDeToken) = await ResolverContextoDelPortadorAsync( + userAccounts, tenants, httpContext.User, supportReferenceId, cancellationToken); + + if (errorDeToken is not null) return errorDeToken; + + // El sistema que acota el bloque `profiles` sale del claim del PORTADOR, no del cuerpo: + // el `systemCode` del cuerpo es la guarda de coherencia y el llamante podría omitirlo o + // contradecirlo, mientras que el claim lo emitió UMS al autenticar y el cliente no puede + // alterarlo sin invalidar la firma. + var comando = new SwitchProfileCommand( + ProfileId: request.ProfileId, + UserId: user!.Props.Id.GetValue(), + TenantId: tenant!.Props.Id.GetValue(), + ClientIp: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + SystemCode: httpContext.User.FindFirst("sys_suite")?.Value); + + var resultado = await mediator.Send(comando, cancellationToken); + + if (resultado.IsFailure) + { + var code = ExtractCode(resultado.Error); + // AUTH_020 cubre «no existe» y «no es tuyo», que el manejador ya colapsa en el mismo + // error a propósito: distinguirlos convertiría este endpoint en un detector de + // perfiles ajenos para cualquiera con un portador válido. AUTH_021 es «inactivo». + var statusCode = code switch + { + "AUTH_020" => StatusCodes.Status404NotFound, + "AUTH_021" => StatusCodes.Status409Conflict, + "AUTH_005" => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status401Unauthorized, + }; + return Results.Json(new ClientAuthErrorResponse( + code, SpanishMessage(code), supportReferenceId), + statusCode: statusCode); + } + + var grafo = resultado.Value.Graph; + + // Guarda de coherencia de sistema (ADR-0156 §8.5). `systemCode` es opcional y el satélite + // lo envía siempre. En el flujo normal NUNCA dispara, porque el bloque `profiles` que el + // cliente leyó ya venía acotado a su sistema; existe para que esa propiedad la garantice + // el SERVIDOR y no la disciplina del cliente, por si alguna vez llega un `profileId` que + // no salió de su propio grafo. + // + // Se comprueba DESPUÉS del cambio porque el sistema sale del grafo resultante, que es la + // fuente autoritativa. La consecuencia, declarada y no escondida: el manejador ya habrá + // registrado `Auth.Profile.Switch` como exitoso cuando esta guarda rechaza la respuesta. + // El perfil vigente NO cambia en la base —no hay estado de sesión que mutar, el perfil + // viaja en el token— así que el efecto es solo esa imprecisión de auditoría. Registrada + // como G-225. + var sistemaPedido = request.SystemCode?.Trim(); + if (!string.IsNullOrWhiteSpace(sistemaPedido) && + !string.Equals(grafo.Context.SystemSuite?.Code, sistemaPedido, StringComparison.OrdinalIgnoreCase)) + { + return Results.Json(new ClientAuthErrorResponse("AUTH_036", + "El perfil solicitado no pertenece al sistema indicado.", supportReferenceId), + statusCode: StatusCodes.Status409Conflict); + } + + var (formato, serializado) = await SerializarGrafoAsync( + grafo, request.Format, formatProvider, factory, httpContext, cancellationToken); + + // El token anterior NO se revoca (ADR-0156 §8.6): `ITokenRevocationStore` revoca por + // usuario y ventana de tiempo, no por token, así que revocar aquí dejaría al usuario fuera + // inmediatamente después de cambiarse —incluido el token recién emitido—. No hay escalada: + // el usuario poseía legítimamente ambos perfiles. Quien debe descartar su copia anterior + // es el satélite, reemplazando la entrada de su caché en vez de añadir otra. + var token = jwtService.GenerateSemanticGraphToken(grafo); + + httpContext.Response.Headers["X-Graph-Format"] = formato; + return Results.Ok(new ClientAuthResponse( + Token: token, + TokenType: "Bearer", + ExpiresIn: resultado.Value.ExpiresIn, + IssuedAt: resultado.Value.IssuedAt, + Format: formato, + Graph: serializado, + RequestId: httpContext.TraceIdentifier)); + } + + /// + /// Inquilino y cuenta del portador, con el mismo criterio que `GET /client/graph`: el token de + /// cliente lleva el inquilino por CÓDIGO y su `sub` puede ser un GUID o el nombre de usuario. + /// Devuelve el resultado de error ya formado cuando no resuelve, para que los dos endpoints + /// que dependen del portador no puedan divergir en el status ni en el mensaje. + /// + private static async Task<(Ums.Domain.Identity.Tenant.Tenant? Tenant, + UserAccountAgregado? Usuario, + IResult? Error)> ResolverContextoDelPortadorAsync( + IUserAccountRepository userAccounts, + ITenantRepository tenants, + ClaimsPrincipal principal, + string supportReferenceId, + CancellationToken cancellationToken) + { + var subject = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? principal.FindFirst("sub")?.Value; + var correo = principal.FindFirst(ClaimTypes.Email)?.Value + ?? principal.FindFirst("email")?.Value; + var tenantCode = principal.FindFirst("tenant_code")?.Value; + + if (string.IsNullOrWhiteSpace(tenantCode) || + (string.IsNullOrWhiteSpace(subject) && string.IsNullOrWhiteSpace(correo))) + { + return (null, null, Results.Json(new ClientAuthErrorResponse("AUTH_020", + "El token no identifica un usuario y un inquilino.", supportReferenceId), + statusCode: StatusCodes.Status401Unauthorized)); + } + + var tenant = await tenants.GetByCodeAsync(tenantCode.ToUpperInvariant(), cancellationToken); + var user = await ResolverUsuarioDelTokenAsync( + userAccounts, tenant, subject, correo, cancellationToken); + + // Mismo error para «no existe» y «no pertenece»: distinguirlos convertiría esto en un + // detector de cuentas e inquilinos para cualquiera con un token válido. + if (tenant is null || user is null || + user.Props.TenantId.GetValue() != tenant.Props.Id.GetValue()) + { + return (null, null, Results.Json(new ClientAuthErrorResponse("AUTH_020", + "No se encontró el contexto de autorización del token.", supportReferenceId), + statusCode: StatusCodes.Status404NotFound)); + } + + return (tenant, user, null); + } + + /// + /// Serializa el grafo en el formato negociado: parámetro explícito, cabecera `Accept` o el + /// que resuelva el inquilino. Compartido por la autenticación y el cambio de perfil para que + /// un satélite que ya sabe leer la respuesta del login sepa leer la del cambio sin aprender + /// nada nuevo. + /// + private static async Task<(string Formato, string Serializado)> SerializarGrafoAsync( + Ums.Domain.Authorization.Graph.AuthorizationGraph grafo, + string? formatoPedido, + IAuthGraphFormatProvider formatProvider, + IFactory factory, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var solicitado = formatoPedido?.ToUpperInvariant() + ?? GetFormatFromAcceptHeader(httpContext.Request.Headers.Accept.ToString()); + + var formato = await formatProvider.ResolveFormatAsync( + grafo.Context.Tenant.Id, solicitado, cancellationToken); + + var serializer = factory + .Create( + new GraphSerializationCriteria(formato)) + .SingleOrDefault(); + + // Sin serializador para el formato resuelto no se inventa uno ni se calla: JSON es el + // formato que todo consumidor del contrato entiende, y el encabezado dirá cuál viajó. + return serializer is null + ? ("JSON", System.Text.Json.JsonSerializer.Serialize( + Ums.Application.Authorization.Graph.Serializers.AuthGraphPayload.Build(grafo))) + : (formato, serializer.Serialize(grafo)); + } + + /// + /// Resuelve la cuenta que representa el portador. Por id cuando el `sub` es un GUID (token de + /// grafo del portal) y por correo dentro del inquilino cuando el `sub` es el nombre de usuario + /// (token semántico de `/client/authenticate`, que evita identificadores internos a propósito). + /// + private static async Task ResolverUsuarioDelTokenAsync( + IUserAccountRepository userAccounts, + Ums.Domain.Identity.Tenant.Tenant? tenant, + string? subject, + string? correo, + CancellationToken cancellationToken) + { + if (Guid.TryParse(subject, out var userGuid)) + { + return await userAccounts.GetByIdAsync(userGuid, cancellationToken); + } + + if (tenant is null || string.IsNullOrWhiteSpace(correo)) + { + return null; + } + + return await userAccounts.GetByTenantAndEmailAsync( + tenant.Props.Id.GetValue(), + Ums.Domain.Kernel.ValueObjects.Email.Create(correo), + cancellationToken: cancellationToken); } private static async Task HandleClientAuthenticateAsync( @@ -45,12 +379,16 @@ private static async Task HandleClientAuthenticateAsync( HttpContext httpContext, CancellationToken cancellationToken) { + // SD-08: los mensajes al cliente se emiten en español y siempre acompañados + // de un supportReferenceId para trazabilidad (mismo contrato que /auth/login). + var supportReferenceId = httpContext.TraceIdentifier; + if (string.IsNullOrWhiteSpace(request.TenantCode) || string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { return Results.Json(new ClientAuthErrorResponse("AUTH_001", - "TenantCode, Username and Password are required.", null), + "TenantCode, Username y Password son obligatorios.", supportReferenceId), statusCode: StatusCodes.Status400BadRequest); } @@ -62,15 +400,17 @@ private static async Task HandleClientAuthenticateAsync( Password: request.Password, ClientIp: clientIp, AccessScope: Ums.Domain.Identity.Auth.AuthAccessScope.ExternalApi, - RememberMe: false); + RememberMe: false, + SystemCode: request.SystemCode?.Trim()); var result = await mediator.Send(command, cancellationToken); if (result.IsFailure) { var statusCode = GetStatusCode(result.Error); + var code = ExtractCode(result.Error); return Results.Json(new ClientAuthErrorResponse( - ExtractCode(result.Error), CleanMessage(result.Error), null), + code, SpanishMessage(code), supportReferenceId), statusCode: statusCode); } @@ -125,15 +465,30 @@ private static async Task HandleClientAuthenticateAsync( // ── Helpers ────────────────────────────────────────────────────────────── + // G-053 (anti-enumeración): en este endpoint público y anónimo, «tenant no existe» + // (AUTH_002) y «tenant inactivo» (AUTH_003) NO deben distinguirse de «credenciales + // inválidas» (AUTH_006). Antes AUTH_002→404 y AUTH_003→400 permitían enumerar códigos + // de inquilino comparando el status contra el 401 de credenciales. Ahora todos colapsan + // a 401 con un mensaje genérico e indistinguible. AUTH_004/005 (nivel usuario, tras auth + // del IDP) y AUTH_011/012 (infraestructura) conservan su semántica. private static int GetStatusCode(string error) => error switch { - var e when e.StartsWith("AUTH_002") => StatusCodes.Status404NotFound, - var e when e.StartsWith("AUTH_003") => StatusCodes.Status400BadRequest, + var e when e.StartsWith("AUTH_002") => StatusCodes.Status401Unauthorized, + var e when e.StartsWith("AUTH_003") => StatusCodes.Status401Unauthorized, var e when e.StartsWith("AUTH_004") => StatusCodes.Status404NotFound, var e when e.StartsWith("AUTH_005") => StatusCodes.Status401Unauthorized, var e when e.StartsWith("AUTH_006") => StatusCodes.Status401Unauthorized, + // ADR-UMS-095: bloqueo temporal de cuenta (AUTH_017). Anti-enumeración G-053: en este endpoint + // público y anónimo NO se expone 423 Locked; colapsa a 401 e indistinguible de credenciales + // inválidas, para no revelar que la cuenta existe y está bloqueada. (En /auth/login sí es 423.) + var e when e.StartsWith("AUTH_017") => StatusCodes.Status401Unauthorized, var e when e.StartsWith("AUTH_011") => StatusCodes.Status503ServiceUnavailable, var e when e.StartsWith("AUTH_012") => StatusCodes.Status503ServiceUnavailable, + // FR-042 (ADR-UMS-097 §2.3): cadena de fallback de IdP agotada por indisponibilidad → 503, no 401. + var e when e.StartsWith("AUTH_018") => StatusCodes.Status503ServiceUnavailable, + // G-108 (ADR-UMS-097 §2.3): token endpoint OIDC indisponible (5xx/timeout/transporte) → 503, no 401. + // Es INFRA, no credencial (el 4xx invalid_grant es AUTH_021 y colapsa al 401 por defecto). + var e when e.StartsWith("AUTH_035") => StatusCodes.Status503ServiceUnavailable, _ => StatusCodes.Status401Unauthorized, }; @@ -143,11 +498,34 @@ private static string ExtractCode(string error) return idx > 0 ? error[..idx].Trim() : "AUTH_000"; } - private static string CleanMessage(string error) + // SD-08: traducción de los códigos de error del motor de autenticación a mensajes + // en español, seguros para el cliente (sin filtrar detalles internos). No se reusa el + // mensaje crudo del handler (en inglés y con posible detalle técnico). + private static string SpanishMessage(string code) => code switch { - var idx = error.IndexOf(':'); - return idx > 0 ? error[(idx + 1)..].Trim() : error; - } + // G-053 (anti-enumeración): AUTH_002 (tenant no existe) y AUTH_003 (tenant inactivo) + // devuelven el MISMO mensaje genérico que las credenciales inválidas, para no revelar + // la existencia ni el estado de un inquilino desde un endpoint público. + var c when c.StartsWith("AUTH_002") => "No pudimos autenticar. Verifique sus credenciales.", + var c when c.StartsWith("AUTH_003") => "No pudimos autenticar. Verifique sus credenciales.", + var c when c.StartsWith("AUTH_004") => "No pudimos autenticar. Verifique sus credenciales.", + var c when c.StartsWith("AUTH_005") => "Su cuenta no está activa. Contacte al administrador.", + var c when c.StartsWith("AUTH_006") => "No pudimos autenticar. Verifique sus credenciales.", + // ADR-UMS-095: bloqueo temporal (AUTH_017) — mismo mensaje genérico que credenciales inválidas + // (anti-enumeración G-053); el mensaje accionable de bloqueo solo se entrega en /auth/login. + var c when c.StartsWith("AUTH_017") => "No pudimos autenticar. Verifique sus credenciales.", + var c when c.StartsWith("AUTH_011") => "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", + var c when c.StartsWith("AUTH_012") => "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", + var c when c.StartsWith("AUTH_018") => "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", + var c when c.StartsWith("AUTH_035") => "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", + // Cambio de perfil (ADR-0156 §8.4). AUTH_020 cubre a la vez «el perfil no existe» y «el + // perfil no es tuyo»: el mensaje es deliberadamente el mismo, porque distinguirlos + // permitiría enumerar perfiles ajenos preguntando por identificadores. + var c when c.StartsWith("AUTH_020") => "No se encontró el perfil solicitado.", + var c when c.StartsWith("AUTH_021") => "El perfil solicitado no está activo.", + var c when c.StartsWith("AUTH_036") => "El perfil solicitado no pertenece al sistema indicado.", + _ => "No pudimos autenticar. Intente nuevamente.", + }; private static string? GetFormatFromAcceptHeader(string acceptHeader) => acceptHeader switch @@ -170,7 +548,33 @@ public record ClientAuthRequest( string Username, // email or identity reference string Password, // plaintext (Local) or MOCK-* (stub IDP) string? Format = null, // override graph format: JSON|XML|YAML|CSV - string[]? RequestedScopes = null); // optional scope filter (future use) + string[]? RequestedScopes = null, // optional scope filter (future use) + // ADR-0156 §3.2 — código del sistema que pide el grafo. OPCIONAL a propósito: un portal + // multiproducto lo omite y recibe los perfiles de todos sus sistemas; un satélite como el + // Tablero SDLC envía el suyo y recibe solo los de ese sistema. + // + // No se reutiliza `RequestedScopes` para esto: es un filtro de ÁMBITOS, no de sistema, y + // darle una segunda semántica lo dejaría inservible para la primera (§3.3). + string? SystemCode = null); + +/// +/// Cuerpo de POST /api/v1/client/switch-profile (ADR-0156 §8.2). +/// +/// +/// Obligatorio. Sale de graph.profiles[].id, que el contrato emite siempre desde 2.4.0. +/// Nunca se confía en él: el manejador comprueba que el perfil pertenezca al usuario y a su +/// inquilino antes de construir nada. +/// +/// +/// Opcional. Guarda de coherencia: si viene, el perfil debe pertenecer a ese sistema. En el flujo +/// normal no dispara, porque el bloque profiles ya viaja acotado; existe para que esa +/// propiedad la garantice el servidor y no la disciplina del cliente. +/// +/// Igual que en la autenticación: JSON|XML|YAML|CSV. +public record ClientSwitchProfileRequest( + Guid ProfileId, + string? SystemCode = null, + string? Format = null); /// /// Response from POST /api/v1/client/authenticate. diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/JwksEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/JwksEndpoints.cs new file mode 100644 index 00000000..eab9be03 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/JwksEndpoints.cs @@ -0,0 +1,73 @@ +namespace Ums.Presentation.Endpoints.Identity.Auth; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Ums.Presentation.Services; + +/// +/// El material público con el que cualquiera verifica un portador de UMS. +/// +/// +/// +/// Es la mitad que faltaba de ADR-0157. Firmar en RS256 sin publicar la clave +/// pública no sirve de nada: ningún satélite podría verificar, y la única salida +/// sería repartir material de firma, que es exactamente lo que la decisión elimina. +/// +/// +/// Anónimo a propósito. Una clave pública es pública: exigir +/// credenciales para obtenerla crearía el problema del huevo y la gallina —haría +/// falta un token válido para conseguir con qué validar tokens—. Es lo que hacen +/// todos los proveedores de identidad, y por lo mismo. +/// +/// +public static class JwksEndpoints +{ + /// Mapea el JWKS y el documento de descubrimiento. + /// Aplicación web. + public static void MapJwksEndpoints(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + _ = app.MapGet("/.well-known/jwks.json", (MaterialDeFirma firma, HttpContext contexto) => + { + // Cachear en el cliente, no en el servidor: el satélite guarda el JWKS y + // solo vuelve cuando ve un `kid` que no conoce. Diez minutos es corto a + // propósito —una rotación no debe tardar horas en propagarse— y suficiente + // para que la verificación no dependa de la red en cada petición. + contexto.Response.Headers.CacheControl = "public, max-age=600"; + + return Results.Ok(new { keys = new[] { firma.ComoJwkPublico() } }); + }) + .WithName("Jwks") + .WithTags("Discovery") + .WithSummary("Claves públicas con las que se verifica un portador emitido por UMS") + .AllowAnonymous(); + + // Tres campos y ni uno más (ADR-0157 §4.1). UMS **no** declara con esto + // conformidad como OpenID Provider, y no debe leerse así: el documento existe + // porque los validadores de .NET y de Node resuelven las claves a través de él, + // y publicarlo cuesta menos que cablear el `MetadataAddress` en cada satélite. + _ = app.MapGet("/.well-known/openid-configuration", ( + MaterialDeFirma firma, + IConfiguration configuracion, + HttpContext contexto) => + { + string emisor = configuracion["Jwt:Issuer"] ?? "ums-api"; + string baseUrl = $"{contexto.Request.Scheme}://{contexto.Request.Host}"; + + contexto.Response.Headers.CacheControl = "public, max-age=600"; + + return Results.Ok(new + { + issuer = emisor, + jwks_uri = $"{baseUrl}/.well-known/jwks.json", + id_token_signing_alg_values_supported = new[] { "RS256" }, + }); + }) + .WithName("OpenIdConfiguration") + .WithTags("Discovery") + .WithSummary("Descubrimiento mínimo: emisor, ubicación del JWKS y algoritmo de firma") + .AllowAnonymous(); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/BranchQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/BranchQueryEndpoints.cs index c72dbf61..c20dcdf1 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/BranchQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/BranchQueryEndpoints.cs @@ -7,19 +7,44 @@ public static class BranchQueryEndpoints { public static IEndpointRouteBuilder MapBranchQueryEndpoints(this IEndpointRouteBuilder app) { + // G-041 (SEGURIDAD): la estructura de sucursales del tenant exige autenticación. var group = app.MapGroup("/tenants/{tenantId:guid}/branches") - .WithTags("Branches - Queries"); + .WithTags("Branches - Queries") + .RequireAuthorization(); - group.MapGet("/", async (Guid tenantId, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapGet("/", async ( + Guid tenantId, + [FromQuery] bool? includeClosed, + IMediator mediator, + HttpContext context, + CancellationToken ct) => { - var result = await mediator.Send(new GetBranchesByTenantIdQuery(tenantId), ct); + var result = await mediator.Send(new GetBranchesByTenantIdQuery(tenantId, includeClosed ?? false), ct); return result.ToOk(context); }) .WithName("GetBranchesByTenantId") - .WithSummary("Get all branches for a tenant") + .WithSummary("Get all branches for a tenant (las cerradas definitivamente se excluyen salvo includeClosed=true)") .Produces>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound); + // ADR-0164: la BITÁCORA de la sucursal. Existe porque el estado solo dice cómo está hoy, y la + // pregunta de auditoría de un operador aduanero es cómo estaba cuando salió un despacho de + // hace años. Responde también para sucursales cerradas: ese es su caso de uso principal. + group.MapGet("/{branchId:guid}/bitacora", async ( + Guid tenantId, + Guid branchId, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var result = await mediator.Send(new GetBranchLifecycleQuery(tenantId, branchId), ct); + return result.ToOk(context); + }) + .WithName("GetBranchLifecycle") + .WithSummary("Bitácora de episodios de una sucursal: apertura, bajas, reaperturas y cierre definitivo") + .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound); + return app; } } diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/TenantQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/TenantQueryEndpoints.cs index d04e7b0a..ea21e08e 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/TenantQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/TenantQueryEndpoints.cs @@ -10,8 +10,10 @@ public static class TenantQueryEndpoints { public static IEndpointRouteBuilder MapTenantQueryEndpoints(this IEndpointRouteBuilder app) { + // G-041 (SEGURIDAD): la lectura de tenant por id exige autenticación. var group = app.MapGroup("/tenants") - .WithTags("Tenants - Queries"); + .WithTags("Tenants - Queries") + .RequireAuthorization(); // GetAllTenants lives in TenantEndpoints to share the same route group // and avoid Asp.Versioning GET-root shadowing on duplicate MapGroup("/tenants"). diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBranchEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBranchEndpoints.cs index d27de0fe..4ec835bd 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBranchEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBranchEndpoints.cs @@ -28,18 +28,40 @@ public static IEndpointRouteBuilder MapTenantBranchEndpoints(this IEndpointRoute .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + group.MapPut("/{branchId:guid}", async ( + Guid tenantId, + Guid branchId, + [FromBody] UpdateBranchRequest request, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var command = new UpdateBranchCommand(tenantId, branchId, request.Name, request.GeofencingMetadata); + var result = await mediator.Send(command, ct); + return result.ToNoContent(context); + }) + .WithName("UpdateBranch") + .WithSummary("Update a branch's editable data (name, geofencing)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + // ADR-0164: el verbo HTTP sigue siendo DELETE —es el verbo REST de la baja y el frontend ya + // lo invoca—, pero lo que ocurre detrás es un cierre DEFINITIVO con borrado lógico: la fila + // permanece y el código queda ocupado para siempre. No hay borrado físico que ofrecer. group.MapDelete("/{branchId:guid}", async ( Guid tenantId, Guid branchId, + [FromQuery] string? reason, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveBranchCommand(tenantId, branchId), ct); + var result = await mediator.Send(new CloseBranchCommand(tenantId, branchId, reason), ct); return result.ToNoContent(context); }) - .WithName("RemoveBranch") - .WithSummary("Remove a branch from a tenant") + .WithName("CloseBranch") + .WithSummary("Cierra definitivamente una sucursal (borrado lógico; la fila permanece y el código no se libera)") .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); @@ -81,3 +103,4 @@ public static IEndpointRouteBuilder MapTenantBranchEndpoints(this IEndpointRoute } public sealed record AddBranchRequest(string Code, string Name, string? GeofencingMetadata); +public sealed record UpdateBranchRequest(string Name, string? GeofencingMetadata); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantEndpoints.cs index edb0ca01..95dbeabb 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantEndpoints.cs @@ -10,6 +10,9 @@ namespace Ums.Presentation.Endpoints.Identity.Tenant; public sealed record SetManagementOwnerRequest(bool Value); +// FS-26 (G-024): datos generales editables del tenant. El Code es inmutable, no se envía. +public sealed record UpdateTenantRequest(string Name, string Type, string? CompanyReference); + public static class TenantEndpoints { public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilder app) @@ -35,7 +38,11 @@ public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilde page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, - string.IsNullOrWhiteSpace(criteria) ? "name" : criteria, + // criteria = campo de búsqueda; se pasa tal cual (null si no se envía). El handler/repo + // cae a sortBy por compatibilidad. Antes se forzaba a "name", lo que impedía buscar por + // código salvo enviando criteria=code explícito (G-014 residual: GetTenants_AfterCreate + // usaba sortBy=code sin criteria y dejaba de encontrar el tenant). + string.IsNullOrWhiteSpace(criteria) ? null : criteria, string.IsNullOrWhiteSpace(status) ? "all" : status, string.IsNullOrWhiteSpace(sortBy) ? "name" : sortBy, string.IsNullOrWhiteSpace(sortOrder) ? "asc" : sortOrder), ct); @@ -43,6 +50,8 @@ public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilde }) .WithName("GetAllTenants") .WithSummary("Get tenants using server-side pagination") + // G-041 (SEGURIDAD): el listado de tenants exige autenticación. + .RequireAuthorization() .Produces>(StatusCodes.Status200OK); // ── Commands ───────────────────────────────────────────────────────── @@ -80,6 +89,23 @@ public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilde .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + group.MapPut("/{tenantId:guid}", async ( + Guid tenantId, + UpdateTenantRequest body, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var result = await mediator.Send( + new UpdateTenantCommand(tenantId, body.Name, body.Type, body.CompanyReference), ct); + return result.ToNoContent(context); + }) + .WithName("UpdateTenant") + .WithSummary("Update a tenant's general data (name, type, company reference)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + group.MapPost("/{tenantId:guid}/set-management-owner", async ( Guid tenantId, SetManagementOwnerRequest body, diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/Queries/UserAccountQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/Queries/UserAccountQueryEndpoints.cs index a1160f18..2668fd78 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/Queries/UserAccountQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/Queries/UserAccountQueryEndpoints.cs @@ -8,8 +8,10 @@ public static class UserAccountQueryEndpoints { public static IEndpointRouteBuilder MapUserAccountQueryEndpoints(this IEndpointRouteBuilder app) { + // G-041 (SEGURIDAD): estas consultas exponen PII; solo usuarios autenticados. var group = app.MapGroup("/user-accounts") - .WithTags("UserAccounts - Queries"); + .WithTags("UserAccounts - Queries") + .RequireAuthorization(); group.MapGet("/{userAccountId:guid}", async (Guid userAccountId, IMediator mediator, HttpContext context, CancellationToken ct) => { diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs index f4a54a8b..0192d5ad 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs @@ -1,5 +1,7 @@ namespace Ums.Presentation.Endpoints.Identity.UserAccount; +#pragma warning disable S125 + using Ums.Application.Common; using Ums.Application.Identity.UserAccount.Commands; using Ums.Application.Identity.UserAccount.DTOs; @@ -38,6 +40,8 @@ public static IEndpointRouteBuilder MapUserAccountEndpoints(this IEndpointRouteB }) .WithName("GetAllUserAccounts") .WithSummary("Get user accounts using server-side pagination") + // G-041 (SEGURIDAD): el listado expone PII; exige autenticación. + .RequireAuthorization() .Produces>(StatusCodes.Status200OK); @@ -153,27 +157,6 @@ public static IEndpointRouteBuilder MapUserAccountEndpoints(this IEndpointRouteB // Historic credential reactivation and physical deletion remain intentionally // unavailable: password rotation retains inactive entries for security audit. - // group.MapPost("/{userAccountId:guid}/passwords/{credentialId:guid}/activate", async (Guid userAccountId, Guid credentialId, IMediator mediator, HttpContext context, CancellationToken ct) => - // { - // var result = await mediator.Send(new ActivateUserAccountPasswordCommand(userAccountId, credentialId), ct); - // return result.ToNoContent(context); - // }) - // .WithName("ActivateUserAccountPassword") - // .WithSummary("Activate an existing password credential") - // .Produces(StatusCodes.Status204NoContent) - // .ProducesProblem(StatusCodes.Status404NotFound) - // .ProducesProblem(StatusCodes.Status409Conflict); - - // group.MapDelete("/{userAccountId:guid}/passwords/{credentialId:guid}", async (Guid userAccountId, Guid credentialId, IMediator mediator, HttpContext context, CancellationToken ct) => - // { - // var result = await mediator.Send(new RemoveUserAccountPasswordCommand(userAccountId, credentialId), ct); - // return result.ToNoContent(context); - // }) - // .WithName("RemoveUserAccountPassword") - // .WithSummary("Remove a password credential") - // .Produces(StatusCodes.Status204NoContent) - // .ProducesProblem(StatusCodes.Status404NotFound) - // .ProducesProblem(StatusCodes.Status409Conflict); group.MapPost("/{userAccountId:guid}/mfa-enrollments", async (Guid userAccountId, EnrollUserAccountMfaCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/DelegationEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/DelegationEndpoints.cs index 79971a41..e5ac2cb4 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/DelegationEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/DelegationEndpoints.cs @@ -7,8 +7,14 @@ public static class DelegationEndpoints { public static IEndpointRouteBuilder MapDelegationEndpoints(this IEndpointRouteBuilder app) { + // SEGURIDAD (ADR-UMS-086 endurecido, G-148): una delegación confiere autoridad de + // administrador; todo el grupo de escritura exige autenticación (RequireAuthorization), + // igual que los endpoints de consulta de delegación y el grupo IGA de promoción de rol. + // La autorización fina (management-owner) y el aislamiento por inquilino se hacen cumplir + // en la capa de aplicación (ITenantScopePolicy), y la separación de funciones en el dominio. var group = app.MapGroup("/delegations") - .WithTags("Delegations"); + .WithTags("Delegations") + .RequireAuthorization(); group.MapPost("/", async (CreateDelegationCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { @@ -43,6 +49,39 @@ public static IEndpointRouteBuilder MapDelegationEndpoints(this IEndpointRouteBu .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + group.MapPost("/{delegationId:guid}/submit-for-approval", async (Guid delegationId, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new SubmitDelegationForApprovalCommand(delegationId), ct); + return result.ToNoContent(context); + }) + .WithName("SubmitDelegationForApproval") + .WithSummary("Submit a draft delegation for approval (Draft → PendingApproval)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{delegationId:guid}/approve", async (Guid delegationId, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ApproveDelegationCommand(delegationId), ct); + return result.ToNoContent(context); + }) + .WithName("ApproveDelegation") + .WithSummary("Approve a delegation pending approval (PendingApproval → Active)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{delegationId:guid}/reject", async (Guid delegationId, string reason, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new RejectDelegationCommand(delegationId, reason), ct); + return result.ToNoContent(context); + }) + .WithName("RejectDelegation") + .WithSummary("Reject a delegation pending approval (PendingApproval → Rejected)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + group.MapPost("/{delegationId:guid}/expire", async (Guid delegationId, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(new ExpireDelegationCommand(delegationId), ct); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/Queries/DelegationQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/Queries/DelegationQueryEndpoints.cs index 82b5dcf2..67dde2e9 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/Queries/DelegationQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/Queries/DelegationQueryEndpoints.cs @@ -1,5 +1,7 @@ namespace Ums.Presentation.Endpoints.Identity.UserManagementDelegation.Queries; +#pragma warning disable S125 + using Ums.Application.Identity.UserManagementDelegation.DTOs; using Ums.Application.Identity.UserManagementDelegation.Queries; @@ -7,8 +9,11 @@ public static class DelegationQueryEndpoints { public static IEndpointRouteBuilder MapDelegationQueryEndpoints(this IEndpointRouteBuilder app) { + // G-041 (SEGURIDAD): las delegaciones exponen relaciones de gestión (PII); + // solo usuarios autenticados. var group = app.MapGroup("/delegations") - .WithTags("Delegations - Queries"); + .WithTags("Delegations - Queries") + .RequireAuthorization(); group.MapGet("/", async (IMediator mediator, HttpContext context, CancellationToken ct) => { diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RoleMaturityStatus/Queries/RoleMaturityStatusQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RoleMaturityStatus/Queries/RoleMaturityStatusQueryEndpoints.cs new file mode 100644 index 00000000..a28443a3 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RoleMaturityStatus/Queries/RoleMaturityStatusQueryEndpoints.cs @@ -0,0 +1,37 @@ +namespace Ums.Presentation.Endpoints.Iga.RoleMaturityStatus.Queries; + +using Ums.Application.IGA.DTOs; +using Ums.Application.IGA.RoleMaturity.Queries; + +/// +/// Endpoints de lectura del estado de madurez de rol (IGA, ADR-UMS-093, FR-062, G-052). +/// El grupo exige autenticación y la acotación por inquilino se aplica en la capa de aplicación +/// (ITenantScopePolicy); no se admite lectura anónima ni cruzada (G-041). +/// +public static class RoleMaturityStatusQueryEndpoints +{ + public static IEndpointRouteBuilder MapRoleMaturityStatusQueryEndpoints(this IEndpointRouteBuilder app) + { + // SEGURIDAD (ADR-UMS-093, G-052/G-041): madurez de rol es gobernanza; solo autenticados y por inquilino. + var group = app.MapGroup("/role-maturity-status") + .WithTags("IGA - RoleMaturityStatus - Queries") + .RequireAuthorization(); + + group.MapGet("/users/{userId:guid}", async ( + Guid userId, + [FromQuery] Guid tenantId, + [FromQuery] Guid? roleId, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var result = await mediator.Send(new GetRoleMaturityStatusByUserQuery(tenantId, userId, roleId), ct); + return result.ToOk(context); + }) + .WithName("GetRoleMaturityStatusByUser") + .WithSummary("Obtiene el/los estado(s) de madurez de un usuario, acotado por inquilino; si se indica roleId, el de ese rol (FR-062).") + .Produces>(StatusCodes.Status200OK); + + return app; + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/Queries/RolePromotionRequestQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/Queries/RolePromotionRequestQueryEndpoints.cs new file mode 100644 index 00000000..7a4d3e90 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/Queries/RolePromotionRequestQueryEndpoints.cs @@ -0,0 +1,47 @@ +namespace Ums.Presentation.Endpoints.Iga.RolePromotionRequest.Queries; + +using Ums.Application.IGA.DTOs; +using Ums.Application.IGA.RolePromotion.Queries; + +/// +/// Endpoints de lectura de las solicitudes de promoción de rol (IGA, ADR-UMS-093, G-052). +/// El grupo exige autenticación y la acotación por inquilino se aplica en la capa de aplicación +/// (ITenantScopePolicy): un usuario regular queda ceñido a su inquilino, evitando la lectura +/// anónima y cruzada señalada en G-041. +/// +public static class RolePromotionRequestQueryEndpoints +{ + public static IEndpointRouteBuilder MapRolePromotionRequestQueryEndpoints(this IEndpointRouteBuilder app) + { + // SEGURIDAD (ADR-UMS-093, G-052/G-041): datos de gobernanza; solo autenticados y por inquilino. + var group = app.MapGroup("/role-promotion-requests") + .WithTags("IGA - RolePromotionRequests - Queries") + .RequireAuthorization(); + + group.MapGet("/", async ( + [FromQuery] Guid? tenantId, + [FromQuery] string? status, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var result = await mediator.Send(new ListRolePromotionRequestsQuery(tenantId, status), ct); + return result.ToOk(context); + }) + .WithName("ListRolePromotionRequests") + .WithSummary("Lista solicitudes de promoción de rol acotadas por inquilino y, opcionalmente, por estado.") + .Produces>(StatusCodes.Status200OK); + + group.MapGet("/{id:guid}", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new GetRolePromotionRequestByIdQuery(id), ct); + return result.ToOk(context); + }) + .WithName("GetRolePromotionRequestById") + .WithSummary("Obtiene una solicitud de promoción de rol por su identificador, acotada por inquilino.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound); + + return app; + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/RolePromotionRequestEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/RolePromotionRequestEndpoints.cs new file mode 100644 index 00000000..9be154cb --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/RolePromotionRequestEndpoints.cs @@ -0,0 +1,148 @@ +namespace Ums.Presentation.Endpoints.Iga.RolePromotionRequest; + +#pragma warning disable S125 + +using Ums.Application.IGA.DTOs; +using Ums.Application.IGA.RolePromotion.Commands; + +/// +/// Endpoints de escritura de la máquina de estados de promoción de rol (IGA, ADR-UMS-093, G-052). +/// Cada transición se expone como POST a una subruta de acción, coherente con el estilo del +/// repositorio (Approvals/Delegation). Todo el grupo exige autenticación (RequireAuthorization) +/// y queda acotado por inquilino en la capa de aplicación (ITenantScopePolicy); no se admite +/// escritura anónima ni cruzada (cerrando el antipatrón de G-040/G-041). +/// +public static class RolePromotionRequestEndpoints +{ + public static IEndpointRouteBuilder MapRolePromotionRequestEndpoints(this IEndpointRouteBuilder app) + { + // SEGURIDAD (ADR-UMS-093, G-052): la promoción de rol es gobernanza sensible; + // solo usuarios autenticados y acotados por inquilino. + var group = app.MapGroup("/role-promotion-requests") + .WithTags("IGA - RolePromotionRequests") + .RequireAuthorization(); + + group.MapPost("/", async (CreateRolePromotionRequestCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(command, ct); + return result.ToCreated(r => $"/role-promotion-requests/{r.RolePromotionRequestId}", context); + }) + .WithName("CreateRolePromotionRequest") + .WithSummary("Crea una solicitud de promoción de rol en estado Draft (FR-060).") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest); + + group.MapPost("/{id:guid}/submit", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new SubmitRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("SubmitRolePromotionRequest") + .WithSummary("Draft → PendingEligibilityCheck: congela el RiskScore (FR-061).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/confirm-eligibility", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ConfirmRolePromotionEligibilityCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("ConfirmRolePromotionEligibility") + .WithSummary("PendingEligibilityCheck → PendingManagerApproval o Rejected, fail-closed (FR-062).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/manager-approve", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ManagerApproveRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("ManagerApproveRolePromotion") + .WithSummary("PendingManagerApproval → PendingSecurityReview o Approved según el RiskScore (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/manager-reject", async (Guid id, RolePromotionDecisionReasonBody body, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ManagerRejectRolePromotionCommand(id, body.Reason), ct); + return result.ToNoContent(context); + }) + .WithName("ManagerRejectRolePromotion") + .WithSummary("PendingManagerApproval → Rejected con motivo (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/security-approve", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new SecurityApproveRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("SecurityApproveRolePromotion") + .WithSummary("PendingSecurityReview → Approved (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/security-reject", async (Guid id, RolePromotionDecisionReasonBody body, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new SecurityRejectRolePromotionCommand(id, body.Reason), ct); + return result.ToNoContent(context); + }) + .WithName("SecurityRejectRolePromotion") + .WithSummary("PendingSecurityReview → Rejected con motivo (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/execute", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ExecuteRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("ExecuteRolePromotion") + .WithSummary("Approved → Executed: aplica el cambio de rol (INV-RPR5).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/verify", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new VerifyRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("VerifyRolePromotion") + .WithSummary("Executed → Verified: verificación post-ejecución por un auditor (INV-RPR5).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/cancel", async (Guid id, RolePromotionDecisionReasonBody body, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new CancelRolePromotionCommand(id, body.Reason), ct); + return result.ToNoContent(context); + }) + .WithName("CancelRolePromotion") + .WithSummary("Draft → Cancelled: el solicitante cancela antes de enviar (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + return app; + } +} + +/// Cuerpo de las transiciones de rechazo/cancelación: el motivo de la decisión. +public sealed record RolePromotionDecisionReasonBody(string Reason); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/PactProviderStateEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/PactProviderStateEndpoints.cs index 9bb98bdd..8746a0ae 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/PactProviderStateEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/PactProviderStateEndpoints.cs @@ -10,7 +10,17 @@ using Ums.Domain.Authorization.SystemSuite; using Ums.Domain.Authorization.Template; using Ums.Domain.Authorization.Profile; +using Ums.Domain.Approvals; +using Ums.Domain.Audit.AuditRecord; +using Ums.Domain.Configuration; +using Ums.Domain.IGA; using Ums.Infrastructure.Persistence; +using BeyondNetCode.Shell.Ddd; +using ApprovalRequestAggregate = Ums.Domain.Approvals.ApprovalRequest.ApprovalRequest; +using AuditRecordAggregate = Ums.Domain.Audit.AuditRecord.AuditRecord; +using FeatureFlagAggregate = Ums.Domain.Configuration.FeatureFlag.FeatureFlag; +using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; namespace Ums.Presentation.Endpoints; @@ -27,7 +37,12 @@ public static IEndpointRouteBuilder MapPactProviderStateEndpoints(this IEndpoint IUserAccountRepository userAccounts, IPermissionTemplateRepository permissionTemplates, IProfileRepository profiles, - ISystemSuiteRepository systemSuites) => + ISystemSuiteRepository systemSuites, + IApprovalRequestRepository approvalRequests, + IAuditRecordRepository auditRecords, + IFeatureFlagRepository featureFlags, + IAppConfigurationRepository appConfigurations, + IRolePromotionRequestRepository rolePromotionRequests) => { await (request.State switch { @@ -52,6 +67,30 @@ var s when s.StartsWith("a profile with id ") => EnsureProfileExistsAsync(s var s when s.StartsWith("a system suite with id ") => EnsureSystemSuiteExistsAsync(s, systemSuites, tenants), "at least one system suite exists" => EnsureDefaultSystemSuiteAsync(systemSuites, tenants), + // G-082: Approvals — ApprovalRequest + var s when s.StartsWith("an approval request with id ") => EnsureApprovalRequestExistsAsync(s, approvalRequests), + var s when s.StartsWith("no approval request with id ") => Task.CompletedTask, + "at least one approval request exists" => SeedApprovalRequestAsync(Guid.Empty, approvalRequests), + + // G-082: Audit — AuditRecord + var s when s.StartsWith("an audit record with id ") => EnsureAuditRecordExistsAsync(s, auditRecords), + var s when s.StartsWith("no audit record with id ") => Task.CompletedTask, + "at least one audit record exists" => SeedAuditRecordAsync(Guid.Empty, auditRecords), + + // G-082: Configuration — FeatureFlag + var s when s.StartsWith("a feature flag with id ") => EnsureFeatureFlagExistsAsync(s, featureFlags), + var s when s.StartsWith("no feature flag with id ") => Task.CompletedTask, + "at least one feature flag exists" => SeedFeatureFlagAsync(Guid.Empty, featureFlags), + + // G-082: Configuration — AppConfiguration + var s when s.StartsWith("no app configuration with id ") => Task.CompletedTask, + "at least one app configuration exists" => SeedAppConfigurationAsync(appConfigurations), + + // G-082: IGA — RolePromotionRequest + var s when s.StartsWith("a role promotion request with id ") => EnsureRolePromotionRequestExistsAsync(s, rolePromotionRequests), + var s when s.StartsWith("no role promotion request with id ") => Task.CompletedTask, + "at least one role promotion request exists" => SeedRolePromotionRequestAsync(Guid.Empty, rolePromotionRequests), + _ => Task.CompletedTask, }); @@ -65,6 +104,11 @@ var s when s.StartsWith("a system suite with id ") => EnsureSystemSuiteExistsAsy private static readonly Guid DefaultUserGuid = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); private static readonly ActorId SeedActor = ActorId.Create("00000000-0000-0000-0000-000000000111"); + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en fixture de estado de proveedor Pact. Fija IDs bien " + + "conocidos sobre props no públicos para reproducir estados de proveedor en pruebas de " + + "contrato; no forma parte del runtime de producción.")] private static void SetEntityId(T entity, Guid id) where T : class { var type = entity.GetType(); @@ -291,6 +335,158 @@ private static async Task SeedProfileAsync(Guid id, Guid tenantId, Guid userId, await profiles.UnitOfWork.SaveEntitiesAsync(); } + // ───────────────────────────────────────────────────────────── + // G-082: sembradores de los cuatro contextos sin contrato previo. + // Sólo interesa la FORMA HTTP; se construyen agregados válidos con + // identificadores fijos cargados desde GUID (sin verificar existencia + // de referencias, que no participan en la forma de la respuesta). + // ───────────────────────────────────────────────────────────── + private static readonly Guid SeedRefGuidA = Guid.Parse("aaaa1111-0000-0000-0000-000000000001"); + private static readonly Guid SeedRefGuidB = Guid.Parse("bbbb2222-0000-0000-0000-000000000002"); + + // ── Approvals: ApprovalRequest ───────────────────────────────── + private static async Task EnsureApprovalRequestExistsAsync(string state, IApprovalRequestRepository requests) + { + var id = ExtractGuid(state); + if (id == Guid.Empty) return; + if (await requests.GetByIdAsync(id) is not null) return; + await SeedApprovalRequestAsync(id, requests); + } + + private static async Task SeedApprovalRequestAsync(Guid id, IApprovalRequestRepository requests) + { + var result = ApprovalRequestAggregate.Create( + ApprovalWorkflowId.Load(SeedRefGuidA), + UserId.Load(SeedRefGuidB), + null, + SystemSuiteId.Load(SeedRefGuidA), + null, + RoleId.Load(SeedRefGuidB), + "Contract test approval request.", + SeedActor); + + if (result.IsFailure) return; + + var request = result.Value; + if (id != Guid.Empty) SetEntityId(request, id); + + await requests.AddAsync(request); + await requests.UnitOfWork.SaveEntitiesAsync(); + } + + // ── Audit: AuditRecord ───────────────────────────────────────── + private static async Task EnsureAuditRecordExistsAsync(string state, IAuditRecordRepository auditRecords) + { + var id = ExtractGuid(state); + if (id == Guid.Empty) return; + if (await auditRecords.GetByIdAsync(id) is not null) return; + await SeedAuditRecordAsync(id, auditRecords); + } + + private static async Task SeedAuditRecordAsync(Guid id, IAuditRecordRepository auditRecords) + { + // El listado se acota por inquilino (RootTenantId == tenantId de la consulta) y por + // ventana temporal reciente: se siembra con el inquilino por defecto y WhenOccurred = ahora. + var result = AuditRecordAggregate.Record( + DefaultUserGuid, + SubjectType.System, + "ContractTestEvent", + "ContractTestEvent", + AuditResult.Success, + DefaultUserGuid, + "ContractTest", + DefaultTenantGuid, + null); + + if (result.IsFailure) return; + + var record = result.Value; + if (id != Guid.Empty) SetEntityId(record, id); + + await auditRecords.AppendAsync(record); + await auditRecords.UnitOfWork.SaveEntitiesAsync(record); + } + + // ── Configuration: FeatureFlag ───────────────────────────────── + private static async Task EnsureFeatureFlagExistsAsync(string state, IFeatureFlagRepository featureFlags) + { + var id = ExtractGuid(state); + if (id == Guid.Empty) return; + if (await featureFlags.GetByIdAsync(id) is not null) return; + await SeedFeatureFlagAsync(id, featureFlags); + } + + private static async Task SeedFeatureFlagAsync(Guid id, IFeatureFlagRepository featureFlags) + { + var result = FeatureFlagAggregate.Create( + IdValueObject.Load(SeedRefGuidA), + null, + "CONTRACT_TEST_FLAG", + FlagType.Boolean, + "all", + null, + null, + null, + SeedActor); + + if (result.IsFailure) return; + + var flag = result.Value; + if (id != Guid.Empty) SetEntityId(flag, id); + + await featureFlags.AddAsync(flag); + await featureFlags.UnitOfWork.SaveEntitiesAsync(); + } + + // ── Configuration: AppConfiguration ──────────────────────────── + private static async Task SeedAppConfigurationAsync(IAppConfigurationRepository appConfigurations) + { + var result = AppConfigurationAggregate.Create( + null, + null, + null, + Code.Create("CONTRACT_TEST_CFG"), + ConfigurationValue.Create("contract-test-value"), + Description.Create("Contract test configuration."), + true, + false, + SeedActor, + false); + + if (result.IsFailure) return; + + await appConfigurations.AddAsync(result.Value); + await appConfigurations.UnitOfWork.SaveEntitiesAsync(); + } + + // ── IGA: RolePromotionRequest ────────────────────────────────── + private static async Task EnsureRolePromotionRequestExistsAsync(string state, IRolePromotionRequestRepository requests) + { + var id = ExtractGuid(state); + if (id == Guid.Empty) return; + if (await requests.GetByIdAsync(id) is not null) return; + await SeedRolePromotionRequestAsync(id, requests); + } + + private static async Task SeedRolePromotionRequestAsync(Guid id, IRolePromotionRequestRepository requests) + { + var result = RolePromotionRequestAggregate.Create( + TenantId.Load(DefaultTenantGuid), + UserId.Load(SeedRefGuidA), + UserId.Load(SeedRefGuidB), + RoleId.Load(SeedRefGuidA), + RoleId.Load(SeedRefGuidB), + SeedActor); + + if (result.IsFailure) return; + + var request = result.Value; + if (id != Guid.Empty) SetEntityId(request, id); + + await requests.AddAsync(request); + await requests.UnitOfWork.SaveEntitiesAsync(); + } + private static Guid ExtractGuid(string state) { foreach (var part in state.Split(' ')) diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs index fb05b13c..366097e2 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs @@ -1,107 +1,155 @@ namespace Ums.Presentation.Extensions; +using System.Text; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; /// -/// HARDENING-02: Configures JWT Bearer authentication for production. +/// Nombres de los esquemas de autenticación de UMS. Se centralizan aquí porque un esquema es +/// una cadena mágica: repetirla endpoint por endpoint es la vía más corta a que uno de ellos +/// autentique por el esquema equivocado sin que nadie lo note (G-191). +/// +public static class UmsAuthSchemes +{ + /// Cookie `ums.session`: la sesión del portal web. + public const string Cookie = CookieAuthenticationDefaults.AuthenticationScheme; + + /// Portador JWT firmado por UMS (HS256): los sistemas satélite. + public const string Portador = JwtBearerDefaults.AuthenticationScheme; + + /// + /// Esquema de política que decide, por petición, cuál de los dos anteriores aplica. + /// Es el esquema POR DEFECTO del host. + /// + public const string Automatico = "UmsAuto"; +} + +/// +/// Políticas de autorización nombradas de UMS. +/// +public static class UmsAuthPolicies +{ + /// + /// Superficie de sistemas satélite: exige portador VÁLIDO y descarta la cookie de forma + /// explícita. + /// + /// Por qué una política nombrada y no repetir el esquema en cada endpoint: fijar el esquema + /// suelto (`new AuthorizeAttribute { AuthenticationSchemes = "Bearer" }`) obliga a repetir la + /// cadena en cada `MapGet`, y basta que un endpoint nuevo la omita para que herede el esquema + /// por defecto y vuelva a autenticar por cookie — exactamente el defecto que G-191 corrige. + /// Con la política, el contrato «esto es para satélites» se declara UNA vez, se reutiliza y + /// sobrevive a cualquier cambio futuro del esquema por defecto. + /// + public const string Satelite = "UmsSatelite"; +} + +/// +/// Configura la autenticación de UMS: dos esquemas que conviven. /// -/// Configuration (appsettings.json): -/// -/// "Authentication": { -/// "Enabled": true, -/// "Authority": "https://your-idp.example.com", // OIDC discovery endpoint base URL -/// "Audience": "ums-api", // Expected JWT audience claim -/// "RequireHttpsMetadata": true, -/// "ValidIssuers": ["https://your-idp.example.com"] -/// } -/// +/// Cookie (`ums.session`) — el portal web. `POST /api/v1/auth/login` firma la cookie +/// y el navegador la reenvía sola. /// -/// Claim conventions (populate IUserContext / ITenantContext from these): -/// sub → user ID (standard OIDC) -/// name → display name -/// tenant_id → UMS organization / tenant ID (custom claim) -/// email → user email (optional, only if IdP provides it) +/// Portador (JWT HS256 emitido por UMS) — los sistemas satélite. `POST +/// /api/v1/client/authenticate` devuelve el token y el satélite lo presenta en `Authorization`. /// -/// In development, set "Authentication:Enabled": false to fall back to the -/// DevAuthMiddleware which reads X-User-Id / X-User-Name headers. -/// NEVER enable DevAuthMiddleware in production. +/// Cuál se aplica: el esquema por defecto es , +/// un esquema de política que reenvía a portador cuando la petición trae `Authorization: Bearer` +/// y a cookie en cualquier otro caso. Antes (G-191) el defecto era la cookie fija: la cabecera +/// `Authorization` se ignoraba y el reto de la cookie contestaba con un 302 al `LoginPath`, de +/// modo que NINGÚN satélite podía consumir un endpoint autenticado — ni en desarrollo, donde el +/// manejador de portador ni siquiera se registraba. +/// +/// La firma: UMS emite sus propios tokens en HS256 con `Jwt:Secret` +/// (JwtTokenService), así que el manejador de portador valida ESO, en todos los entornos. +/// El manejador anterior apuntaba a una `Authority` OIDC externa: la API no estaba preparada para +/// validar lo que ella misma firma. La federación con un IdP externo ocurre en el LOGIN (motor de +/// autenticación, `AuthenticateUserCommand`), no en el servidor de recursos: quien entra por un +/// IdP también sale con un token de UMS. +/// +/// Claims que transporta el portador (ver JwtTokenService): +/// sub / email / name, tenant_id o tenant_code, role, scope*, feature*, is_internal_admin. /// public static class AuthenticationExtensions { + /// + /// HS256 exige una clave de al menos 256 bits. Se valida al arrancar para no descubrirlo en + /// la primera petición con un 500 opaco. + /// + private const int LongitudMinimaDelSecreto = 32; + public static IServiceCollection AddUmsAuthentication( this IServiceCollection services, - IConfiguration configuration) + IConfiguration configuration, + IHostEnvironment environment) { - var enabled = configuration.GetValue("Authentication:Enabled", false); - var authority = configuration["Authentication:Authority"]; - var audience = configuration["Authentication:Audience"] ?? "ums-api"; - var requireHttps = configuration.GetValue("Authentication:RequireHttpsMetadata", true); - var validIssuers = configuration.GetSection("Authentication:ValidIssuers") - .Get() ?? []; - - if (!enabled || string.IsNullOrWhiteSpace(authority)) - { - // Dev/test mode: JWT Bearer is disabled. Cookie auth is the default scheme so that - // UseAuthentication() reads ums.session cookies set by the login endpoint. - // DevAuthMiddleware then only activates for truly unauthenticated requests (no cookie). - services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => - { - options.Cookie.Name = "ums.session"; - options.Cookie.HttpOnly = true; - options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; - options.ExpireTimeSpan = TimeSpan.FromHours(1); - options.SlidingExpiration = true; - options.LoginPath = "/api/v1/auth/login"; - options.AccessDeniedPath = "/api/v1/auth/denied"; - }); - services.AddAuthorization(); - return services; - } + ConfigurarProteccionDeDatos(services, configuration); + + ValidarSecretoDeFirma(configuration["Jwt:Secret"], environment); services - .AddAuthentication(cfg => + .AddAuthentication(UmsAuthSchemes.Automatico) + .AddPolicyScheme(UmsAuthSchemes.Automatico, "Portador si viaja Authorization; cookie si no", options => { - cfg.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - cfg.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + // El selector rige TODAS las operaciones del esquema (autenticar, retar, prohibir), + // así que el reto de una petición con portador inválido lo emite el manejador de + // portador → 401 con `WWW-Authenticate`, nunca una redirección de cookie. + options.ForwardDefaultSelector = contexto => + { + string? cabecera = contexto.Request.Headers.Authorization; + return cabecera is not null + && cabecera.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? UmsAuthSchemes.Portador + : UmsAuthSchemes.Cookie; + }; }) - .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => + .AddCookie(UmsAuthSchemes.Cookie, options => { - options.Cookie.Name = "ums.session"; - options.Cookie.HttpOnly = true; + options.Cookie.Name = "ums.session"; + options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; - options.ExpireTimeSpan = TimeSpan.FromHours(1); - options.SlidingExpiration = true; - }) - .AddJwtBearer(options => - { - options.Authority = authority; - options.Audience = audience; - options.RequireHttpsMetadata = requireHttps; + options.ExpireTimeSpan = TimeSpan.FromHours(1); + options.SlidingExpiration = true; + options.LoginPath = "/api/v1/auth/login"; + options.AccessDeniedPath = "/api/v1/auth/denied"; - options.TokenValidationParameters = new TokenValidationParameters + // Un 401 tiene que ser un 401. El manejador de cookie, pensado para páginas, + // responde al reto con 302 hacia el formulario de acceso; un cliente de API no + // sigue un `Location` y lo interpreta como éxito o como fallo de red. Bajo + // `/api/**` la redirección se sustituye por el código que corresponde. + options.Events = new CookieAuthenticationEvents { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidIssuers = validIssuers.Length > 0 ? validIssuers : [authority], - ValidAudience = audience, - ClockSkew = TimeSpan.FromSeconds(30), + OnRedirectToLogin = ctx => ResponderSinRedirigir(ctx, StatusCodes.Status401Unauthorized), + OnRedirectToAccessDenied = ctx => ResponderSinRedirigir(ctx, StatusCodes.Status403Forbidden), }; + }) + .AddJwtBearer(UmsAuthSchemes.Portador, options => + { + // Sin `Authority`: la clave la conoce el propio proceso. No hay descubrimiento OIDC + // que resolver ni red que dependa de estar disponible para validar un token. + + // Se conserva el mapeo entrante de claims (sub → NameIdentifier, email → Email, + // name → Name) para que el principal del portador y el de la cookie se lean con + // los MISMOS tipos de claim; de eso dependen `UserContext`, la revocación y el + // particionado del limitador de tasa. + options.MapInboundClaims = true; options.Events = new JwtBearerEvents { OnAuthenticationFailed = ctx => { - // Structured log — token validation failures are security events. + // Un token que no valida es un evento de seguridad: se registra el tipo de + // fallo, nunca el token. var logger = ctx.HttpContext.RequestServices .GetRequiredService() .CreateLogger("UMS.Authentication"); logger.LogWarning( - "JWT authentication failed. Path={Path} Error={Error}", + "Falló la validación del portador. Path={Path} Error={Error}", ctx.Request.Path, ctx.Exception.GetType().Name); @@ -109,8 +157,7 @@ public static IServiceCollection AddUmsAuthentication( }, OnTokenValidated = ctx => { - // Populate ITenantContext from the JWT claims. - // This runs after signature validation, so the claims are trusted. + // Corre DESPUÉS de verificar la firma: los claims ya son de fiar. var tenantContext = ctx.HttpContext.RequestServices .GetService(); @@ -123,8 +170,19 @@ public static IServiceCollection AddUmsAuthentication( if (Guid.TryParse(tenantIdClaim, out var tenantId)) { - var isInternalAdmin = isInternalAdminClaim?.ToLower() == "true"; - tenantContext.Initialize(tenantId, isInternalAdmin); + var isInternalAdmin = string.Equals( + isInternalAdminClaim, "true", StringComparison.OrdinalIgnoreCase); + + // El contexto puede venir ya inicializado por otro middleware; que lo + // esté no es un error. + try + { + tenantContext.Initialize(tenantId, isInternalAdmin); + } + catch (InvalidOperationException) + { + // Ya inicializado: se respeta el valor vigente. + } } return Task.CompletedTask; @@ -132,10 +190,146 @@ public static IServiceCollection AddUmsAuthentication( }; }); - services.AddAuthorization(); + // Los parámetros de validación se resuelven TARDE, contra la configuración final del host y + // no contra la que hubiera en el momento del registro. No es un rodeo: los hosts de prueba + // (y cualquier fuente que se añada después, como Key Vault) inyectan su configuración + // mientras se construye la aplicación, es decir DESPUÉS de este método. Capturar aquí el + // secreto producía el peor fallo posible —la API firmaba con una clave y validaba con otra, + // rechazando sus propios tokens con `SecurityTokenSignatureKeyNotFound`—, que es justo el + // mismo pitfall de temporización que ya se documentó con la cadena de conexión (G-014). + // `JwtTokenService`, que firma, también lee la configuración tarde: así ambos lados + // coinciden por construcción. + services.AddOptions(UmsAuthSchemes.Portador) + .Configure((options, config, material) => + { + var secreto = ValidarSecretoDeFirma(config["Jwt:Secret"], environment); + var emisor = config["Jwt:Issuer"] ?? "ums-api"; + var audiencia = config["Jwt:Audience"] ?? "ums-web-app"; + + // Emisores/audiencias adicionales aceptados (p. ej. un alias de despliegue). El + // emisor y la audiencia propios SIEMPRE se aceptan: son los que UMS estampa al firmar. + var emisoresValidos = new[] { emisor } + .Concat(config.GetSection("Authentication:ValidIssuers").Get() ?? []) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + var audienciasValidas = new[] { audiencia } + .Concat(config.GetSection("Authentication:ValidAudiences").Get() ?? []) + .Append(config["Authentication:Audience"]) + .Where(valor => !string.IsNullOrWhiteSpace(valor)) + .Select(valor => valor!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + options.RequireHttpsMetadata = config.GetValue("Authentication:RequireHttpsMetadata", true); + + // Verificadores primero, emisión después (ADR-0157 §4.9, etapa E1). UMS ya + // firma RS256, pero los tokens HS256 emitidos ANTES de este despliegue siguen + // vivos hasta caducar: rechazarlos echaría de su sesión a quien la tuviera + // abierta. La ventana se cierra en E3 borrando el secreto y esta rama —no + // detrás de una bandera: una bandera que reactiva HS256 es la vulnerabilidad + // con un interruptor—. + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKeys = + [ + material.ClavePublica, + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secreto)), + ], + // La lista blanca es lo que impide la confusión de `alg` de RFC 8725 §2.1: + // sin ella, un token que declarase HS256 podría intentar verificarse contra + // la clave RSA **pública** —que es material publicado— y pasar. + ValidAlgorithms = [SecurityAlgorithms.RsaSha256, SecurityAlgorithms.HmacSha256], + ValidateIssuer = true, + ValidIssuers = emisoresValidos, + ValidateAudience = true, + ValidAudiences = audienciasValidas, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); + + services.AddAuthorization(options => + { + options.AddPolicy(UmsAuthPolicies.Satelite, policy => policy + .AddAuthenticationSchemes(UmsAuthSchemes.Portador) + .RequireAuthenticatedUser()); + }); + return services; } + /// + /// Sin secreto —o con uno más corto que 256 bits— no hay forma de validar el portador, y + /// arrancar «a medias» reproduce G-191 en silencio: la API ignoraría tokens que debería + /// comprobar. Se verifica al registrar (aviso temprano) y al resolver las opciones (valor real). + /// + /// + /// Marcadores de posición del repositorio. Miden 64 caracteres, así que la comprobación de + /// longitud los daba por buenos: el arranque los aceptaba y UMS firmaba con una cadena + /// PÚBLICA, versionada y conocida — quien la lea emite tokens válidos de cualquier usuario y + /// cualquier inquilino (G-203). + /// + /// No se corrige cambiando el valor en el repositorio: poner un secreto real bajo control de + /// versiones es el mismo defecto con otra cara. Se corrige rechazando el marcador allí donde + /// firmar con él tiene consecuencias. + /// + private static readonly string[] MarcasDeMarcadorDePosicion = + [ + "YOUR_", "CHANGE_IN_PRODUCTION", "CHANGE_IN_LOCAL", "CHANGEME", "PLACEHOLDER", "EXAMPLE_", + ]; + + private static bool PareceMarcadorDePosicion(string secreto) => + MarcasDeMarcadorDePosicion.Any(m => secreto.Contains(m, StringComparison.OrdinalIgnoreCase)); + + private static string ValidarSecretoDeFirma(string? secreto, IHostEnvironment entorno) + { + // En Development el marcador se tolera: es el valor que trae el repositorio para que un + // desarrollador arranque sin ceremonia, y su alcance es su propia máquina. Fuera de ahí no + // se tolera, y el arranque FALLA: una API de identidad que arranca firmando con una clave + // pública es peor que una que no arranca. + if (!entorno.IsDevelopment() && !string.IsNullOrWhiteSpace(secreto) && PareceMarcadorDePosicion(secreto)) + { + throw new InvalidOperationException( + $"Jwt:Secret es un marcador de posición del repositorio y el entorno es " + + $"'{entorno.EnvironmentName}'. UMS firmaría sus tokens con una cadena pública y " + + "conocida: cualquiera que lea el repositorio podría emitir tokens válidos de " + + "cualquier usuario y cualquier inquilino. Inyecta el secreto real por " + + "`Jwt__Secret` desde un Secret de Kubernetes, o resuélvelo por " + + "`Secrets:Source=KeyVault` (G-203, ADR-0157)."); + } + + if (string.IsNullOrWhiteSpace(secreto) || secreto.Length < LongitudMinimaDelSecreto) + { + throw new InvalidOperationException( + "Jwt:Secret no está configurado o es más corto que 32 caracteres. UMS firma y " + + "valida sus propios tokens en HS256: sin ese secreto la API no puede autenticar " + + "a los sistemas satélite."); + } + + return secreto; + } + + /// + /// Sustituye la redirección del manejador de cookie por un código de estado cuando la petición + /// va a la API. Fuera de `/api/**` (p. ej. Swagger) se conserva el comportamiento de navegador. + /// + private static Task ResponderSinRedirigir( + Microsoft.AspNetCore.Authentication.RedirectContext contexto, + int codigo) + { + if (contexto.Request.Path.StartsWithSegments("/api")) + { + contexto.Response.StatusCode = codigo; + return Task.CompletedTask; + } + + contexto.Response.Redirect(contexto.RedirectUri); + return Task.CompletedTask; + } + /// /// Configures the Swagger UI to accept Bearer tokens. /// Only adds the security definition; does not enforce it on endpoints @@ -168,4 +362,33 @@ public static void AddSwaggerBearerAuth(this Swashbuckle.AspNetCore.SwaggerGen.S }, }); } + + /// + /// Fija la identidad del anillo de claves de Data Protection y, con Redis disponible, lo + /// persiste para que TODAS las réplicas compartan las mismas claves. + /// + /// Sin esto, cada pod genera su propio anillo efímero: la cookie `ums.session` emitida por + /// un pod no la descifra ninguno de los demás, así que un despliegue con más de una réplica + /// produce 401 aparentemente aleatorios y cada reinicio invalida todas las sesiones vivas + /// (G-169). `SetApplicationName` va SIEMPRE —también sin Redis— porque el nombre por defecto + /// deriva de la ruta del contenido: dos pods con rutas distintas ya no se entienden aunque + /// compartieran el almacén. + /// + private static void ConfigurarProteccionDeDatos(IServiceCollection services, IConfiguration configuration) + { + var proteccion = services.AddDataProtection().SetApplicationName("ums"); + + var redis = Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar( + configuration["Redis:Connection"] ?? configuration["REDIS_CONNECTION"]); + + if (string.IsNullOrWhiteSpace(redis)) + { + // Anillo en memoria: válido para desarrollo, pruebas y despliegue de UNA réplica. + return; + } + + proteccion.PersistKeysToStackExchangeRedis( + StackExchange.Redis.ConnectionMultiplexer.Connect(redis), + "ums:dataprotection-keys"); + } } diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/BlockedOperationResponse.cs b/src/apps/ums.api/Ums.Presentation/Extensions/BlockedOperationResponse.cs index c7d7433f..52e0141d 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/BlockedOperationResponse.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/BlockedOperationResponse.cs @@ -27,6 +27,13 @@ internal static class BlockedOperationMessages "No se puede suspender el tenant porque tiene sucursales activas.", "A tenant cannot be suspended while active branches exist."), + // ADR-0164 §2.2: guarda de cascada del CIERRE DEFINITIVO de una sucursal. El desglose que + // acompaña a este código dice cuántas cuentas y cuántos perfiles bloquean; el mensaje solo + // tiene que decir qué hacer con ellos. + [DomainErrors.Tenant.BranchHasLiveReferences] = ( + "No se puede cerrar la sucursal porque todavía tiene usuarios o perfiles activos asignados. Reasígnelos o desactívelos primero.", + "A branch cannot be closed while active user accounts or profiles still reference it."), + [DomainErrors.Tenant.HasActiveIdpConfig] = ( "No se puede suspender el tenant porque tiene proveedores de identidad activos.", "A tenant cannot be suspended while active identity providers exist."), @@ -44,8 +51,14 @@ internal static class BlockedOperationMessages "A role cannot be deactivated while active child roles depend on it."), [DomainErrors.Authorization.TemplateHasActiveProfiles] = ( - "No se puede deprecar el template porque tiene perfiles activos asociados.", - "A permission template cannot be deprecated while active profiles are linked to it."), + "No se puede deprecar ni eliminar el template porque tiene perfiles activos asociados.", + "A permission template cannot be deprecated or deleted while active profiles are linked to it."), + + // Guardia de cascada del borrado lógico de parámetros de inquilino: un parámetro activo es un + // vínculo vivo de la configuración; hay que desactivarlo antes de poder eliminarlo. + [DomainErrors.TenantParameter.HasActiveBinding] = ( + "No se puede eliminar el parámetro porque sigue activo en la configuración del inquilino; desactívelo primero.", + "A tenant parameter cannot be deleted while it is still active in the tenant configuration."), [DomainErrors.Authorization.DomainResourceHasTemplateItems] = ( "No se puede eliminar el recurso de dominio porque está referenciado en uno o más templates.", @@ -54,6 +67,14 @@ internal static class BlockedOperationMessages [DomainErrors.Authorization.ModuleHasActiveMenus] = ( "No se puede eliminar el módulo porque tiene menús activos configurados.", "A module cannot be removed while active menus are configured within it."), + + [DomainErrors.Authorization.SystemSuiteHasDependents] = ( + "No se puede eliminar el sistema porque todavía hay elementos vivos que dependen de él. Elimínelos primero.", + "A system suite cannot be deleted while live roles, permission templates, feature flags, configurations, approval workflows or tenant defaults still reference it."), + + [DomainErrors.Configuration.ParameterHasActiveValues] = ( + "No se puede eliminar la definición de parámetro porque tiene valores globales o de inquilino asociados.", + "A parameter definition cannot be deleted while global or tenant values reference it."), }; public static string GetMessage(string errorCode) diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/DomainErrorStatusMapper.cs b/src/apps/ums.api/Ums.Presentation/Extensions/DomainErrorStatusMapper.cs index f1ac9bdb..29ddeddf 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/DomainErrorStatusMapper.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/DomainErrorStatusMapper.cs @@ -17,17 +17,99 @@ public static (int Status, string Title) Map(string error) return (StatusCodes.Status400BadRequest, "Validation Error"); } - if (ContainsAny(error, DomainErrors.Common.NotFound, DomainErrors.Tenant.NotFound, DomainErrors.Tenant.BranchNotFound, DomainErrors.Tenant.IdpNotFound, DomainErrors.Tenant.BrandingNotFound, DomainErrors.SystemSuite.ConfigurationKeyNotFound, DomainErrors.Authorization.PermissionNotFound)) + // ADR-UMS-095: cuenta con bloqueo temporal por intentos fallidos → 423 Locked. + // AUTH_017 (AUTH_012 ya está tomado con el significado «no IDP adapter registered»). + if (error.StartsWith("AUTH_017", StringComparison.OrdinalIgnoreCase)) + { + return (StatusCodes.Status423Locked, "Locked"); + } + + // Detección de «no encontrado» → 404 por CÓDIGO de dominio estable e idioma-agnóstico + // (DomainErrors.*), no por el texto del mensaje. G-100: el brazo de substring en inglés de + // abajo no clasificaba los errores que el dominio devuelve en español (p. ej. IGA: + // «No se encontró la solicitud de promoción de rol.»), colapsándolos al 400 por defecto. + // Los handlers deben devolver el código; el mapeo depende de él, no del idioma del mensaje. + // + // G-104: se añaden los códigos de fin en not_found que direccionan un recurso por id o clave y + // que hoy caían al 400 por defecto porque ni estaban en esta lista ni contenían la frase inglesa + // «not found». Son CriteriaNotFound —al quitar un criterio de feature flag inexistente—, + // MfaEnrollmentNotFound —al revocar una inscripción de MFA inexistente— y TenantParameter.NotFound + // —parámetro de inquilino direccionado por su código—. Los dos endpoints reales ya declaraban 404 + // en su contrato OpenAPI; el mapeo por código lo hace efectivo en lugar de contradecirlo con 400. + // + // G-104, EXCLUIDOS DELIBERADAMENTE, NO son 404 y no deben re-cablearse: IdpFallbackNotFound y + // ParentResourceNotFound NO direccionan el recurso de la URI, sino que validan una referencia + // colgante en el cuerpo de un alta —un identificador de fallback o de recurso padre que apunta a + // algo inexistente—. El recurso objetivo del alta sí existe o se está creando; lo que falla es la + // integridad referencial del payload, que es validación 400 —semánticamente un 422—, nunca 404. + // Sus endpoints declaran 400 y no 404 en el contrato, así que se dejan en el 400 por defecto. + if (ContainsAny(error, DomainErrors.Common.NotFound, DomainErrors.Tenant.NotFound, DomainErrors.Tenant.BranchNotFound, DomainErrors.Tenant.IdpNotFound, DomainErrors.SystemSuite.ConfigurationKeyNotFound, DomainErrors.Authorization.PermissionNotFound, DomainErrors.IGA.RolePromotionRequestNotFound, DomainErrors.Configuration.CriteriaNotFound, DomainErrors.UserAccount.MfaEnrollmentNotFound, DomainErrors.TenantParameter.NotFound)) { return (StatusCodes.Status404NotFound, "Not Found"); } + // Fallback heredado para handlers que aún incrustan la frase en inglés «not found» en el mensaje + // (Tenant/UserAccount/Configuration…). Es dependiente del idioma y sólo se mantiene por + // compatibilidad; la vía correcta es el código de dominio del bloque anterior (G-100). if (error.Contains("not found", StringComparison.OrdinalIgnoreCase)) { return (StatusCodes.Status404NotFound, "Not Found"); } - if (ContainsAny(error, DomainErrors.Common.Duplicate, DomainErrors.Tenant.SignupRequestAlreadyExists, DomainErrors.Tenant.BranchCodeNotUnique, DomainErrors.Tenant.IdpCodeNotUnique, DomainErrors.UserAccount.EmailNotUnique, DomainErrors.SystemSuite.OptionCodeNotUnique, DomainErrors.SystemSuite.SubMenuCodeNotUnique, DomainErrors.SystemSuite.MenuCodeNotUnique, DomainErrors.SystemSuite.ModuleCodeNotUnique, DomainErrors.SystemSuite.ConfigurationKeyAlreadyExists, DomainErrors.Authorization.TemplateItemTargetAlreadyExists, DomainErrors.Authorization.PermissionAlreadyExists, DomainErrors.Compliance.DocumentAlreadyExpired)) + // G-246: eliminar lógicamente un sistema que sigue vigente, que aún tiene referencias vivas o + // que ya está eliminado es un conflicto con el estado actual del recurso → 409. Lo mismo para + // los dos intentos de esquivar la guarda por `PUT /status` (entrar a «eliminado» o salir de + // él). SystemSuiteHasDependents sale normalmente por la vía del BlockedOperationResponse (409 + // con el desglose de qué bloquea); se lista aquí para que el camino sin desglose no lo + // degrade a 400. + if (ContainsAny( + error, + DomainErrors.Authorization.SystemSuiteNotDeprecated, + DomainErrors.Authorization.SystemSuiteHasDependents, + DomainErrors.Authorization.SystemSuiteAlreadyDeleted, + DomainErrors.Authorization.SystemSuiteDeletedNotSettable, + DomainErrors.Authorization.SystemSuiteDeletedIsTerminal)) + { + return (StatusCodes.Status409Conflict, "Conflict"); + } + + // ── Borrado lógico: guardias de cascada y estados terminales → 409 ──── + // Solo existe borrado lógico. Dos familias de conflicto salen de ahí y ambas son 409, no 400: + // · Guardia de cascada: se intenta eliminar algo con referencias VIVAS (perfiles activos que + // usan la plantilla, vínculo activo del parámetro de inquilino). Es el análogo de un + // ON DELETE RESTRICT y sale además enriquecido con BlockedOperationResponse cuando el + // handler adjunta las dependencias; este brazo cubre el caso sin dependencias adjuntas. + // · Estado terminal ya alcanzado: reintentar el borrado de algo ya eliminado es un conflicto + // con el estado actual del recurso, igual que Tenant.AlreadySuspended. + // + // ADR-0164 aplicado a SUCURSALES: cerrar una con referencias vivas (BranchHasLiveReferences), + // reintentar el cierre de una ya cerrada (BranchAlreadyClosed) e intentar desactivar o + // reactivar una cerrada (BranchClosed) son las tres conflictos con el estado actual del + // recurso → 409. `BranchClosed` importa especialmente: sin él, «no se puede reactivar lo + // cerrado» caería al 400 por defecto y se confundiría con un error de validación del cliente. + // `TemplateNotDeletable` y `ParameterAlreadyDeleted` faltaban y caían al 400 por omisión, en + // contra del `ProducesProblem(409)` que declaran sus propios endpoints. La primera dice que el + // estado de la plantilla no admite el borrado —conflicto con el estado actual, no un error de + // validación del cliente— y la segunda es el reintento sobre algo ya eliminado, exactamente el + // caso que la línea de al lado ya cubría para plantillas y parámetros de inquilino. + if (ContainsAny(error, DomainErrors.Authorization.TemplateHasActiveProfiles, DomainErrors.Authorization.TemplateAlreadyDeleted, DomainErrors.Authorization.TemplateNotDeletable, DomainErrors.Configuration.ParameterAlreadyDeleted, DomainErrors.TenantParameter.HasActiveBinding, DomainErrors.TenantParameter.AlreadyDeleted, DomainErrors.Tenant.BranchHasLiveReferences, DomainErrors.Tenant.BranchAlreadyClosed, DomainErrors.Tenant.BranchClosed)) + { + return (StatusCodes.Status409Conflict, "Conflict"); + } + + // Conflictos de estado idempotentes del ciclo de vida del tenant: reintentar una transición + // hacia el estado ya vigente (suspender lo ya suspendido, activar lo ya activo) es un conflicto + // con el estado actual del recurso → 409, coherente con el resto de duplicados/"already". Antes + // caían al default 400 (G-014 residual: SuspendTenant_AlreadySuspended esperaba 409). + // ManagementOwnerAlreadyExists (G-037/G-045): segundo owner de gestión → 409. + // G-121: los conflictos de TRANSICIÓN de estado del ciclo de vida de documentos y solicitudes + // (aprobar una request no-Pending, validar/expirar un documento en estado incompatible) son + // conflictos con el estado actual del recurso → 409, coherente con DocumentAlreadyExpired y con + // el ProducesProblem(409) que declaran sus endpoints (antes caían mezclados a 400). + // ADR-0164: TemplateItemTargetRetired es el mismo tipo de conflicto —la clave natural del ítem + // sigue ocupada por una concesión retirada— y por eso comparte 409; se mantiene como código + // propio para que el cliente pueda proponer reactivar en vez de repetir el alta. + if (ContainsAny(error, DomainErrors.Common.Duplicate, DomainErrors.Tenant.SignupRequestAlreadyExists, DomainErrors.Tenant.BranchCodeNotUnique, DomainErrors.Tenant.IdpCodeNotUnique, DomainErrors.Tenant.AlreadyActive, DomainErrors.Tenant.AlreadySuspended, DomainErrors.Tenant.ManagementOwnerAlreadyExists, DomainErrors.UserAccount.EmailNotUnique, DomainErrors.SystemSuite.OptionCodeNotUnique, DomainErrors.SystemSuite.SubMenuCodeNotUnique, DomainErrors.SystemSuite.MenuCodeNotUnique, DomainErrors.SystemSuite.ModuleCodeNotUnique, DomainErrors.SystemSuite.ConfigurationKeyAlreadyExists, DomainErrors.Configuration.ParameterCodeNotUnique, DomainErrors.Configuration.ParameterHasActiveValues, DomainErrors.Authorization.TemplateItemTargetAlreadyExists, DomainErrors.Authorization.TemplateItemTargetRetired, DomainErrors.Authorization.PermissionAlreadyExists, DomainErrors.Compliance.DocumentAlreadyExpired, DomainErrors.Approvals.DocumentTypeAlreadyRequired, DomainErrors.Compliance.DocumentNotPendingReview, DomainErrors.Compliance.DocumentCannotTransition, DomainErrors.Approvals.RequestNotPending)) { return (StatusCodes.Status409Conflict, "Conflict"); } @@ -42,7 +124,7 @@ public static (int Status, string Title) Map(string error) return (StatusCodes.Status400BadRequest, "Validation Error"); } - if (ContainsAny(error, DomainErrors.Common.Invalid, DomainErrors.UserAccount.InvalidEmail, DomainErrors.Tenant.SignupRequestNotPending, DomainErrors.Tenant.SignupRequestAlreadyProcessed, DomainErrors.Branding.InvalidHexColor, DomainErrors.Branding.InvalidCustomDomain, DomainErrors.Branding.InvalidCnameTarget, DomainErrors.Branding.InvalidLogoFormat, DomainErrors.Configuration.IdpConfigPayloadInvalid, DomainErrors.Configuration.FlagPercentageOutOfRange, DomainErrors.Configuration.AppConfigNotDraft, DomainErrors.Configuration.AppConfigNotPublished, DomainErrors.Configuration.FlagArchivedCannotChange, DomainErrors.Configuration.AppConfigAlreadyArchived, DomainErrors.Compliance.ExpirationBeforeIssueDate, DomainErrors.Compliance.DocumentCannotTransition, DomainErrors.Compliance.DocumentNotPendingReview, DomainErrors.ValueObject.DateRangeInvalid)) + if (ContainsAny(error, DomainErrors.Common.Invalid, DomainErrors.UserAccount.InvalidEmail, DomainErrors.Tenant.SignupRequestNotPending, DomainErrors.Tenant.SignupRequestAlreadyProcessed, DomainErrors.Configuration.IdpConfigPayloadInvalid, DomainErrors.Configuration.FlagPercentageOutOfRange, DomainErrors.Configuration.AppConfigNotDraft, DomainErrors.Configuration.AppConfigNotPublished, DomainErrors.Configuration.FlagArchivedCannotChange, DomainErrors.Configuration.AppConfigAlreadyArchived, DomainErrors.Compliance.ExpirationBeforeIssueDate, DomainErrors.Approvals.PolicyInactiveCannotUpdate, DomainErrors.ValueObject.DateRangeInvalid)) { return (StatusCodes.Status400BadRequest, "Validation Error"); } diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/LoggingExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/LoggingExtensions.cs index 68bd61fb..35f49be5 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/LoggingExtensions.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/LoggingExtensions.cs @@ -4,6 +4,7 @@ namespace Ums.Presentation.Extensions; using Serilog.Events; using Serilog.Formatting.Compact; using Serilog.Sinks.Grafana.Loki; +using Ums.Presentation.Observability; /// /// REC-14 / OBS-01: Configures Serilog as the application's structured-logging provider. @@ -50,13 +51,16 @@ public static LoggerConfiguration ConfigureUmsSerilog( var consoleFormat = loggingSection["ConsoleFormat"] ?? (env.IsDevelopment() ? "Text" : "CompactJson"); var minimumLevel = loggingSection["MinimumLevel"] ?? (env.IsDevelopment() ? "Debug" : "Information"); var outputTemplate = loggingSection["OutputTemplate"] - ?? "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}"; + ?? "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}"; loggerConfig .ReadFrom.Configuration(context.Configuration) // honour appsettings Serilog section .Enrich.FromLogContext() // picks up BeginScope() key-values .Enrich.WithMachineName() .Enrich.WithThreadId() + // ADR-0046: TraceId/SpanId W3C (Activity.Current) en toda línea → correlación + // uniforme de logs (Loki) con trazas (Tempo), también en sinks no-OTel. + .Enrich.With() // HARDENING-04: Mask PII fields before any sink sees them. .Enrich.With() .Destructure.With() diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/ObservabilityExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/ObservabilityExtensions.cs index 0cd5d465..18d3fca2 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/ObservabilityExtensions.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/ObservabilityExtensions.cs @@ -1,5 +1,7 @@ namespace Ums.Presentation.Extensions; +#pragma warning disable S125 + using System.Diagnostics; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -35,11 +37,20 @@ public static IServiceCollection AddUmsObservability( ?? configuration["OpenTelemetry:ServiceVersion"] ?? "1.0.0"; + // ADR-0096 §2.1: el entorno se declara con la semconv OTel `deployment.environment.name`. + var environmentName = configuration["Observability:Environment"] + ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? "unknown"; + var resourceBuilder = ResourceBuilder.CreateDefault() .AddService( serviceName: ServiceName, serviceVersion: version, autoGenerateServiceInstanceId: true) + .AddAttributes(new[] + { + new KeyValuePair("deployment.environment.name", environmentName), + }) .AddTelemetrySdk() .AddEnvironmentVariableDetector(); @@ -61,12 +72,6 @@ public static IServiceCollection AddUmsObservability( { activity.SetTag(ObservabilityKeys.SessionTrackingId, sessionTrackingId.ToString()); } - - if (request.Headers.TryGetValue(ObservabilityHeaders.CorrelationId, out var correlationId) - && !string.IsNullOrWhiteSpace(correlationId)) - { - activity.SetTag(ObservabilityKeys.CorrelationId, correlationId.ToString()); - } }; }) .AddHttpClientInstrumentation(opts => @@ -82,7 +87,11 @@ public static IServiceCollection AddUmsObservability( }; }) // EF Core SQL queries traced via activity source - .AddSource("Microsoft.EntityFrameworkCore"); + .AddSource("Microsoft.EntityFrameworkCore") + // ADR-UMS-098: los aspectos AOP de los shells emiten spans con un + // ActivitySource nativo de la BCL (sin SDK de OTel en la librería); + // registrarlo aquí incorpora esos spans al TracerProvider. + .AddSource("BeyondNetCode.Shell.Aop"); if (!string.IsNullOrWhiteSpace(endpoint)) { diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/PiiMaskingPolicy.cs b/src/apps/ums.api/Ums.Presentation/Extensions/PiiMaskingPolicy.cs index 8afd143b..1f8b60de 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/PiiMaskingPolicy.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/PiiMaskingPolicy.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Presentation.Extensions; using Serilog.Core; @@ -98,3 +99,5 @@ private static string MaskEmail(string email) return $"{local}***@***.{tld}"; } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/ResultExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/ResultExtensions.cs index b082fdd2..fb242595 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/ResultExtensions.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/ResultExtensions.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Presentation.Extensions; using Microsoft.AspNetCore.Http; @@ -53,6 +54,8 @@ private static IResult ToProblem(string error, HttpContext? context = null) Extensions = { ["timestamp"] = DateTimeOffset.UtcNow, + ["errorCode"] = error, + ["brokenRule"] = error }, }; @@ -82,8 +85,14 @@ private static IResult ToProblem(string error, HttpContext? context = null) private static string GetUserMessage(string error, int status) { const string validationPrefix = "Validation.Failed:"; - // Updated: Validation failures now map to 422 Unprocessable Entity. - if (status == StatusCodes.Status422UnprocessableEntity && error.StartsWith(validationPrefix, StringComparison.OrdinalIgnoreCase)) + // G-061: la convención de validación devuelve 400 (DomainErrorStatusMapper mapea + // "Validation.Failed:" → 400). El mensaje accionable de campo (localizado por + // FluentValidation, p. ej. "…Código tiene un formato inválido… por ejemplo REPORTS_01…") + // debe surfacearse en `detail` con independencia del status; antes se condicionaba a 422 + // y, tras el cambio a 400, se perdía y caía al genérico "error.request.invalid". + // El prefijo "Validation.Failed:" solo lo emite el pipeline de validación, así que el + // mensaje es siempre un texto seguro para el usuario (sin detalles técnicos). + if (error.StartsWith(validationPrefix, StringComparison.OrdinalIgnoreCase)) { var message = error[validationPrefix.Length..].Trim(); return string.IsNullOrWhiteSpace(message) @@ -122,3 +131,5 @@ private static string GetUserMessage(string error, int status) _ => StringLocalizer.T("error.request.invalid"), }; } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Presentation/GlobalUsings.cs b/src/apps/ums.api/Ums.Presentation/GlobalUsings.cs index 703867c9..45231688 100644 --- a/src/apps/ums.api/Ums.Presentation/GlobalUsings.cs +++ b/src/apps/ums.api/Ums.Presentation/GlobalUsings.cs @@ -10,4 +10,4 @@ global using Microsoft.AspNetCore.Routing; global using Ums.Application.Common.Interfaces; global using Ums.Presentation.Extensions; -global using BeyondNetCode.Shell.Aop.Aspects.Logger.Serilog; +global using Ums.Infrastructure.Observability; diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/AccessEnforcementPolicyQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/AccessEnforcementPolicyQueries.cs index 30bc5433..f8baa492 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/AccessEnforcementPolicyQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/AccessEnforcementPolicyQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Approvals; [ExtendObjectType("Query")] public sealed class AccessEnforcementPolicyQueries { - public async Task> GetAccessEnforcementPoliciesAsync( + public static async Task> GetAccessEnforcementPoliciesAsync( int page, int pageSize, string? search, string? criteria, string? status, string? sortBy, string? sortOrder, Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) { @@ -24,7 +24,7 @@ public async Task> GetAccessEnforcementP return result.UnwrapGraphQl(); } - public async Task GetAccessEnforcementPolicyByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetAccessEnforcementPolicyByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetAccessEnforcementPolicyByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalRequestQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalRequestQueries.cs index 4a54e20e..c2e6a8be 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalRequestQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalRequestQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Approvals; [ExtendObjectType("Query")] public sealed class ApprovalRequestQueries { - public async Task> GetApprovalRequestsAsync( + public static async Task> GetApprovalRequestsAsync( int page, int pageSize, string? search, string? criteria, string? status, string? sortBy, string? sortOrder, Guid? tenantId, Guid? userId, [Service] IMediator mediator, CancellationToken cancellationToken) { @@ -24,7 +24,7 @@ public async Task> GetApprovalRequestsAsync( return result.UnwrapGraphQl(); } - public async Task GetApprovalRequestByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetApprovalRequestByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetApprovalRequestByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalWorkflowQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalWorkflowQueries.cs index 36f785f5..2f3bc221 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalWorkflowQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalWorkflowQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Approvals; [ExtendObjectType("Query")] public sealed class ApprovalWorkflowQueries { - public async Task> GetApprovalWorkflowsAsync( + public static async Task> GetApprovalWorkflowsAsync( int page, int pageSize, string? search, string? criteria, string? sortBy, string? sortOrder, Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) { @@ -23,7 +23,7 @@ public async Task> GetApprovalWorkflowsAsync( return result.UnwrapGraphQl(); } - public async Task GetApprovalWorkflowByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetApprovalWorkflowByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetApprovalWorkflowByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/DocumentTypeQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/DocumentTypeQueries.cs index ff703cbb..a935dcbe 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/DocumentTypeQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/DocumentTypeQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Approvals; [ExtendObjectType("Query")] public sealed class DocumentTypeQueries { - public async Task> GetDocumentTypesAsync( + public static async Task> GetDocumentTypesAsync( int page, int pageSize, string? search, string? criteria, string? sortBy, string? sortOrder, Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) { @@ -23,7 +23,7 @@ public async Task> GetDocumentTypesAsync( return result.UnwrapGraphQl(); } - public async Task GetDocumentTypeByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetDocumentTypeByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetDocumentTypeByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/NotificationRuleQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/NotificationRuleQueries.cs index b49769f1..434814c1 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/NotificationRuleQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/NotificationRuleQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Approvals; [ExtendObjectType("Query")] public sealed class NotificationRuleQueries { - public async Task> GetNotificationRulesAsync( + public static async Task> GetNotificationRulesAsync( int page, int pageSize, string? search, string? criteria, string? status, string? sortBy, string? sortOrder, Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) { @@ -24,7 +24,7 @@ public async Task> GetNotificationRulesAsync( return result.UnwrapGraphQl(); } - public async Task GetNotificationRuleByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetNotificationRuleByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetNotificationRuleByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/UserDocumentQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/UserDocumentQueries.cs index 91bf08a0..6a807e3d 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/UserDocumentQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/UserDocumentQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Approvals; [ExtendObjectType("Query")] public sealed class UserDocumentQueries { - public async Task> GetUserDocumentsAsync( + public static async Task> GetUserDocumentsAsync( int page, int pageSize, string? search, string? criteria, string? status, string? sortBy, string? sortOrder, Guid? userId, Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) { @@ -24,7 +24,7 @@ public async Task> GetUserDocumentsAsync( return result.UnwrapGraphQl(); } - public async Task GetUserDocumentByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetUserDocumentByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetUserDocumentByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Audit/AuditRecordQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Audit/AuditRecordQueries.cs index fdb6c1fc..e7a541db 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Audit/AuditRecordQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Audit/AuditRecordQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Audit; [ExtendObjectType("Query")] public sealed class AuditRecordQueries { - public async Task> GetAuditRecordsAsync( + public static async Task> GetAuditRecordsAsync( int page, int pageSize, string? eventType, @@ -32,7 +32,7 @@ public async Task> GetAuditRecordsAsync( return result.UnwrapGraphQl(); } - public async Task GetAuditRecordByIdAsync( + public static async Task GetAuditRecordByIdAsync( Guid auditRecordId, [Service] IMediator mediator, CancellationToken cancellationToken) diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/PermissionTemplateQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/PermissionTemplateQueries.cs index 2e72c035..218965b6 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/PermissionTemplateQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/PermissionTemplateQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Authorization; [ExtendObjectType("Query")] public sealed class PermissionTemplateQueries { - public async Task> GetPermissionTemplatesAsync( + public static async Task> GetPermissionTemplatesAsync( int page, int pageSize, string? search, @@ -40,7 +40,7 @@ public async Task> GetPermissionTemplatesAsyn return result.UnwrapGraphQl(); } - public async Task GetPermissionTemplateByIdAsync( + public static async Task GetPermissionTemplateByIdAsync( Guid templateId, [Service] IMediator mediator, CancellationToken cancellationToken) diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/ProfileQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/ProfileQueries.cs index 819ca47d..6957aa94 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/ProfileQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/ProfileQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Authorization; [ExtendObjectType("Query")] public sealed class ProfileQueries { - public async Task> GetProfilesAsync( + public static async Task> GetProfilesAsync( int page, int pageSize, string? search, @@ -38,7 +38,7 @@ public async Task> GetProfilesAsync( return result.UnwrapGraphQl(); } - public async Task GetProfileByIdAsync( + public static async Task GetProfileByIdAsync( Guid profileId, [Service] IMediator mediator, CancellationToken cancellationToken) diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/RoleQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/RoleQueries.cs index 47713cab..dea29231 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/RoleQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/RoleQueries.cs @@ -9,7 +9,7 @@ namespace Ums.Presentation.GraphQL.Authorization; [ExtendObjectType("Query")] public sealed class RoleQueries { - public async Task> GetRolesBySystemSuiteAsync( + public static async Task> GetRolesBySystemSuiteAsync( Guid systemSuiteId, [Service] IMediator mediator, CancellationToken cancellationToken) diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/SystemSuiteQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/SystemSuiteQueries.cs index 68aafb1c..9a337822 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/SystemSuiteQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/SystemSuiteQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Authorization; [ExtendObjectType("Query")] public sealed class SystemSuiteQueries { - public async Task> GetSystemSuitesAsync( + public static async Task> GetSystemSuitesAsync( int page, int pageSize, string? search, @@ -36,7 +36,7 @@ public async Task> GetSystemSuitesAsync( return result.UnwrapGraphQl(); } - public async Task GetSystemSuiteByIdAsync( + public static async Task GetSystemSuiteByIdAsync( Guid systemSuiteId, [Service] IMediator mediator, CancellationToken cancellationToken) diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs index b639d52d..e4de4855 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Configuration; [ExtendObjectType("Query")] public sealed class AppConfigurationQueries { - public async Task> GetAppConfigurationsAsync( + public static async Task> GetAppConfigurationsAsync( int page, int pageSize, string? search, @@ -48,7 +48,7 @@ public async Task> GetAppConfigurationsAsync( return result.Value; } - public async Task GetAppConfigurationByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetAppConfigurationByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetAppConfigurationByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/FeatureFlagQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/FeatureFlagQueries.cs index dbe48ac5..fa457ac0 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/FeatureFlagQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/FeatureFlagQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Configuration; [ExtendObjectType("Query")] public sealed class FeatureFlagQueries { - public async Task> GetFeatureFlagsAsync( + public static async Task> GetFeatureFlagsAsync( int page, int pageSize, string? search, @@ -37,7 +37,7 @@ public async Task> GetFeatureFlagsAsync( return result.UnwrapGraphQl(); } - public async Task GetFeatureFlagByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetFeatureFlagByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetFeatureFlagByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/IdpConfigurationQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/IdpConfigurationQueries.cs index b5ac1a63..540307e0 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/IdpConfigurationQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/IdpConfigurationQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Configuration; [ExtendObjectType("Query")] public sealed class IdpConfigurationQueries { - public async Task> GetIdpConfigurationsAsync( + public static async Task> GetIdpConfigurationsAsync( int page, int pageSize, string? search, @@ -39,13 +39,13 @@ public async Task> GetIdpConfigurationsAsync( return result.UnwrapGraphQl(); } - public async Task GetIdpConfigurationByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) + public static async Task GetIdpConfigurationByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) { var result = await mediator.Send(new GetIdpConfigurationByIdQuery(id), cancellationToken); return result.UnwrapGraphQlOrNull(); } - public async Task ResolveIdpConfigurationAsync( + public static async Task ResolveIdpConfigurationAsync( Guid tenantId, Guid? systemSuiteId, string? emailDomain, diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/DelegationQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/DelegationQueries.cs index cd91b6b1..5926b6af 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/DelegationQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/DelegationQueries.cs @@ -9,7 +9,7 @@ namespace Ums.Presentation.GraphQL.Identity; [ExtendObjectType("Query")] public sealed class DelegationQueries { - public async Task GetDelegationByIdAsync( + public static async Task GetDelegationByIdAsync( Guid delegationId, [Service] IMediator mediator, CancellationToken cancellationToken) @@ -18,7 +18,7 @@ public sealed class DelegationQueries return result.UnwrapGraphQlOrNull(); } - public async Task> GetDelegationsByDelegatedAdminAsync( + public static async Task> GetDelegationsByDelegatedAdminAsync( Guid delegatedAdminId, Guid tenantId, [Service] IMediator mediator, @@ -28,7 +28,7 @@ public async Task> GetDelegationsByDelegatedAdminAs return result.UnwrapGraphQl(); } - public async Task> GetDelegationsByDelegatingAdminAsync( + public static async Task> GetDelegationsByDelegatingAdminAsync( Guid delegatingAdminId, Guid tenantId, [Service] IMediator mediator, @@ -38,7 +38,7 @@ public async Task> GetDelegationsByDelegatingAdminA return result.UnwrapGraphQl(); } - public async Task> GetAllDelegationsAsync( + public static async Task> GetAllDelegationsAsync( [Service] IMediator mediator, CancellationToken cancellationToken) { diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/TenantQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/TenantQueries.cs index 37808853..eb106ded 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/TenantQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/TenantQueries.cs @@ -17,7 +17,7 @@ namespace Ums.Presentation.GraphQL.Identity; [ExtendObjectType("Query")] public sealed class TenantQueries { - public async Task> GetTenantsAsync( + public static async Task> GetTenantsAsync( int page, int pageSize, string? search, @@ -40,7 +40,7 @@ public async Task> GetTenantsAsync( return result.UnwrapGraphQl(); } - public async Task GetTenantByIdAsync( + public static async Task GetTenantByIdAsync( Guid tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) @@ -50,7 +50,7 @@ public async Task> GetTenantsAsync( return result.UnwrapGraphQlOrNull(); } - public async Task> GetTenantBranchesAsync( + public static async Task> GetTenantBranchesAsync( Guid tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) @@ -60,7 +60,7 @@ public async Task> GetTenantBranchesAsync( return result.UnwrapGraphQl(); } - public async Task GetTenantBrandingAsync( + public static async Task GetTenantBrandingAsync( Guid tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) @@ -70,7 +70,7 @@ public async Task> GetTenantBranchesAsync( return result.UnwrapGraphQlOrNull(); } - public async Task> GetTenantIdentityProvidersAsync( + public static async Task> GetTenantIdentityProvidersAsync( Guid tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/UserAccountQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/UserAccountQueries.cs index 2a347231..256ee9c6 100644 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/UserAccountQueries.cs +++ b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/UserAccountQueries.cs @@ -11,7 +11,7 @@ namespace Ums.Presentation.GraphQL.Identity; [ExtendObjectType("Query")] public sealed class UserAccountQueries { - public async Task> GetUserAccountsAsync( + public static async Task> GetUserAccountsAsync( int page, int pageSize, string? search, @@ -36,7 +36,7 @@ public async Task> GetUserAccountsAsync( return result.UnwrapGraphQl(); } - public async Task GetUserAccountByIdAsync( + public static async Task GetUserAccountByIdAsync( Guid userAccountId, [Service] IMediator mediator, CancellationToken cancellationToken) diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/CorrelationIdMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/CorrelationIdMiddleware.cs deleted file mode 100644 index 5deea260..00000000 --- a/src/apps/ums.api/Ums.Presentation/Middleware/CorrelationIdMiddleware.cs +++ /dev/null @@ -1,78 +0,0 @@ -namespace Ums.Presentation.Middleware; - -using System.Diagnostics; -using Microsoft.AspNetCore.Http; - -/// -/// REC-17: Injects a correlation-id into every request so distributed traces -/// can be correlated by a client-provided or auto-generated opaque ID. -/// -/// Propagation chain: -/// 1. Inbound X-Correlation-Id header → stored on -/// 2. TraceIdentifier → Activity baggage ("correlation.id") -/// 3. TraceIdentifier → ILogger scope (key "CorrelationId") -/// 4. Outbound X-Correlation-Id header ← echoed back to caller -/// -/// OTEL baggage travels with outbound HttpClient calls (W3C baggage propagator), -/// so the correlation-id flows to every downstream service automatically. -/// The ILogger scope is picked up by any structured-log sink that reads scopes -/// (Serilog, OpenTelemetry.Logs, Application Insights). -/// -public sealed class CorrelationIdMiddleware -{ - private readonly RequestDelegate _next; - private readonly ILogger _logger; - - public CorrelationIdMiddleware( - RequestDelegate next, - ILogger logger) - { - _next = next; - _logger = logger; - } - - public async Task InvokeAsync(HttpContext context) - { - var correlationId = GetOrAddCorrelationId(context); - context.Response.Headers[ObservabilityHeaders.CorrelationId] = correlationId; - - // Propagate into current OTEL Activity baggage so it travels - // with all downstream HttpClient calls via W3C baggage header. - var activity = Activity.Current; - if (activity is not null) - { - activity.SetBaggage(ObservabilityKeys.CorrelationId, correlationId); - // Also tag the root span so the ID appears in the trace UI. - activity.SetTag(ObservabilityKeys.CorrelationId, correlationId); - } - - // Enrich every log line emitted during this request. - using (_logger.BeginScope(new Dictionary - { - ["CorrelationId"] = correlationId, - })) - { - await _next(context); - } - } - - private static string GetOrAddCorrelationId(HttpContext context) - { - if (context.Request.Headers.TryGetValue(ObservabilityHeaders.CorrelationId, out var existingId) - && !string.IsNullOrWhiteSpace(existingId)) - { - context.TraceIdentifier = existingId!; - return existingId!; - } - - var newId = Guid.NewGuid().ToString("N"); - context.TraceIdentifier = newId; - return newId; - } -} - -public static class CorrelationIdMiddlewareExtensions -{ - public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app) - => app.UseMiddleware(); -} diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/CultureMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/CultureMiddleware.cs index 8ca01cc8..1243f4a4 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/CultureMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/CultureMiddleware.cs @@ -1,6 +1,6 @@ using System.Globalization; using Ums.Globalization.Access; -using Ums.Infrastructure.Services; +using Ums.Infrastructure.Observability; namespace Ums.Presentation.Middleware; @@ -11,12 +11,12 @@ public class CultureMiddleware(RequestDelegate next) private const string AcceptLanguageHeader = "Accept-Language"; private const string TimezoneHeader = "X-Timezone"; - public async Task InvokeAsync(HttpContext context, RequestContextAccessor requestContextAccessor) + public async Task InvokeAsync(HttpContext context, RequestContext requestContext) { var culture = ResolveCulture(context); var timezone = context.Request.Headers[TimezoneHeader].FirstOrDefault(); - requestContextAccessor.SetClientTimezone(string.IsNullOrWhiteSpace(timezone) ? null : timezone); + requestContext.SetClientTimezone(string.IsNullOrWhiteSpace(timezone) ? null : timezone); using (CultureContext.Set(culture)) { diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/DevAuthMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/DevAuthMiddleware.cs index 4383fc4a..4a9d4b9f 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/DevAuthMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/DevAuthMiddleware.cs @@ -9,13 +9,19 @@ public sealed class DevAuthMiddleware { private const string DefaultUserId = "dev-user"; private const string DefaultUserName = "Developer"; - private const string DefaultTenantId = "11111111-1111-1111-1111-111111111111"; - private const string InternalAdminTenantId = "11111111-1111-1111-1111-111111111111"; + // ADR-0071 / FS-26: el Admin Root (propietario de gestión) es BEYONDNET. La conveniencia + // de desarrollo (peticiones no autenticadas) actúa por defecto como BEYONDNET, y el + // privilegio transversal is_internal_admin se otorga a su tenant. La derivación + // autoritativa (login real) vive en AuthEndpoints: is_internal_admin = Tenant.IsManagementOwner. + private const string DefaultTenantId = "5f4e3d2c-1b0a-9f8e-7d6c-543210987654"; // BEYONDNET (Tenant Raíz) + private const string ManagementOwnerTenantId = "5f4e3d2c-1b0a-9f8e-7d6c-543210987654"; // BEYONDNET private const string UserIdHeader = "X-User-Id"; private const string UserNameHeader = "X-User-Name"; private const string TenantIdHeader = "X-Tenant-Id"; private const string IsInternalAdminHeader = "X-Is-Internal-Admin"; private const string DisableDevAuthHeader = "X-Disable-Dev-Auth"; + private const string AuthorizationHeader = "Authorization"; + private const string SessionCookieName = "ums.session"; private readonly RequestDelegate _next; private readonly IHostEnvironment _environment; @@ -35,9 +41,14 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext) } // Public authentication endpoints must execute with the real anonymous context. - // Default dev claims would force the internal admin tenant and break tenant-scoped - // login, signup, forgot-password, and session bootstrap flows. - if (context.Request.Path.StartsWithSegments("/api/v1/auth")) + // Default dev claims would force the internal admin tenant (BEYONDNET) and break tenant-scoped + // login, signup, forgot-password, and session bootstrap flows. Esto cubre tanto el flujo + // interno (/api/v1/auth) como el de autenticación de sistemas cliente (/api/v1/client): sin + // exentar /client, DevAuthMiddleware fijaba el contexto a BEYONDNET y el usuario CLIENT + // (tenant-scoped) no se resolvía → AUTH_006/401 (G-042). El tenant debe resolverse por el + // TenantCode del cuerpo, no por el default de dev. + if (context.Request.Path.StartsWithSegments("/api/v1/auth") + || context.Request.Path.StartsWithSegments("/api/v1/client")) { await _next(context); return; @@ -47,6 +58,35 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext) context.Request.Headers[DisableDevAuthHeader].FirstOrDefault(), "true", StringComparison.OrdinalIgnoreCase)) + { + // G-042 (endurecimiento): X-Disable-Dev-Auth desactiva la inyección de claims de + // desarrollo para poder ejercer la autenticación REAL (bearer/cookie). NO debe + // convertirse en un pase anónimo: antes, la cabecera saltaba dev-auth y la petición + // llegaba SIN identidad al endpoint (probado: X-Disable-Dev-Auth:true → 200). Ahora, + // si no viaja una credencial real (cabecera Authorization o cookie ums.session), se + // rechaza fail-closed (401) en lugar de continuar como anónimo. Los flujos legítimos + // (login previo + cookie, o bearer) conservan el bypass; el resto queda vedado. + var hasRealCredential = + context.Request.Headers.ContainsKey(AuthorizationHeader) + || context.Request.Cookies.ContainsKey(SessionCookieName); + + if (!hasRealCredential) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = "Bearer"; + return; + } + + await _next(context); + return; + } + + // G-191: si la petición trae un portador, decide el manejador REAL de portador, no las + // claims de conveniencia. Este middleware corre ANTES de UseAuthentication, así que + // inyectar identidad aquí enmascaraba el rechazo de un token inválido o caducado: la + // autenticación fallaba, el principal de desarrollo sobrevivía y el endpoint respondía + // 200. Con la exención, un portador inválido es 401 también en desarrollo. + if (context.Request.Headers.ContainsKey(AuthorizationHeader)) { await _next(context); return; @@ -64,7 +104,7 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext) tenantId ??= DefaultTenantId; var isInternalAdmin = isInternalAdminHeader?.ToLower() == "true" - || tenantId == InternalAdminTenantId; + || tenantId == ManagementOwnerTenantId; var claims = new List { diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/FunctionalTransactionMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/FunctionalTransactionMiddleware.cs new file mode 100644 index 00000000..99ed5804 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Middleware/FunctionalTransactionMiddleware.cs @@ -0,0 +1,111 @@ +namespace Ums.Presentation.Middleware; + +using Microsoft.AspNetCore.Http; +using Ums.Application.Common.Interfaces; +using Ums.Infrastructure.Observability; + +/// +/// Abre y cierra siempre la transacción funcional de cada petición (ADR-0096 §2.3; +/// decisión UMS en ADR-UMS-085). Garantiza el desenlace por construcción: emite la +/// apertura al entrar y el desenlace en un finally, con el estado derivado del +/// resultado —incluida la ruta de fallo—. Así el invariante «transacción sin desenlace = +/// defecto alertable» no depende de la disciplina de cada handler. +/// +/// Se ubica por fuera de en el pipeline: cuando +/// un handler lanza, el manejador global convierte la excepción en respuesta (y adjunta el +/// localizador legible); este middleware, ya de vuelta, lee el código de estado final y +/// emite el desenlace correspondiente. +/// +/// El localizador legible TX-AAAA-NNNNNN se acuña por adelantado en operaciones que +/// mutan estado (para que toda mutación porte su referencia) y de forma perezosa ante +/// cualquier fallo; las lecturas exitosas se identifican por su traceId W3C. +/// +public sealed class FunctionalTransactionMiddleware +{ + private readonly RequestDelegate _next; + + public FunctionalTransactionMiddleware(RequestDelegate next) + => _next = next; + + public async Task InvokeAsync(HttpContext context, FunctionalTransaction transaction) + { + // Las sondas de salud no son transacciones funcionales. + if (context.Request.Path.StartsWithSegments("/health")) + { + await _next(context); + return; + } + + var name = $"{context.Request.Method} {context.Request.Path}"; + transaction.Open(name, ResolveActor(context)); + + if (IsMutating(context.Request.Method)) + { + // No debe abortar la petición si la acuñación falla: se degrada a traceId. + try + { + await transaction.GetOrMintLocatorAsync(CancellationToken.None); + } + catch + { + // El desenlace volverá a intentarlo si el resultado es un fallo. + } + } + + try + { + await _next(context); + } + finally + { + // El actor definitivo se conoce tras la autenticación (middleware interno). + transaction.SetActor(ResolveActor(context)); + await transaction.CompleteAsync( + DeriveState(context), + context.Response.StatusCode, + CancellationToken.None); + } + } + + private static TransactionState DeriveState(HttpContext context) + { + if (context.RequestAborted.IsCancellationRequested) + { + return TransactionState.Cancelled; + } + + var statusCode = context.Response.StatusCode; + return statusCode switch + { + StatusCodes.Status408RequestTimeout => TransactionState.TimedOut, + >= 400 => TransactionState.Failed, + _ => TransactionState.Completed, + }; + } + + private static bool IsMutating(string method) + => HttpMethods.IsPost(method) + || HttpMethods.IsPut(method) + || HttpMethods.IsPatch(method) + || HttpMethods.IsDelete(method); + + private static string ResolveActor(HttpContext context) + { + var user = context.User; + if (user?.Identity is { IsAuthenticated: true }) + { + return user.Identity.Name + ?? user.FindFirst("sub")?.Value + ?? user.FindFirst("preferred_username")?.Value + ?? "(autenticado)"; + } + + return "(anónimo)"; + } +} + +public static class FunctionalTransactionMiddlewareExtensions +{ + public static IApplicationBuilder UseFunctionalTransaction(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/GlobalExceptionHandler.cs b/src/apps/ums.api/Ums.Presentation/Middleware/GlobalExceptionHandler.cs index 0a8d100f..0bab4ebc 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/GlobalExceptionHandler.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/GlobalExceptionHandler.cs @@ -46,7 +46,13 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception { var errorId = UserFacingErrorContext.GetOrCreateErrorId(context); var statusCode = GetStatusCode(exception); - var problemDetails = CreateProblemDetails(context, exception, errorId, statusCode); + + // ADR-0096 §2.2 / ADR-UMS-084: acuña el localizador legible TX-AAAA-NNNNNN y muéstralo + // al usuario en el fallo. Es lo que pega en el ticket de soporte. Nunca debe convertir + // el manejo del error en un fallo secundario. + var transactionLocator = await TryMintLocatorAsync(context); + + var problemDetails = CreateProblemDetails(context, exception, errorId, transactionLocator, statusCode); // Full diagnostic context goes to the log — never to the response body. LogException(exception, context, errorId); @@ -58,13 +64,39 @@ await context.Response.WriteAsync( JsonSerializer.Serialize(problemDetails, UmsProblemDetailsJsonOptions.Instance)); } + /// + /// Acuña el localizador legible de la transacción funcional (ADR-UMS-084) sin propagar + /// fallos: si la acuñación fallara, el error original sigue devolviéndose con su errorId. + /// + private static async Task TryMintLocatorAsync(HttpContext context) + { + try + { + var transaction = context.RequestServices + .GetService(typeof(Application.Common.Interfaces.IFunctionalTransaction)) + as Application.Common.Interfaces.IFunctionalTransaction; + + if (transaction is null) + { + return null; + } + + return await transaction.GetOrMintLocatorAsync(context.RequestAborted); + } + catch + { + return null; + } + } + private static ProblemDetails CreateProblemDetails( HttpContext context, Exception exception, string errorId, + string? transactionLocator, int statusCode) { - return new ProblemDetails + var problemDetails = new ProblemDetails { Title = GetErrorTitle(exception), Detail = GetErrorDetail(exception), // localized, user-friendly — no internal info @@ -79,6 +111,16 @@ private static ProblemDetails CreateProblemDetails( ["timestamp"] = DateTimeOffset.UtcNow, }, }; + + // Localizador legible TX-AAAA-NNNNNN: la referencia comunicable por voz/chat que el + // usuario final entrega a soporte (ADR-0096 §2.2). Convive con el errorId técnico. + if (!string.IsNullOrWhiteSpace(transactionLocator)) + { + problemDetails.Extensions["transactionId"] = transactionLocator; + problemDetails.Extensions["supportReference"] = transactionLocator; + } + + return problemDetails; } private static string GetErrorTitle(Exception exception) => exception switch @@ -86,6 +128,10 @@ private static ProblemDetails CreateProblemDetails( ConcurrencyConflictException => "Conflict", UnauthorizedAccessException => "Unauthorized", System.Collections.Generic.KeyNotFoundException => "Not Found", + // Parámetros de query/ruta ausentes o mal tipados: ASP.NET responde 400. + // El título debe reflejar el 400 y NO "Internal Server Error" (p.ej. GET + // /system-suites sin `page`/`pageSize`). + Microsoft.AspNetCore.Http.BadHttpRequestException => "Bad Request", InvalidOperationException => "Invalid Operation", ArgumentException => "Bad Request", _ => "Internal Server Error", @@ -96,6 +142,7 @@ private static ProblemDetails CreateProblemDetails( ConcurrencyConflictException => StringLocalizer.T("error.operation.conflict"), UnauthorizedAccessException => StringLocalizer.T("error.authentication.required"), System.Collections.Generic.KeyNotFoundException => StringLocalizer.T("error.resource.not_found"), + Microsoft.AspNetCore.Http.BadHttpRequestException => StringLocalizer.T("error.request.invalid"), ArgumentException => StringLocalizer.T("error.request.invalid"), _ => StringLocalizer.T("error.unexpected"), }; @@ -105,6 +152,10 @@ private static ProblemDetails CreateProblemDetails( ConcurrencyConflictException => StatusCodes.Status409Conflict, UnauthorizedAccessException => StatusCodes.Status401Unauthorized, System.Collections.Generic.KeyNotFoundException => StatusCodes.Status404NotFound, + // Parámetros de query/ruta ausentes o mal tipados: ASP.NET lanza esto con + // su propio StatusCode (400). Sin este caso caía a 500 (p.ej. GET + // /system-suites sin `page`). + Microsoft.AspNetCore.Http.BadHttpRequestException badRequest => badRequest.StatusCode, InvalidOperationException => StatusCodes.Status400BadRequest, ArgumentException => StatusCodes.Status400BadRequest, _ => StatusCodes.Status500InternalServerError, diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/IdempotencyMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/IdempotencyMiddleware.cs index 36bef818..037dcbb1 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/IdempotencyMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/IdempotencyMiddleware.cs @@ -2,7 +2,7 @@ namespace Ums.Presentation.Middleware; using System.Text.Json; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; /// @@ -20,16 +20,18 @@ namespace Ums.Presentation.Middleware; /// - Completed key → return cached response immediately (no handler invoked). /// - In-flight key → return 409 "request already in progress" (parallel duplicate). /// -/// Cache backend: (single-node). For multi-replica -/// deployments swap to -/// to share state across pods. +/// Cache backend: . Con Redis configurado, la deduplicación +/// es efectiva ENTRE pods; sin él, el registro cae en la implementación en memoria y solo +/// protege dentro del proceso. Antes usaba IMemoryCache siempre, así que un reintento +/// con la misma clave atendido por otro pod re-ejecutaba el comando: la garantía se anunciaba +/// pero no existía en cuanto había más de una réplica (G-169). /// /// TTL: 24 hours (configurable via ). /// Cached methods: POST, PUT, PATCH only (GET/DELETE are naturally idempotent). /// public sealed class IdempotencyMiddleware( RequestDelegate next, - IMemoryCache cache, + IDistributedCache cache, ILogger logger) { private const string IdempotencyKeyHeader = "Idempotency-Key"; @@ -53,12 +55,13 @@ public async Task InvokeAsync(HttpContext context) return; } - var idempotencyKey = keyValues.First()!.Trim(); + var idempotencyKey = keyValues[0]!.Trim(); var cacheKey = $"idem:{idempotencyKey}"; var inFlightKey = cacheKey + InFlightSuffix; // ── Case 1: completed response already cached ────────────────────────── - if (cache.TryGetValue(cacheKey, out IdempotencyEntry? cached) && cached is not null) + var cached = await cache.LeerAsync(cacheKey, context.RequestAborted); + if (cached is not null) { logger.LogDebug( "IdempotencyMiddleware: returning cached response for key={Key} (status={Status}).", @@ -76,7 +79,7 @@ public async Task InvokeAsync(HttpContext context) } // ── Case 2: same key is in-flight (parallel duplicate request) ───────── - if (cache.TryGetValue(inFlightKey, out _)) + if (await cache.GetAsync(inFlightKey, context.RequestAborted) is not null) { logger.LogWarning( "IdempotencyMiddleware: duplicate in-flight request for key={Key}.", idempotencyKey); @@ -96,7 +99,8 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new // ── Case 3: new key — mark in-flight, execute, cache result ─────────── var ttl = IdempotencyOptions.CacheTtl; - cache.Set(inFlightKey, true, ttl); + var opciones = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }; + await cache.SetAsync(inFlightKey, [1], opciones, context.RequestAborted); // Buffer the response so we can cache it var originalBody = context.Response.Body; @@ -118,7 +122,11 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new context.Response.ContentType ?? "application/json", body); - cache.Set(cacheKey, entry, ttl); + await cache.SetAsync( + cacheKey, + JsonSerializer.SerializeToUtf8Bytes(entry), + opciones, + context.RequestAborted); logger.LogDebug( "IdempotencyMiddleware: cached response for key={Key} (status={Status}, ttl={Ttl}h).", @@ -134,8 +142,11 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new } finally { - // Always remove in-flight marker regardless of success/failure - cache.Remove(inFlightKey); + // Always remove in-flight marker regardless of success/failure. + // `CancellationToken.None`: si el cliente abortó, el token de la petición ya está + // cancelado y la marca quedaría colgada hasta el TTL, bloqueando 24 h los reintentos + // legítimos de esa misma clave. + await cache.RemoveAsync(inFlightKey, CancellationToken.None); context.Response.Body = originalBody; } } @@ -144,6 +155,27 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new /// Cached idempotency response snapshot. internal sealed record IdempotencyEntry(int StatusCode, string ContentType, byte[] Body); +/// Lee y deserializa una entrada cacheada; trata el dato corrupto como ausencia. +file static class IdempotencyCacheReader +{ + public static async Task LeerAsync( + this IDistributedCache cache, string clave, CancellationToken ct) + { + var bytes = await cache.GetAsync(clave, ct); + if (bytes is null || bytes.Length == 0) return null; + + try + { + return JsonSerializer.Deserialize(bytes); + } + catch (JsonException) + { + // Formato antiguo o entrada corrupta: se re-ejecuta la petición en vez de fallar. + return null; + } + } +} + /// Tunable constants for IdempotencyMiddleware. public static class IdempotencyOptions { diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/LimiteDePeticionesMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/LimiteDePeticionesMiddleware.cs new file mode 100644 index 00000000..f6b3f480 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Middleware/LimiteDePeticionesMiddleware.cs @@ -0,0 +1,126 @@ +namespace Ums.Presentation.Middleware; + +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +/// +/// G-248 — el cupo de peticiones, contado en un sitio que todas las réplicas comparten y con la +/// identidad de quien llama ya resuelta. +/// +/// Sustituye al PartitionedRateLimiter de ASP.NET, que fallaba por dos motivos a la +/// vez. Contaba en proceso, así que con N réplicas el cupo efectivo era N veces el +/// declarado. Y se ejecutaba antes de UseAuthentication, de modo que +/// HttpContext.User venía vacío y la clave de reparto caía siempre a la IP: dos usuarios +/// distintos compartían cupo. Medido el 2026-08-04 con el cupo en 6 — tres peticiones de una cuenta +/// y tres de otra bastaban para que ambas recibieran 429. +/// +/// Detrás de un Ingress eso era peor de lo que parece: la IP que ve el proceso es la del +/// controlador, no la del cliente, así que el tráfico anónimo entero caía en un único cubo y un solo +/// abusador dejaba fuera a los demás. Por eso la IP se lee de X-Forwarded-For cuando viene, +/// que es lo que el Ingress rellena. +/// +/// Va DESPUÉS de UseAuthentication y UseAuthorization, y antes de los +/// endpoints: es el único punto donde se sabe a quién se está limitando. +/// +public sealed class LimiteDePeticionesMiddleware(RequestDelegate next, int cupo, TimeSpan ventana) +{ + public async Task InvokeAsync(HttpContext context, ILimitadorDePeticiones limitador) + { + // Las sondas quedan fuera: kubelet las llama sin credencial y desde la IP del nodo, así que + // consumirían el cupo anónimo y podrían llegar a limitarse a sí mismas. Un limitador que + // provoca reinicios en cadena hace más daño que el abuso del que protege. + if (context.Request.Path.StartsWithSegments("/health")) + { + await next(context); + return; + } + + var clave = ResolverClave(context); + var resultado = await limitador.RegistrarAsync(clave, cupo, ventana, context.RequestAborted); + + if (resultado.Permitida) + { + await next(context); + return; + } + + var segundos = Math.Max(1, (int)Math.Ceiling(resultado.EsperaSugerida.TotalSeconds)); + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers.RetryAfter = segundos.ToString(); + context.Response.ContentType = "application/problem+json"; + + // Mismo cuerpo que emitía el limitador anterior: quien ya trate un 429 no debe notar el + // cambio de implementación. + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Title = "Too Many Requests", + Detail = "Rate limit exceeded. Please try again later.", + Status = StatusCodes.Status429TooManyRequests, + Type = "https://httpstatuses.io/429", + Extensions = { ["retryAfter"] = segundos }, + }); + } + + /// + /// A quién se le cuenta esta petición. De lo más específico a lo más general: si se sabe el + /// inquilino y el usuario, el cupo es suyo; si no, de quien se pueda identificar. + /// + private static string ResolverClave(HttpContext ctx) + { + var tenantId = ctx.User.FindFirst("tenant_id")?.Value + ?? ctx.User.FindFirst("org_id")?.Value; + + var sub = ctx.User.FindFirst("sub")?.Value + ?? ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(sub)) + return $"tenant:{tenantId}:user:{sub}"; + + if (!string.IsNullOrEmpty(sub)) + return $"user:{sub}"; + + var apiKey = ctx.Request.Headers["X-Api-Key"].FirstOrDefault(); + if (!string.IsNullOrEmpty(apiKey)) + return $"apikey:{apiKey}"; + + return $"ip:{IpDelCliente(ctx)}"; + } + + /// + /// La IP del cliente, no la del Ingress. + /// + /// Se toma la PRIMERA de X-Forwarded-For, que es la del cliente original; las + /// siguientes son los saltos intermedios. La cabecera la puede falsificar quien llame + /// directamente al pod, y por eso solo gobierna el cupo anónimo: quien se autentica se cuenta + /// por su identidad, que no se puede inventar sin credencial. + /// + private static string IpDelCliente(HttpContext ctx) + { + var reenviada = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(reenviada)) + { + var primera = reenviada.Split(',')[0].Trim(); + if (!string.IsNullOrEmpty(primera)) return primera; + } + + return ctx.Connection.RemoteIpAddress?.ToString() ?? "desconocida"; + } +} + +public static class LimiteDePeticionesMiddlewareExtensions +{ + /// + /// Registra el límite de peticiones. Debe ir DESPUÉS de UseAuthentication: antes, + /// HttpContext.User viene vacío y el cupo de una IP lo comparten cuantos la usen (G-248). + /// + public static IApplicationBuilder UseLimiteDePeticiones( + this IApplicationBuilder app, IConfiguration configuration) + { + var seccion = configuration.GetSection("ApiSettings:RateLimiting"); + var cupo = seccion.GetValue("PermitLimit", 100); + var ventana = TimeSpan.FromMinutes(seccion.GetValue("WindowMinutes", 1)); + + return app.UseMiddleware(cupo, ventana); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/SessionTrackingMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/SessionTrackingMiddleware.cs index c8d5dc27..b2c6f946 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/SessionTrackingMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/SessionTrackingMiddleware.cs @@ -2,11 +2,15 @@ namespace Ums.Presentation.Middleware; using System.Diagnostics; using Microsoft.AspNetCore.Http; -using Ums.Infrastructure.Services; +using Ums.Infrastructure.Observability; /// -/// Ensures every request has a session tracking identifier that can be correlated across -/// logs, traces, business flows, and background handoffs. +/// Garantiza que toda petición tenga un identificador de rastreo de sesión, correlacionable +/// entre logs, trazas, flujos de negocio y traspasos a segundo plano. +/// +/// El rastreo de sesión (SessionTrackingId) es un identificador de producto de UMS, ajeno al +/// estándar W3C: la correlación de traza (trace_id/span_id) la aporta +/// (traceparent), no este middleware. /// public sealed class SessionTrackingMiddleware { @@ -21,7 +25,7 @@ public SessionTrackingMiddleware( _logger = logger; } - public async Task InvokeAsync(HttpContext context, RequestContextAccessor requestContextAccessor) + public async Task InvokeAsync(HttpContext context, RequestContext requestContext) { var sessionTrackingId = GetOrAddSessionTrackingId(context); context.Response.Headers[ObservabilityHeaders.SessionTrackingId] = sessionTrackingId; @@ -33,13 +37,7 @@ public async Task InvokeAsync(HttpContext context, RequestContextAccessor reques activity.SetTag(ObservabilityKeys.SessionTrackingId, sessionTrackingId); } - requestContextAccessor.Set(new ExecutionContextSnapshot( - CorrelationId: activity?.GetBaggageItem(ObservabilityKeys.CorrelationId) - ?? context.TraceIdentifier - ?? string.Empty, - SessionTrackingId: sessionTrackingId, - TraceId: activity?.TraceId.ToString() ?? string.Empty, - SpanId: activity?.SpanId.ToString() ?? string.Empty)); + requestContext.SetSessionTrackingId(sessionTrackingId); using (_logger.BeginScope(new Dictionary { diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/TokenRevocationMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/TokenRevocationMiddleware.cs index 1737a020..f59c1d45 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/TokenRevocationMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/TokenRevocationMiddleware.cs @@ -6,6 +6,14 @@ namespace Ums.Presentation.Middleware; /// /// HARDENING-03: Rejects requests from users whose tokens have been revoked. /// +/// G-247: comprueba DOS cosas distintas, y son distintas a propósito. La cuenta puede estar +/// vetada entera —bloqueo, borrado, cambio de contraseña—, y eso lo responde +/// ITokenRevocationStore, que revoca por usuario. O puede estar cerrada solo ESTA sesión +/// —alguien pulsó «cerrar sesión» en este dispositivo—, y eso lo responde +/// ISessionRevocationStore, que revoca por `sid`. Mezclarlas en una sola clave obligaría a +/// elegir: o el logout echa al usuario de todos sus dispositivos, o el bloqueo de una cuenta deja +/// vivas las sesiones ya abiertas. +/// /// Runs after UseAuthentication so HttpContext.User is already populated. /// Returns HTTP 401 with a Problem Details body when the authenticated user is in /// the revocation list (deleted or blocked). This ensures that deleting or blocking @@ -16,7 +24,10 @@ namespace Ums.Presentation.Middleware; /// public sealed class TokenRevocationMiddleware(RequestDelegate next) { - public async Task InvokeAsync(HttpContext context, ITokenRevocationStore revocationStore) + public async Task InvokeAsync( + HttpContext context, + ITokenRevocationStore revocationStore, + ISessionRevocationStore sessionRevocationStore) { // Only check authenticated requests — anonymous routes pass through. if (context.User.Identity?.IsAuthenticated == true) @@ -24,7 +35,18 @@ public async Task InvokeAsync(HttpContext context, ITokenRevocationStore revocat var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value; - if (!string.IsNullOrEmpty(userId) && await revocationStore.IsRevokedAsync(userId, context.RequestAborted)) + // G-247: la sesión de este portador. Los emitidos antes de que existiera `sid` no lo + // llevan: siguen valiendo hasta caducar, que es lo mismo que hacían antes. Rechazarlos + // cerraría de golpe todas las sesiones vivas en el despliegue del cambio. + var sessionId = context.User.FindFirst("sid")?.Value; + + var vetado = !string.IsNullOrEmpty(userId) + && await revocationStore.IsRevokedAsync(userId, context.RequestAborted); + var sesionCerrada = !vetado + && !string.IsNullOrEmpty(sessionId) + && await sessionRevocationStore.EstaRevocadaAsync(sessionId, context.RequestAborted); + + if (vetado || sesionCerrada) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/problem+json"; diff --git a/src/apps/ums.api/Ums.Presentation/Observability/ActivityTraceEnricher.cs b/src/apps/ums.api/Ums.Presentation/Observability/ActivityTraceEnricher.cs new file mode 100644 index 00000000..4fa2803b --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Observability/ActivityTraceEnricher.cs @@ -0,0 +1,28 @@ +namespace Ums.Presentation.Observability; + +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; + +/// +/// Enriquecedor de Serilog que añade TraceId y SpanId del contexto de traza +/// W3C () a toda línea de log (ADR-0046). +/// +/// Correlaciona de forma uniforme los logs (Loki) con las trazas (Tempo) sin depender del +/// sink de OTel: los sinks no-OTel (Console, Grafana Loki) transportan los mismos campos. +/// Solo emite cuando la usa el formato de identificador W3C. +/// +public sealed class ActivityTraceEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + var activity = Activity.Current; + if (activity is null || activity.IdFormat != ActivityIdFormat.W3C) + { + return; + } + + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString())); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString())); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Program.cs b/src/apps/ums.api/Ums.Presentation/Program.cs index 4568386c..24f96f3b 100644 --- a/src/apps/ums.api/Ums.Presentation/Program.cs +++ b/src/apps/ums.api/Ums.Presentation/Program.cs @@ -2,7 +2,6 @@ using Azure.Core; using Azure.Extensions.AspNetCore.Configuration.Secrets; using Azure.Identity; -using Microsoft.OpenApi.Models; using Ums.Presentation.Bootstrapping; using Ums.Presentation.Extensions; using Serilog; @@ -25,10 +24,20 @@ var app = builder.Build(); await app.InitializeUmsPlatformAsync(); +// Modo «migra y sal» (G-169): el proceso ya migró y sembró en InitializeUmsPlatformAsync, así que +// termina aquí sin levantar el servidor. Lo usa el Job previo al despliegue, para que la migración +// ocurra UNA vez y no en el arranque de cada réplica. +if (app.Configuration.GetValue("Persistence:MigrateAndExit")) +{ + Log.Information("Persistence:MigrateAndExit — plataforma inicializada; el proceso termina sin servir tráfico."); + await Log.CloseAndFlushAsync(); + return; +} + app.UseUmsApiPipeline(); app.MapUmsApiSurface(); -app.Run(); +await app.RunAsync(); static void ConfigureSecrets(WebApplicationBuilder builder) { @@ -67,20 +76,4 @@ static void ConfigureSecrets(WebApplicationBuilder builder) } } -internal sealed class LanguageHeaderOperationFilter : Swashbuckle.AspNetCore.SwaggerGen.IOperationFilter -{ - public void Apply(Microsoft.OpenApi.Models.OpenApiOperation operation, Swashbuckle.AspNetCore.SwaggerGen.OperationFilterContext context) - { - operation.Parameters ??= new List(); - operation.Parameters.Add(new Microsoft.OpenApi.Models.OpenApiParameter - { - Name = "X-Language", - In = Microsoft.OpenApi.Models.ParameterLocation.Header, - Required = false, - Description = "Language code (e.g. 'en', 'es'). Falls back to Accept-Language then 'en'.", - Schema = new Microsoft.OpenApi.Models.OpenApiSchema { Type = "string", Default = new Microsoft.OpenApi.Any.OpenApiString("en") }, - }); - } -} - public partial class Program; diff --git a/src/apps/ums.api/Ums.Presentation/Services/JwtTokenService.cs b/src/apps/ums.api/Ums.Presentation/Services/JwtTokenService.cs index 9ad7299e..04986a54 100644 --- a/src/apps/ums.api/Ums.Presentation/Services/JwtTokenService.cs +++ b/src/apps/ums.api/Ums.Presentation/Services/JwtTokenService.cs @@ -15,7 +15,12 @@ public interface IJwtTokenService /// Generates a JWT token that embeds the full AuthorizationGraph as claims. /// Used by /client/authenticate — the token IS the auth graph for the client system. /// - string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph); + /// Grafo de autorización que el token transporta. + /// + /// Sesión a la que pertenece el token (G-247). La comparte con la cookie emitida en el mismo + /// login, para que cerrar sesión cierre el dispositivo hable por donde hable. + /// + string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph, string? sessionId = null); /// /// Generates a semantic-only JWT for external client systems. @@ -38,18 +43,35 @@ public record TokenGenerationRequest( string? ProfileId, string[] Permissions, string Language = "en", - bool IsInternalAdmin = false); + bool IsInternalAdmin = false, + /// + /// Sesión a la que pertenece este token (G-247). Es la MISMA que lleva la cookie emitida en el + /// mismo login: cerrar sesión cierra el dispositivo, y un dispositivo puede hablar por cookie o + /// por portador. Si va vacío, el token no es revocable por sesión — se genera uno para no + /// dejarlo sin identidad, pero entonces nadie más conoce ese identificador. + /// + string? SessionId = null); public class JwtTokenService : IJwtTokenService { - private readonly string _secret; + /// + /// El material de firma, único para los tres tokens que UMS emite. + /// + /// + /// Antes cada método construía su propia SymmetricSecurityKey a partir de + /// Jwt:Secret. Tres construcciones idénticas son tres sitios donde el + /// algoritmo puede divergir sin que nada falle, así que ahora hay uno solo y es + /// RS256 (ADR-0157). + /// + private readonly MaterialDeFirma _firma; + private readonly string _issuer; private readonly string _audience; private readonly IConfigurationProvider _configProvider; - public JwtTokenService(IConfiguration configuration, IConfigurationProvider configProvider) + public JwtTokenService(IConfiguration configuration, IConfigurationProvider configProvider, MaterialDeFirma firma) { - _secret = configuration["Jwt:Secret"] ?? throw new ArgumentNullException("Jwt:Secret is not configured"); + _firma = firma ?? throw new ArgumentNullException(nameof(firma)); _issuer = configuration["Jwt:Issuer"] ?? "ums-api"; _audience = configuration["Jwt:Audience"] ?? "ums-web-app"; _configProvider = configProvider; @@ -57,8 +79,7 @@ public JwtTokenService(IConfiguration configuration, IConfigurationProvider conf public string GenerateToken(TokenGenerationRequest request) { - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + var credentials = _firma.Credenciales; var claims = new List { @@ -69,6 +90,11 @@ public string GenerateToken(TokenGenerationRequest request) new("tenant_code", request.TenantCode), new("session_tracking_id", Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + // G-247: la sesión, compartida con la cookie del mismo login. `jti` no sirve para esto: + // es distinto en cada token, así que revocarlo no alcanzaría al que emita el refresh. + new("sid", string.IsNullOrWhiteSpace(request.SessionId) + ? Guid.NewGuid().ToString("N") + : request.SessionId), }; if (!string.IsNullOrEmpty(request.Role)) @@ -111,10 +137,9 @@ public string GenerateToken(TokenGenerationRequest request) return new JwtSecurityTokenHandler().WriteToken(token); } - public string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph) + public string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph, string? sessionId = null) { - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + var credentials = _firma.Credenciales; var ctx = graph.Context; var claims = new List @@ -124,33 +149,49 @@ public string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGra new(JwtRegisteredClaimNames.Name, ctx.User.Username), new("tenant_id", ctx.Tenant.Id.ToString()), new("tenant_code", ctx.Tenant.Code), - new("sys_suite", ctx.SystemSuite.Code), - new(ClaimTypes.Role, ctx.Role.Code), - new("role_name", ctx.Role.Name), - new("profile_id", ctx.Profile.Id.ToString()), + // El privilegio de operador (internal-admin) se deriva de IsManagementOwner + // (igual que en AuthEndpoints). Debe viajar en el token Bearer de grafo — no solo + // en el principal de cookie — para que switch-tenant y la gestión on-behalf del + // operador sean alcanzables vía API (evolith-core#18). No aplica al token semántico + // de clientes externos (GenerateSemanticGraphToken), que nunca es internal-admin. + new("is_internal_admin", ctx.Tenant.IsManagementOwner ? "true" : "false"), new("auth_method", graph.Authentication.Method), new("graph_generated_at", graph.GeneratedAt.ToString("O")), new("graph_valid_until", graph.ValidUntil.ToString("O")), new("session_tracking_id", Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + // G-247: la misma sesión que la cookie del login. `jti` cambia en cada token, así que + // revocarlo no alcanzaría al que emita el refresh. + new("sid", string.IsNullOrWhiteSpace(sessionId) ? Guid.NewGuid().ToString("N") : sessionId), }; + // G-043: en el grafo lobby (onboarding pendiente) SystemSuite/Role/Profile son null; sus claims + // se omiten (el token lobby no otorga rol/suite/perfil, coherente con "sin perfil aún"). + if (ctx.SystemSuite is not null) + claims.Add(new Claim("sys_suite", ctx.SystemSuite.Code)); + if (ctx.Role is not null) + { + claims.Add(new Claim(ClaimTypes.Role, ctx.Role.Code)); + claims.Add(new Claim("role_name", ctx.Role.Name)); + } + if (ctx.Profile is not null) + claims.Add(new Claim("profile_id", ctx.Profile.Id.ToString())); + if (ctx.Branch is not null) claims.Add(new Claim("branch_id", ctx.Branch.Id.ToString())); if (graph.Authentication.Provider is not null) claims.Add(new Claim("idp_provider", graph.Authentication.Provider.Name)); - // Embed permissions as compact claims: "TargetCode:ActionCode:Effect" - foreach (var module in graph.MenuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options) - claims.Add(new Claim("perm", $"{opt.Code}:{opt.ActionCode}:{opt.Effect}")); - - foreach (var res in graph.DomainPermissions) - foreach (var act in res.Actions) - claims.Add(new Claim("domain_perm", $"{res.ResourceCode}:{act.ActionCode}:{act.Effect}")); + // G-172: aquí se emitía un claim `perm` por CADA par opción-acción y un `domain_perm` por + // CADA par recurso-acción, SIN filtrar el efecto: sobre datos reales, 84 + 286 claims que + // hinchaban el token a 16-24 KB para UN SOLO sistema, enviados en la cabecera + // `Authorization` de cada petición. El grafo ya viaja en el cuerpo y el cliente lo cachea + // toda la sesión: el token no necesita repetirlo. + // + // Los `scope` SÍ se quedan: la autorización del servidor los lee (`UserContext.HasPermission`, + // que alimenta al aspecto AOP). Quitarlos denegaría todo, fail-closed. Moverlos a una + // resolución por sesión es una mejora aparte, no un efecto colateral de este cambio. foreach (var scope in graph.Scopes) claims.Add(new Claim("scope", scope)); @@ -170,8 +211,7 @@ public string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGra public string GenerateSemanticGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph) { - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + var credentials = _firma.Credenciales; var ctx = graph.Context; var claims = new List @@ -181,11 +221,6 @@ public string GenerateSemanticGraphToken(Ums.Domain.Authorization.Graph.Authoriz new(JwtRegisteredClaimNames.Name, ctx.User.DisplayName), new("tenant_code", ctx.Tenant.Code), new("tenant_name", ctx.Tenant.Name), - new("sys_suite", ctx.SystemSuite.Code), - new("sys_suite_name", ctx.SystemSuite.Name), - new(ClaimTypes.Role, ctx.Role.Code), - new("role_name", ctx.Role.Name), - new("profile_scope", ctx.Profile.Scope), new("auth_method", graph.Authentication.Method), new("graph_generated_at", graph.GeneratedAt.ToString("O")), new("graph_valid_until", graph.ValidUntil.ToString("O")), @@ -193,6 +228,36 @@ public string GenerateSemanticGraphToken(Ums.Domain.Authorization.Graph.Authoriz new(JwtRegisteredClaimNames.Jti, GenerateOpaqueId()), }; + // G-043: grafo lobby → SystemSuite/Role/Profile null; se omiten sus claims. + if (ctx.SystemSuite is not null) + { + claims.Add(new Claim("sys_suite", ctx.SystemSuite.Code)); + claims.Add(new Claim("sys_suite_name", ctx.SystemSuite.Name)); + } + if (ctx.Role is not null) + { + claims.Add(new Claim(ClaimTypes.Role, ctx.Role.Code)); + claims.Add(new Claim("role_name", ctx.Role.Name)); + } + if (ctx.Profile is not null) + { + claims.Add(new Claim("profile_scope", ctx.Profile.Scope)); + + // El PERFIL VIGENTE viaja en el token desde ADR-0156 §8. Sin él, el cambio de perfil + // duraba lo que la respuesta: `GET /client/graph` reconstruía el grafo por desempate y + // devolvía otra vez el perfil que el usuario acababa de abandonar. Medido: `pmo.sdlc@` + // cambiaba a PMO —75 permisos— y al revalidar volvía a DIRECTORIO —7—, en el mismo + // proceso. Una conmutación que no sobrevive a la siguiente petición no es una + // conmutación. + // + // Este token evita identificadores internos a propósito, y por eso el `sub` es el + // nombre de usuario. El del perfil es la excepción justificada: desde el contrato + // 2.4.0 el propio grafo publica `profiles[].id` a ese mismo cliente, así que el claim + // no le revela nada que no tenga ya. No se usa un selector semántico rol+sistema + // porque no desambigua: dos perfiles pueden compartirlos y diferir solo en sucursal. + claims.Add(new Claim("profile_id", ctx.Profile.Id.ToString())); + } + if (!string.IsNullOrWhiteSpace(ctx.Branch?.Code)) claims.Add(new Claim("branch_code", ctx.Branch.Code)); @@ -202,16 +267,8 @@ public string GenerateSemanticGraphToken(Ums.Domain.Authorization.Graph.Authoriz claims.Add(new Claim("idp_strategy", graph.Authentication.Provider.Strategy)); } - foreach (var module in graph.MenuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options) - claims.Add(new Claim("perm", $"{opt.Code}:{opt.ActionCode}:{opt.Effect}")); - - foreach (var res in graph.DomainPermissions) - foreach (var act in res.Actions) - claims.Add(new Claim("domain_perm", $"{res.ResourceCode}:{act.ActionCode}:{act.Effect}")); - + // G-172: el token semántico tampoco replica la matriz de permisos. Los `scope` bastan + // para autorizar y son la mitad del volumen; el grafo completo viaja en el cuerpo. foreach (var scope in graph.Scopes) claims.Add(new Claim("scope", scope)); diff --git a/src/apps/ums.api/Ums.Presentation/Services/MaterialDeFirma.cs b/src/apps/ums.api/Ums.Presentation/Services/MaterialDeFirma.cs new file mode 100644 index 00000000..7bfcf6f0 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Services/MaterialDeFirma.cs @@ -0,0 +1,178 @@ +using System; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Ums.Presentation.Services; + +/// +/// La clave con la que UMS firma sus portadores, y el material público que publica. +/// +/// +/// +/// Es ADR-0157 ejecutado: +/// UMS firma con clave privada RSA en RS256 y publica solo la pública. +/// La diferencia con HS256 no es de robustez criptográfica sino de quién puede emitir: +/// con un secreto compartido, verificar y firmar son la misma +/// capacidad, así que cada satélite que valida es también un emisor y ninguna firma +/// identifica su origen. Con RS256 la clave pública se reparte sin custodia y no +/// concede nada. +/// +/// +/// La clave privada no entra al repositorio bajo ninguna forma —ni +/// PEM, ni JWK, ni base64, ni marcador de posición—. Llega por configuración +/// (Jwt:PrivateKeyPem), y el arranque falla si no está: G-203 dejó escrito lo +/// que pasa cuando un marcador del repositorio acaba siendo la clave de todos los +/// entornos. +/// +/// +public sealed class MaterialDeFirma : IDisposable +{ + /// Tamaño mínimo de clave. Por debajo, UMS no arranca. + private const int BitsMinimos = 2048; + + private readonly RSA rsa; + + /// Construye el material a partir de la configuración. + /// Configuración de la aplicación. + /// Entorno de ejecución. + /// Registro de arranque. + public MaterialDeFirma( + IConfiguration configuration, + IHostEnvironment entorno, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(entorno); + ArgumentNullException.ThrowIfNull(logger); + + string? pem = configuration["Jwt:PrivateKeyPem"]; + + rsa = RSA.Create(); + + if (string.IsNullOrWhiteSpace(pem)) + { + // En desarrollo se genera un par efímero. No es una excepción a la regla de + // §4.7 —«la clave privada no entra al repositorio en ninguna forma»—: es la + // única manera de cumplirla sin dejar el repositorio inarrancable, y es lo + // mismo que el ADR prescribe para los arneses de prueba. Cambia en cada + // arranque, así que los tokens no sobreviven a un reinicio: en desarrollo eso + // es una molestia, y en producción sería un incidente, por lo que allí falla. + if (!entorno.IsDevelopment()) + { + throw new InvalidOperationException( + "Jwt:PrivateKeyPem no está configurado. UMS firma sus portadores con clave " + + "privada RSA (ADR-0157) y sin ella no puede emitir ninguno. Se inyecta por " + + "secreto del entorno; no existe valor por omisión, y no debe existir: un " + + "marcador de posición acaba siendo la clave de todos los entornos (G-203)."); + } + + rsa.KeySize = BitsMinimos; + + // El respaldo efímero sirve para UN proceso. Con varias réplicas cada una firma + // con su propia clave y publica en su JWKS solo la suya, así que un token emitido + // por una réplica NO verifica contra el JWKS que responde otra. Medido el + // 2026-08-04 en kind con dos réplicas: la mitad de los tokens se rechazaban, de + // forma intermitente y sin ningún error que lo explicara. El proceso no puede + // saber cuántas réplicas hay, así que lo dice en voz alta y que lo vea quien + // despliegue. + logger.LogWarning( + "UMS ha generado una clave de firma EFÍMERA (kid distinto en cada arranque) porque " + + "Jwt:PrivateKeyPem no está configurado. Solo es válido con UNA réplica: con varias, " + + "cada una firma con una clave distinta y los tokens de una no se verifican contra el " + + "JWKS de otra. Configura Jwt:PrivateKeyPem antes de escalar (ADR-0157 §4.7)."); + } + else + { + try + { + rsa.ImportFromPem(pem); + } + catch (ArgumentException error) + { + throw new InvalidOperationException( + "Jwt:PrivateKeyPem no es una clave RSA en PEM legible.", error); + } + } + + // Fail-closed de arranque: una clave corta no avisa en tiempo de ejecución, + // firma igual y deja el sistema debil sin que nada falle. + if (rsa.KeySize < BitsMinimos) + { + throw new InvalidOperationException( + $"La clave de firma tiene {rsa.KeySize} bits y el mínimo es {BitsMinimos} (ADR-0157 §4.7)."); + } + + RSAParameters publicas = rsa.ExportParameters(includePrivateParameters: false); + Modulo = Base64Url(publicas.Modulus!); + Exponente = Base64Url(publicas.Exponent!); + Kid = HuellaJwk(Modulo, Exponente); + + var clave = new RsaSecurityKey(rsa) { KeyId = Kid }; + Credenciales = new SigningCredentials(clave, SecurityAlgorithms.RsaSha256); + ClavePublica = new RsaSecurityKey(publicas) { KeyId = Kid }; + } + + /// Identificador de la clave, que viaja en la cabecera del token. + /// + /// Es la huella JWK de RFC 7638, no un número de serie: dos despliegues con la + /// misma clave obtienen el mismo kid y dos claves distintas nunca colisionan. + /// De eso depende que la rotación pueda solaparse (ADR-0157 §4.4). + /// + public string Kid { get; } + + /// Credenciales con las que se firma. RS256, siempre. + public SigningCredentials Credenciales { get; } + + /// Clave pública, para que UMS verifique lo que él mismo emitió. + public RsaSecurityKey ClavePublica { get; } + + private string Modulo { get; } + + private string Exponente { get; } + + /// + /// Proyecta la clave pública como JWK, tal como sale en el JWKS. + /// + /// + /// Declara kty, use, alg, kid, n y e, y + /// nada más. RFC 7517 §5 y OIDC Discovery §3 prohíben que un JWK + /// Set publicado contenga material privado o simétrico: un JWKS con una d + /// dentro no es un error de formato, es un incidente de seguridad. + /// + /// El JWK público. + public object ComoJwkPublico() => new + { + kty = "RSA", + use = "sig", + alg = "RS256", + kid = Kid, + n = Modulo, + e = Exponente, + }; + + /// Libera la clave. + public void Dispose() => rsa.Dispose(); + + private static string Base64Url(byte[] valor) => + Convert.ToBase64String(valor).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + /// Huella JWK de RFC 7638: SHA-256 del JSON canónico con las claves ordenadas. + private static string HuellaJwk(string modulo, string exponente) + { + // El orden de los miembros y la ausencia de espacios NO son estilo: RFC 7638 §3 + // exige exactamente esta forma canónica, y cualquier desviación produce una + // huella distinta para la misma clave. + string canonico = JsonSerializer.Serialize(new + { + e = exponente, + kty = "RSA", + n = modulo, + }); + + return Base64Url(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonico))); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj b/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj index 961509f0..56b122ff 100644 --- a/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj +++ b/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj @@ -15,15 +15,23 @@ + + + + + + + + - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.Development.json b/src/apps/ums.api/Ums.Presentation/appsettings.Development.json index 6b321f0b..2a2d642a 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.Development.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.Development.json @@ -14,15 +14,10 @@ "Persistence": { "Provider": "PostgreSql", "AggregateStoreMode": "PostgreSql", - "UseSqliteIdentityStores": false, "UsePostgreSqlIdentityStores": true, - "UseSqliteAuthorizationStores": false, "UsePostgreSqlAuthorizationStores": true, - "UseSqliteConfigurationStores": false, "UsePostgreSqlConfigurationStores": true, - "UseSqliteApprovalsStores": false, "UsePostgreSqlApprovalsStores": true, - "UseSqliteIgaStores": false, "UsePostgreSqlIgaStores": true, "SeedDevData": true, "EnableOutbox": true, @@ -49,7 +44,7 @@ "Logging": { "MinimumLevel": "Debug", "ConsoleFormat": "Text", - "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}", + "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}", "LokiEndpoint": "", "LokiAppLabel": "ums-api", "LokiEnvLabel": "development" diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.Production.json b/src/apps/ums.api/Ums.Presentation/appsettings.Production.json index baa54cc2..fcd3b880 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.Production.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.Production.json @@ -13,11 +13,6 @@ "Persistence": { "Provider": "PostgreSql", "AggregateStoreMode": "PostgreSql", - "UsePostgreSqlIdentityStores": true, - "UsePostgreSqlAuthorizationStores": true, - "UsePostgreSqlConfigurationStores": true, - "UsePostgreSqlApprovalsStores": true, - "UsePostgreSqlIgaStores": true, "SeedDevData": false, "EnableOutbox": true, "InitializePlatformStoreOnStartup": false @@ -41,7 +36,7 @@ "Logging": { "MinimumLevel": "Information", "ConsoleFormat": "CompactJson", - "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {SourceContext} {Message:lj}{NewLine}{Exception}" + "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {SourceContext} {Message:lj}{NewLine}{Exception}" }, "Tracing": { "OtlpEndpoint": "", diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json b/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json index 2015fc47..493280f0 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json @@ -18,9 +18,9 @@ "UsePostgreSqlConfigurationStores": true, "UsePostgreSqlApprovalsStores": true, "UsePostgreSqlIgaStores": true, - "SeedDevData": false, + "SeedDevData": true, "EnableOutbox": true, - "InitializePlatformStoreOnStartup": false + "InitializePlatformStoreOnStartup": true }, "Secrets": { "Source": "AppSettings", @@ -41,7 +41,7 @@ "Logging": { "MinimumLevel": "Information", "ConsoleFormat": "CompactJson", - "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {SourceContext} {Message:lj}{NewLine}{Exception}" + "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {SourceContext} {Message:lj}{NewLine}{Exception}" }, "Tracing": { "OtlpEndpoint": "", diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.json b/src/apps/ums.api/Ums.Presentation/appsettings.json index 765e539a..e1e938cf 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.json @@ -48,7 +48,7 @@ "Logging": { "MinimumLevel": "Information", "ConsoleFormat": "CompactJson", - "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}", + "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}", "LokiEndpoint": "", "LokiAppLabel": "ums-api", "LokiEnvLabel": "production" diff --git a/src/apps/ums.api/Ums.Presentation/umsdev.db b/src/apps/ums.api/Ums.Presentation/umsdev.db deleted file mode 100644 index 91739afa8830926eb7a171b0afde043aa5fe4f21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1716224 zcmeFad5mPoc_&y$*L|tkY?m&Qsg`K6MLzbsBOWQL71wK~SQoQ0*=kFgn(V4q)oE2_ z7BjP&YzaP^B57+5Ntp4@$Od+1V0o~!i?RKOfw8^7E+7+wUBL2UF|#WR3oKv@3m9t) zi}k=3cHvoje-W8kRas|tvnh5{kz{9O=KJ0ezxd*dFTRNQeakP+Rhn6Dy;k2UH*-PX z2l@sE`aYA(_4V})^!4?B8-F{mZ{ow5&KLac-}m{rj{|+rymfI1M<-{#qh-D`^EWd; zn)%C_AI|*Q%nxS%_nGg{{KuI;n)x4E2amgm{rx>fAekJVoqwXguTovhUTeIv34K{9 z?=)-X`--fs?rde%X7Sc`)>!enzD~XI>e$HafQCuzR!JYh=~M)zzJP z{kZE~y-{w88|CW8aRTaQ8>|DxY2Tq;ipI`n^MvPM>+5*fY)w_0mFBHO&~&cVC8aCq z=zA7wwPrD^maFELT_CrwG@mugYvpFSb3Wj29})Gej0fSjI)~b|(|wpVOU>1zB}&`4 z@cs+zUAuj#>*T$o2B~6YD_h?bzfHXH{K&}crAz(4 ze6gjn+uQZp&GP2eTK!Awo3&RP?BEZh2bA~V@jKAq!>1lrdRTZVIbjii+tBkj9+A5t2g$<_S(a)NTIg>S-Ok#*_}qSvR+v&?{5No*SLbK z<5{||uy^Ug0LZ!QiRL;DZrJ6bMk z)%D88PTc^l)x7(MGl%rN`}iFx`oU8VYx+S$yH{nW7}@#S+RlD$+B@-h^ZRmnbLVdD z@A7JG+cczOnoGWsuinV&6$A6JjVj+z)z$i~ZK(1IG7Gip;?1mHudHc*?pVQH<9L)a zaccX1P@4ohbBgKjRyxxrx_=sa<6Qs9?B&b-zZA6^#p-I-K*eBqwwh@#d#ScrS*@V^ zJnq22fktwi+#NKQ<1cY~qv^J!Cm8+X%;&zc!+VDvJ)vL71D0mx=J9Izpeoi&1H-e~ z<)dvahKB#m?I-a5Pj8>c`v|^>)zDE zC9H%!`Cnls1&{{L>wlHN+M$JL@Z35 zK>5<~gv*_Tk~j(j-*ZznpR$#Lda0BzD7lg@7FL*?&o8W(Hyhd28(90C-f8U&?NC3 zU$BIVG>8SELCR>9#vx=p;)GG`J3L4QWRf^YPLfjP5E%=n)d*Y7ah`o{Wp43G zeqn_bi)wzUxE#&t29+x}u_AuEncJ*vRhkABAcDOi_XQ?TkF^@w>_!(R9$|?<0YxgH zHcZ=`BnX|vg<|l4DHW(*Fgd=Vbs4BrTH1XC*QL?(dQlshy?uFXzcF`}?}gAIMuXT1 zxeINgQR1Y`jeNo-^;ODB#TQme%W4ICh*V*L&8_6+QWp#`*14wH29w)qWUw}`HM4rP zyqU|dVdW+E+2pW(@n&TW3;%O4)Xl8CmaDD9Y_CFQyWD8JihWJFRcsB?;^+bH2TT5u zk(OOLd#y{sT+j6q;X8uH=<}oq917iX0vRQt%N0*N*mHDv=nZee6xJw)gTQ&B6lXl;N_p*l{P#E9U3b&nzye6{$6F zuE@6F46K$f%~iI_GFvZWn^&%s&o!})>C3gM*@tO%Js1TwuEc$T(^Eq&oRXLJYmuvj zk4`FbBoAS_1K4IpJ>N-P7C>iIDA2?8;18VEyFIP8%9YK<=Js*j+oBxp(Ms?lr;t$>W?IG?+biw#zwi1@Wnh1IL3l z!LY;Mh3mxO&I$_eAoiJ(EJ!1sNZ%nMkWMJz9KeBa9nTNoeiE86E|%5ua(;2aG{?nK zaRqj(R8-5pyNu=aSX6ErU~Q-69a*Ap5-(5saU^-iU>~sK=4HdS>ezRw{TRud&dV(AA<1y_R+5eLzynTb??du`l{xP--^#7v|G}Vc21om_ z6aTko-kSO1OgJ+!{e$V>pZ@0b#`LGB&rkhu>g}mtn|fvHg{j9Te>C|=lfN8$md7=k+I=F8UEejuMcO#pBg?l^yfq08T!^xZ73ajmdMWCWSVl#fAblIUT*bTMQX=$6nKp(~;hmr5)d!6?Eo9-cE7%tLHR zDks611g9t%orJ+m`-?`Jj* zTqp1yicyafxr}%!O?^tjc`XPRHBu7Yq6i{^F(k#nS*8-wK3p>nVM6ANAPp!?qjG%i z;~9m1S}!0DjX6o;&`aGgez7eW_~=`lNa&E6uOf%1aQxy(;1N<^syO_t5hSsS*5oXE{cUGg)oBfY8dg+88M3Mq^Ld02`($R2v|f>YXR4Sf_q&05%ek~ zfG3EdkCDV>CGi3e%0`99v>?t4QP~uZI0fL8rs(6)(WQh+FOUkL$Dc8R#CNHV$>GAd ze-IKU@d3CW#LyRnqP*0*Yy@#Rl0f%WD&X3~*MQrqI0k=0dOjR6nfRYJf-Z;K;XxJM zBtab2#WVVFOIYHv#P_0<3HNy|i297Va(#z!iUuGA#^_ApkwX$hsFo-W$ft}T?gppL z#SmV5HnD{EBxqbDgjV@NkHmuKv|yqlB2c>soDq~dLiI2uJSUcnGe}2iDxNih46VZT zfi+E`xD;VF&Y+nQT<%oF&|gm`Mv$n;SFyx13c%xg@Tbs>9EP)9m*YehP$L*ik4Fri zCed{*T@N+`xAsD`Dk&I;b`dQ|1+LDL7=__bTpGjsV6JqC0)z^IEEkQ>2qG>B{l_!< zXwo4riBo)r)*FW`MayJv7_|fw=m%m$Xto%f3ZTw(d-UTNumNC}s?Z1~LctYOPLRS} zpwwJfA$8yk5wwO_poIi2!B8dm?g$d0FcAiHFkz_2m~bA4G=Kv8Mvx?~A1dX*9O{}8 zAxe!VFfl@Bn(K1HYW2bwM9J7qL#*$i#DvVBook2ouMpxNPY9 z)L}X(FQMTvzLH3$m$V>}lye0ml_vU%6sNE#h!B~F|CUl{g`m$EK^u1Uh(IYq zo|-{MBxL9zWcid43_QU@*hhtD$20n{X0YT=>;+J_h@iE5pEQCVqA#$yFrP|mkB=UQ zd-~dGlEystm=|3%f-cN~&tOB60E){o6Tr1#;!!_{fU7I%lUgvAs6RAbm}b+vK-Utk z8)bsXs`NZxvET_K2n!P@h{q=?F$MsoPU8eN98EW*nD*ewPa8p|5{&980{5_rAq%15 z0L4Mbnn&mpU@*wzEkO}u<^+a>>tG2(qu?HqP733Nz_z53B%d&XB;tr2!2l#MjUgT# z8ip=5P9#q~ii`U4<3=z@coM?^M5rKu;s*K|Q&c^tsC~p_RB}NJMzC1{5XaPvRtgTq zMd#)@Zip92Q&=?bV@6P*LYb~Y6+v;)B_q77)f_q+%Ongjh|0AE-B6;xio8f0Ql*VO z0`iJU7+v&Kkx-8r!B9j2hlWe2I@&CQ?ZE_y1MOBE^^cpSkG2F8bfgM5gjv*00S#B8 zGEokfr!FC`3?4Cpfsdx2V5UUEq=g!51iqz%M#^BzNrb`PN3~#x0F=h)s{l&^Rq+!qjM)cUg3@IijZ5KKachbz zDyT=4MmkQ5t}~&tS};iAYCta>v~S&9VU-z!JB3Ec1H~m~jl!QYf_MlFF;`2rj4osvlD@7zU4AU+EoVY2T(RIU=Gr-3mHiDQ|;AmeOJ!m)) z@BkRBxer|YR0;`maLx!)ffxgfHmU%onQ7ar5M3szL@+SoX%f2+8A130r~vc|2z@~x zVI<)-GRH;rP&5xePJ**q(2tZWVb9QCBq)rpCpAQbDkP}!$alRUR39*cAvzLFAi)4Y z6~hp2DGyOLSZWGXlLWoaj1deZA<%lqN5BNlUZGtZ|CPZ51rV`f?z9%{W!`hj2=*}9 zH`x+AeU59w2=*}3Gj0TXnEn_uf<46jM_YoY$Gb>~p9kczW3Hj1lZ1 z)-`AZdkFmuJTX`rb9)GV=>7l0!~bvJbbe}R;{4bzk1BkzFN=Uhz#?D~un4?g5qLd6 ze6ECrVPmDf(rv7QD+OT3ne;KG6e4`(xTzj4V4)MjLMax1VdP5~A_77$5Mx0cAS@F{ zSbP;C`hj2+qIO(jM4w9PV)EQ?zp%Uvafj}=_90(SKwwQ4#w&Ooc3e!&Sjgq^4b>WZS8G`D)p{Mc zz4_Wcbks7lgX_gUGxSgg%bgCC3IVn##5hGDtg7QUSoVZr42HT`laV6Ql3>z`VwISZ z2w!4c07n>ZGaUXH6EDh56YV$i_KGqL0#8^`b_Y=UClz%UO3ZSBo-hYuHO)qMv(|#K zk!|c?6%m$q)HKB418tV?5rkhIKVu;5hdRE5e`b-QM9f*m2oefJ@i2_S3Jt_51lF}6 zAgC2pB3#`&got<@zbJ3uUSVjc$!hzc+j{$VK!~RiYiyTSZ`7V?EA@#rahHmDm!}=j z=dW%JYTHoKl~ICqXw?RSQ67R#ffFOVhhcJxm?%Xg8qrwyWqa8v286 zChRx;sctj*zT1~tZ6*pi8U{@qtSdubiODPAhv;aEMH@ggOhbgndbeOFb^?QUV8KpA zvE2*otmW2rw!yau>T9FV;JvKXpE=^_GdZ=1`Tm_UmPX`mm9d&6!&kBs$$xrS!$d~a1>&;Jk4O!Uo6?5zNJzq)+3TowU~fJML}U=gqgSOhEr z76FTZMZh9p5dZ>uZoQZIznTB zzUlvfmHq?xU|$vii-1MIB481)2v`Ix0u}*_zypN9>&}@OtXd%XUl_VcK)^|M$5>zdmVGtyECU) zi_luFRMv|Uz_fz(3Aj{ge)--^{@>mh1YUiQla;a27GAXdIV?+=TgDex z*wRvA@nr>?rkc}A^_uOLy#0MGSJ~Ri)+)%JyLl`3Y6bjFpdz@jvxVK`dQDgM>OCsy z>VPik6UlN%Gj>a=tN%dNcQm4xlFqY*5*T$_wLM?1?x2DW)UwX|sT2QVKfIn(2NhF! z8&wT*w47c8;?&mH?@cLRf2v=X@?&bNANsGk-g;kPa58V%;sRe}1pvpZf5>eOUx70u}*_fJML}U=gqgSOhEr76FTZMc@HJ;Pp%WXF=P+o_J!c^q~?d zvA(`3H{pH&?nhAR24J@YA+P2U3XZj|FTE_DXr0iD4*@Z*fA15Ghs@j)cfDa%zV@vPB{h zWP(KnA#%AYHM0j zc4U!1W2aoL4c_6rzZ+q_0{%acw!zN-|HP@bEwDwvB481)2v`Ix0u}*_fJML}U=gqg zypIsj^Z&iXET(4upT3E{U+A0q#>~GS|MJw_$ghkiL&c%91OI&B@8M^O!>7KI$>G`g zC;Iy;)wS%k#w(kZX0}oW-=+D!(urpjZ*6Cd6|d{-)K|YSF*5ta6a8+FsLpQ}!+ZaN5^yAL=@J z?J3vsKDWqsAAbBI86Tc~@$us{!(6+Nt=8&mP>lWG zCf;~;Y-INF$NRtL8NJwlZ2#x+ZmrmVsCT94KI^bfT)k0as{rq4WS4iY|7^C})Y^SQ z1zx?8RTo!Rck1=yu5f0Rs9PLsxb~c+QJO^7}$HQi83bDqP z#&xxqwn}tM9e>Y4t=2rb#mr|-WKV6DJI}nQSnb_Xdm7npvfUcrrIpY<%=!lJt%PKjL`#XeDH)j(B;FY9#-g+ zt8DoeQqyiN@4$-h*};AFrgxy-Ya3WB*Eh0esgczM%+yA$ev&clFbQ{HeBW8kH!lp0 z%sNj0*IJ!or<}UnNafr(_WJi_u%w*@6kvA6y zjg|Zd^BpTGYSs11#!lS;t<}8yhck!ty!-eaDf+=v4{Q2CE4f!?r&!4O+S<;3ZQ48W zc>nR`^5)JlX7yADa#?2)KcTtg8~N&u3|^-8u#YvC`G%^l)^BYaN9PzfG~ZaLRTn|o zUdKe`Nf+Do2aobDPHo>0YLj4RPH`Q&mCm$@?w^L2_}#X@oUlXtn!;Vw@*usnkLGG&Y_CnX7%eK0{C8cFU% zLi&uvVGt9S1!=?+tO_9_kRX_#%3%q29nTNKC?+&vT)cjM=4|PSF$78WgAq_)CBmU$ zit}Q8N@PMDHwXeoTp6T1eR(l2)XGv`yojt5bvP-{E-tA8D=rq^+q4mqOP-+I4)X$3 zWXe5<`$9TOaOwwCx=OiG8md70!tuDvoFoMlyY{?sA`*HEO zP>Gki4wZl;jJWSm9w!cU(~tK# zNkml=vQ{BqW^;L&FJ4`52S8WmQ}*5(#(Ras?TzunH69BQMSkR@N}$HkPQcg` za7S=IN>Z3dH%xzgHLl!%hk-Ui3^|fQ-xCh^BI1O~B?48(JnAKiN+M$P3oJ~XK>5<~ zgv*_lk<;cbRj&_?9jPa} z1SLyCk@C{qiygP%y)^`h&I+e|&q*mk^AKpZNq{$W(r^UO>5EXMx<#NtI6Q&JM^YH` zNQT1M^8*6^_4C7rJS10wn@cF}cuB%w5MA^rDSQI>1u%{YMK{OpR}YCXKlUgJ$)UZD zF(91N$ic5+AQIpAQq>FR*Xw77&XyiKpdqoOy1_ZbP2f{VG)fwWiQ_YLiV*?Qv5>6r zqV56Jd#fJWO5O{raeMg;8cM%*f7&auOC>k$qdykfWG)12tqcrtWbVroKYs7FObQntsl!mxL7aocmP5#m3Z%lq=vM_mZvTx##Cw^<<&518e1QX-qe>(nqqzfZL3~0JdpG0dr!S za6POL#P?GpNCQf^9$_cguob!zVL0hg_^v{vA&(-A?UWHD+(rL@onF3M_Tcp~%A`Xn9OM`-iU_GNf&v~qB7_b`k-7s+V?2b9 zon<5?v6LxTTM}*rDaOGJiPRLgcsv9P z5I~D4;s##mGL?SD2u4Io1RWjULl84m7+-J}#OS^dal_avCMvpY1OuW{5~5S#I#BET z7=sX;$2r$rvMP)P`Lq#qDGDGk@{Qr&hpB+WE8($AL;{J3d>Z)Tc`YcV$5I^_!Y~Ha zqXeE&s2Ct%sPDm@MOg1sMvx>afTT{0VLlvZ7h_0|1`Z)!>|#J6A`w4l1Y?G10K6{Z z65Lua-{ENho?nqDSNFeB$e-1Mf+>%M@RX%e@YX_tUK+(k^(c(p5m4|LNsM6ZBa#b; zGGZ9Hh7to(>OoosDv1dE2MHxIf&uD}pmG!9;fmac52`%?kGdY*3hD7s5-rGi8VQ^V z=fjIpbOb_Kh|8ua+#ysh2_=t>V8AfELoZH+4hAO?VjgiI90iXOG{p#f;Mt6z8>D~( z>Z%Zag;HR=6(REABRon&H-vH|p%!Ec0XYivBJScDwF)y8K*JTDhhfyNqzGDqvEJI4 zA}FtKjkqnMh`JtCL}L%I`kv<-LDVSjhd>P4gdW`^z`+rY@<+7DVZI?0cb;x(tfTS%N{QZnJUVrxA)0!96pALb$09 zok^i7ct-f~oFjq_ASCMC_3+EnEkOdGl_$70fv3eV{$!ZBKnssU1s!msL_B2#IYaY= zQXpOlH75!|3>P(%$UtGkC`r(iK4}D9hR{BIH!nhHE2L%5%fGB$Pj^GibylA2p^XRz9XPbK4l?IMEryi zbP-UAT(tNQFo!8r2qSEug{X}iAnL|K_orKe0#h_lX_!pdM-PgFKy~88Q0z2DtMG~c zcuNp5`84!Ug|TkeGD4sX%7jLTQCJ*BiU*%)2?k!wpx21>;MNf|11_xVAj&0N6$UEy z+>dKP4L*m3alBZfuDuYkb8NGP?TZmUU~Uw6;(`%`O+gzE(3();VT6YOd?~6vOcYF~ zpZfe`Mliu(4W`0T2`W5vQ7nX6;k9G3MsQ9LfM>auU>FG=pjVKv%J8poYi-RDk0sDP zlsc7<8Nnz*>!fH+XmQ&8hH2Cd$LE;cfKj0&@ELIfbQ25<#iv`!)H5ZpO3aBcwS-cmz?`DZ zz(`0;pWq0k=ZzrDoyQ=k%_Qt&3S*BBO1D`r4Pzz~B_B3|m}X0v2eX5K4BnK4rGOjY zA*euoE@24BIV0!=afopq>@3MdRpIVxl#R&f6qc4t*4*lz)>w}*g{0I(2`0wB=|KZ`;FOuWdBP^Bc zSPkFI>Q^>v*UOt`X%p7T9{KYl?BS!<%)B`;J2D$Z{a<^+EQ8rSRc7mzDwfOFs*R(5 z`*8Q-m!m{_U-xqK*@u_B9KWdR9c&7aSTlXfl5${C#$xXiR*s#%Fzl4|GWo`Qxr*i6 zSdV+`x-nU4Y;TrtE!Nkv`msVMt_Qn6SLKXfeD0@)XBRIXr{#+~&Fi(-=CM4hyrK8C zbbtTQ8zbjOW-ngs|E0Xq;O?W{-#*x_wcQ7M*VlvR9oAH~u~E-9u)un6DQx%ptX+ZQ zHP`GyIOXv18wQrjw>E3#V|G#;zu>a7b7HZ2>;@MsEz?Wovb8Rmy``6htXaP$YCF~D zVI|E;m9o-YG3U0Iz0Q>zO;xYgpwO)ajCsO?jxeFBFr@>55t)V>q$RI9p9J*-%Z^~y#C zJT~ptaH@KpDsYN|opSiS(7!!|4ya&v3%gfpSje=|#R~Wxv%+tEXLIui6MaarJEt9N zB+$}_j_QXyTDnl%TLE9d;$~>+p`XrwwRF~K>08%~mL3u~^uxp5T6*Ys?^=4qsfV@n zpnd7C8NK(anr)u2yc9dtD%%w-!tFHt?hE9xDEEE2`jma(B-O~T9ayHn;vM?w+?&tL zwAT7h7}Yo=aOj7Jx>e)Q@!nPAh*R%WHI7{G-(C6Wer|%RtA3i#R@*99sxMTk&BkZ< z?FQ+-R%=Q4iCyJ#wp!1cg>3z3RXlZteIaYqHg~iiy;QH%ur=Ta&R<&B0F0xR~!?1HK;r4~+(W(>qfm6V0*z z86WM-B481)2v`Ix0u}*_z(EAQ`r)B-C1hD%y!~9Mf2?n;wYiToPD88$a3cK{Mwryc z0&T9>PGEYTN7(*^T@9yi59npV@Zje!RI}~oPPI~gX0^8U3^-Aq-ungLzW9|3ARxNY z8reL1;YPE$-FWuWrOg_`RL^WxR_nD!ZM|8mZdR(9zQU!><#3(VO+Chk2m0*pC7sr9 z7k=(b*{z#8=#FdcY&CYza&SVu>@>`%>~6PI-J6xkU;Wq_Q#mi)7T5MF2TO`#&%+jz z6pN9tGy#*6EXKNm2&;dB)b}`MKYLlEk4bMYjj>9FFg?Q=V*(hfSg@!)@+211==Bs4 zDhK1+dU-9oxmwfU0@Hmz+)eH5o!(tPd%qu7L;ED|qdNM)psAzz+m{yi>L^Ynw!8$c zgV|@jq8BsuSdoUW9Lv^3$`m=gqW7qMM!oD;LLF_FK?v~7M(uj5^_Z{DcCFsj-|x8n znA5u}r**|+T2AZW{Zl2ce`(+$ZPUan`)dR%HLzwV#5xu101k-}$ln zPAtY^*wT42=R0H5r#UDOD8L>lE*ZL~hNO&cm> zpH0%ciUD-fq?|Was(gKQ%_)Bw0STw|>mfU3E_Xi}snLGZ0508*uI@DeEDqt^#X=|S z8ODMPV53)BdRRK%L)EZ*$NQ)%^!opynQ-6w|AXDV{caJk2v`Ix0u}*_fJML}U=gqgSOhEr7J;8k z1oX20Uc%jG{eLf^SF`@Vm-wGq{~z}dmNM)AdkLuM`Tzcz#XkIRUlsw2fJML}U=gqg zSOhEr76FTZMZh9p5wHmKhQRG-`oYUR7GY7Grsxi%fJUKaSpv5`mNbCr7aS9s&RBtX zNTnS76um5IK!T5Yc@^7rvc_jR%S7(YdNDo!-#_zx%>VaRKr3Ytun1TLECLn*i-1MI zB481)2v`Ix0u}*_!2N^36N9C(UZM|r{=e@7_s?_NC$R`v1S|p;0gHe|z#?D~un1TL zECLn*i-1Mo-Xoyr|Lyqy-s5VoXc4doSOhEr76FTZMZh9p5wHkY1S|p;f%^#oJO6(_ z)rNf*i-1MIB481)2v`Ix0u}*_fJML}U=gqgygLZk`TutZS9?{9fJML}U=gqgSOhEr z76FTZMZh9p5wHl{PYCGw|B2x@`({2p{o2%TO#Z>d-%d=7e`@U7=+BS*Jbtz>i-1MI zB481)2)ug;+`c*SP$>v*_m5ra9~ zS$j2G%i&ONK0^-pwT3{dUY(tNyIe0facHyBXy$6`x$-{It(ItRy;jdPZ)CYvCgozS zx&8Fj>^eg-`RdclTa870S6k)ECO!;~c+a@cxXag;i)!9?{n&Bwf0XlrXM^xr7l(q_ zOTxV)#REs&pSk^W;}4aHbNh1t>>U-{Wu@W^E2U+%qCQttg#|XZl9x-RMwU~rHM4rP zyqUxIt2a=nY=3!jJB_T7%XVefkb%3>yp>z3*KStUvU(#|sWzHfc}>H!SzE<*w#$vi ztF`)CZnd5n@$zP4a2;>i;KVyG|L4ZemY#n;>pyoV#dni=_PLdp*<4=ci`Q0`R3X18 zS0uZ(JYRk-_i}l&vStdE)ho5NoGjmJ;P=Yb&Xz8IJy%{`t?g8sxtkq9kSlA!N;S6z z*?}OA2FKrd(fQy*Q(IU zwVbF`*DD*jq9%gY3EOu+Qr*a@YWezRw$}QS zTWuX}HO<`hwoqFcN8fpIwuc`oCCTmCJ1mc}LIw3wDPMpYN*4<&OwQ*Qv;it)uk2LN zZcL@HwXI6EX!5o@ja5*3ujQJxjg3thn;YdO>cz-5ayOxl>y@?%!-37(22}Ia8(B4n zlQkagA`idwBD0~hFue2qXZy#-dbYc6gyO9%vE}8fiv_tdrxvahUzo=;E_H0hTvpv^ z-oQ_$)wN5uwr5K=&G7-x9~^q;Wmq|bhL(J(|FN+ zI1sxbH5Bb<-gyaM8ibW3SNlJ5MlZyx2Xv#GB&dVeQ z&XzpyYX8g~X`Zhw|J>pg=pQSh+g&Oen?Glo2x~U8t?lNnwQn_?a{JfZ^$eYRrn|K- z)pnW#(cr*4FU95lv!&;rdeO9>v0nWa*IP!tXk{zr=hb4VxPtanD&jX(3sfyvgl6Fp z^J}mT=&;RiTHEkjOW~?J%?urt`_Ah@&;L(Me;WM%Grv9a*31`Y!kLNbA58!L^f#wB z@Uwkc1S|p;0gHe|z#?D~un1TLECLn*i-1J{2s}Ah>hC2^KhdM0JKme1n~e1+NJo1V zBqKcv#=|`dMngRchG%*d3uiR&G?h|V0rBDxO^;PKA4A5TgW3NPkPN>%8DiAx+tL)VFE>IWjg z_ppbhg)Y@gr0&79J=%|Fd#wFzBqU*^yue|ED?FQw9Og#Ci3JbwQ$%RWdw?$7U0d?b zA5hQ#56-;Rp8x*|R&rZ(i-1MIB481)2v`Ix0u}*_fJML}U=gqgyz2<)Y5iW}DrWw_ zmk5WM|L-N-Fg5y%eNzJ;9T@*NqyKUAmxsk@bjBHeXyBuRe>MH^)W4qy$NzHT+{C$e z9RPcSdx60Gh2h!qbNzjl>RR?%z)yd*^w`MkxRBTCZ&DyN%w7u4S0RYp!cKcA*$$LlH4tBWeT|&#-Av70`tx0)=rWA8vkj{5Pj1Nw55%pEAt(Wl?3 zChb0{%+|4gRhx%B^=KQfb{k#9PTe}T&6^?~-NH|`A-ip~%R1iI24?(X{-eXQUnIw> z$WavmeL3pq9J;+x-Sy=^+|wnmY}T%qH(NTj?~?m}e&nl#54H4BbnD}Oq1}hs|KmrG z(Z~HVccG8nrythG-Df#n5rJNU2dQv;k>FSyvy`=ETzZlW^{M0u z^_)~Bp5}qTbx24Zn#Nv0gG2=pH@PhE=I9Rx-rC3ZF;C>&2w9!*f9G}J4Du~*Q>{C3 z#2u#aHF2pA&XXh(M4rwp(vfhPqdy*a>m2n3_tMBsoIv;-_@~rKJQ_J(%7{;(Ij&@P zS5cHVGxF$(j6$Y`Pw#Xk$UUfh7@$9j0$;_Qx<9GpEg7&8=?@3qI_FbgsffnJ30wyJeZrl@52PbG zV=IVkaHvXC2c1>uCu!h$%AnuvWC0SNfIH`Nc%f1Vz4`j zgh3YQOd>x{!x-g}QKxhV3Vjqh@kuh0CIkjKa=7QB7039*xSzn8@FFR>^iz@EWijva zv_t3&9YDvIDna9NMC74m5p4?sk~pa4RK?0uL2u~z?U$qf9(d~<&>8GTCvpQX4k$YFFh$FX zCHh+xz$!#4cBPvJoc6$^*1^v`XmYw*-vgjK1KsF&pqZmS20PM^B`{&Bwv-eWh@m%& zJvSDtQ}&beq9x;slVspua`XW3r&*+{gM0v+ugpc9;kPUNQW2r&v}0s6-n4i@v6a)d}Qs^fSN z@?&XP7~bDV@s_7}d-#(W&HsNN#oK?L*7N^Y&is1c^!c$XGvA;3;mmIjpPPDn@+(7s zKJ{xOzd7>8(09fj9s1T}Vf3#@K0o^1$%`{@O}#Qy8~yh9pN{w=W8=R!`sLw28UM!2 z7l(g$vTuBSD4qD@iQgJ}Wc>N57bf1E35UNv@r9X*;cWD!@rTF$V(PKUAB{da_6O5H znEa!Wznch#KQ;aPW4}86&B@=;xdzYt)tNt>7(estW7{L&yPIf(J;x$o5qSS0V7O6Y zk|spx;eihRB(S9k!x-Dup$JY=MCteyEeKC82}8Ipt{b8^^3bz0*P~9V0*t0)3b!~( zjbP-4enbgkk1|Ck0G~M_k>?Ob7%rb8`X-bXLi;B;z4r3?i9Igd$V)KM4ji34)v24Nj<^ zx?V^Et_1_adFW9e!6%FaP3J-3m%&Sp{178WiEsilf{GCP)aRBxq6G4>A0}SB>s*uI0!+eoCk%#M|&OB&VVgy}@Q8q@H zF~x(1(#L4l*LBVOG(i-^jS!I4f>A002Bm{<6#<@*U?>`R(!nr2lyQn^6OD)#ggcam zuIcbmRzwUSg^Hk8p1{Zom51S#W^86$;1(oB5Q7FO9QB4=%Mv+7wrKtG1#Eki-6Bl zTF?z(J7NejjL^^u86tm)!jL~oLx_r)`23Sv5X{sJ3ZT^tvC~kueGKhzYt*KPkQ3(?&2sd*%idU>$}^Ps0$2*_4QSzE2|r z4IkHnu#ZW|!qD+ycmcRXyGD@^h6p_1ND=LfKA{B(OA|C~9VLn|$b>>`8ydp^rLI<} zgeUIDwIG_SgceI!vqaAlr2%GcC=4lKl%@iYDa8dN=o8pB7*e!$ZOs^FcN9=pTJxbN zFiX+Lv>+aqLhpnV>39)LqBekNy$If6Xp3GV31vt5)JUMPk7_|wi;JQ{0ihab_6R~J0uRcW z3(XAzjGE z`+8a~Nzu)?AJT$h$`PhTSz`nx&wG4Vu)Qp~FQH(p%m5C=)6{6;TN()Lv&XWY_VIoYc z5KJm`0iCAjlNcq2VGhn~K>xY^bQA7bEf}F6i8(B%hk-b5&Ctc8k44xwh`B)Ii*WA)S`bq$ z0_GF#6V(O4W8J%AN)RrI!c0_vs1~2mg1yYFP8-1`|H&R=^CtgaFJWqv|F4&bt;zqF^bou>`Tu%})Zt1d=v)$)%oSC<$89bw$sRR^W|!JBh!3lReS(v*#?H~ptlU>??SsjcZQcU!~*Fsl^SxK(v2`~``@0()yqg?o& z=p-?1r3GZGB);TXYh#mWb`9}z|IF`mbQ zpI(r7ptPZXybA;620RSl{!_&E;UU4@^Jcc#H|5$icz__)8M@fn((F5HE%AhtV%)8hgRuL2MDDUD`l> zgjE<8Nu>nte8@2S1E&=gFU(Lq3}S*c23U2GU_O)x9Y8@G00UMmAM*SF0~G?5;o`y4 zhLJ99!0;L)Kuq*dt>dBV>wzScUm|itC1$~tBE4YnAZo*Kmo{h{;8RS}VLXSimWPoE z;wu;<5l#i-Ls$)o7#PPO5K*ES`LbX`5t`sws^fToOdzNtqtJh_v|*@A8$1LL5Wd3n zB%*{E4x^+P!C@?i*k=&x;H@5Fo{qr-s0|}$x{zSZj}aL_{{R;WVI}k#905xQzalQ1 z_=p0lUY1fmfJpQYcC`z*4W7g(5}_u&98Cup6D&}};x0t;rRy^{c6&kM!D<%+UD^=G zSm8k_;z?MQ-S!(?1P~F)Ld-ac;9T}J9sVF{Lw}bx1O!G>Fa&HcJpgS$e~17*qPT=3 z&>CTRV${=etp`aP^!)$y)Z2YC;mlhz-=F^U%!FP8F#Ut+-=F^G)Y~&(oIXFj@g5C+ z?QJar76FTZMZh9p5wHkY1S|p;0gHe|pc?@_3E#`apqc;gW!}up|MxPvWaj^SnR_ww z|Gi95nEC%+qWEV1zn37lng8!45^U!GdkK`9`Tt&`iDv%4mtdQj|L-LhW#<2T3D}tV z|6bx5mjA!IG1+ex0gHe|z#?D~un1TLECLn*i-1MIB481CKO$iH|KE?bYRhC1un1TL zECLn*i-1MIB481)2v`Ix0v3U81T_EunVBErz5m-C{{PXBjhq|$+cW?7;GYfrpZ(wM z`&RcEKb~)|d(#h>O1Eck&z$cc8yh{}R|@+2o|^PElS2qv81Uuj4Lh1qSEmsWAvUdn z(8G(cskw(uuIh73i-qFKQeiR8 FqRhZ8&FXtB*R<0D-(hDp31u<8WY6V=zN)+=i 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 z

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

Lqy@odJQ>ecs$cva;7An&Fp4`Ij_oz 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 z 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

$^mMXT;v-?bcV0@2YjjKAXP zP}HiV3X=46!+{ClWjDp8A6D^R0ovik27ZsGPuBqK%)tMYCY~R((0bIUCU7m zc7Y_1(-9@JU^|kO->h=1XyRy?r6)PzHQ-SKDNP@pq*!nS;Ny__?T#U4xm`?O$H4-z z!w0df(a*<^Z?**U8pA41w?{MnM|2C9T5mW5@NCi;sQj|=I-=5m?&F$(_@YGEidyZe zK$a0PgS)uN&2SDii|nBc6m?iucP)qR+fdY=iIfwYJW$3VnWb4Z&N#|88fa0=p(F`s zhjF0|Z5S#9a4e!|FHd+#?;#CDhc%+-5)f=hbP>j3x8cy7b=FR2ZYP0k{XKI1demLN-va4 zoGyYE(3g&DK-nCrgNi>b5p`nMG?vV4Ux&pOjeoo?#7TyV5eoEnkA5!w^AN7rT-@eF zdvKSB{=YW#SeQoBKGS^9C8tzh*m5lHS`PPHi?K!uj~WoY_y8DtnOY5M?;#LH0CfM5VaBlqXIU(C1c8@CQu@R9pPp0#I<-Rj7oLS z!mj18uTh8aE+e^%-y95KRaUk@TSyDy1q*Y-`dUFYba#gQ5K$^!1oH#ySL;CnZ+#>H zx)mhncqe@r=IE%QSGgJ?Nf~wpTm*GQfAYe1$Nc1Sl>6#~J-7mf+x|rhfFIKEjtzkdNtUL7 z3c;$l-7&XoIf9Oja|$IBA0xbQP%$slAz4UO0D=ZGtQfC!M%TX%Q$)u{V?zglnWTvg z%#B#50{X^CU)NSq;K6(S^}9;qQ+$`(Z_kHQatn^4bCdZ^Q!LE-OGnp9XfjG)~$^z zkKKCs#L2@qo!ICfJHA!W++OtJd~O$2#&hFVaKRvEO(I-bH$;Pt2D720K|f1JoTBE| zz4qFD*Sv7pz5Vv%$L`$PxaRh)bas)B2+1)to5g< zjeyUI!4)+pQyT2sI&^cfd+&z*`Ft+EINy6UA$d=BDCCRX*Ns|K#Uw2P3Omvxv8u60 z%6w9c`xRHeaCr5BYxZ4#<8W}rf#J=EPi|as^v{ff>uVN6`icL~h#ZT_uBUbd}4qTT{Zu?p$yg}O9h1C=>@S^yfT}VVBBPHlc z1vHixMZ*1+^yRL)_gcQ#ohG^~kY-ClWD;)M@B^nN}+jBP5uplBx$trD&>M zm@ym-uN)p24z`YL6>Z>OdQCp3OD@R|nf5L;E@XgY6F9t~{@H6v*AM$WO)QBZ=mt^L zr@DN2^>80kc;mide6!29j>df)_g8$kqK?_sXC5N@dqLI8^v+gp$gkl+?zVm$}rQC*hl=Y{)NZ#PXu&=hNEu>f5Rfc>a3R z32L|Va*|V#0#NectHy>9-`=6`1H=jUEA`*#bEpZlxn%Jkc2{$l2* zX1{Iuk5*p0e8KW-mVYE`P5pHC_fvm6`;^)1r*D~g$@C|t|9<98Gn?m_bhvEh;3Kn? zrQNn)n==P5Od20H;mYjqOS4)2h?JOY{PnIa&m6oc=@Hxe`1#3m7(d{O zEBVJsddlgKQ%;`Bc&A;qwp#jMNv{+WxHSLtrzemg4fbQ~IQQLs7~f0aF7T zslZ7XiTGfNWnqCT^uN|*eNevQ^oDw3zSom}XvblE!uak=n0)N@uR4dPgaLbe@>Aji zZX_?*c)vbw?4=~kVSE2QHu(YL9rBpuFLK5W`sm!urF@YIGj~xw9bv3CDpi0(xQvJy zWor5XDGTAsLAi>uTi%||qn2Oef(NNqVG)N#IYqq~{+}3y`Unbl-G#l_N?!=^NL>@y9Rsux!BE%$!l@OQCmo!QtZn4{P?FV!1nQ<%fDyJ4~+NAZ1VW= z_n%1~KRzbY$>YcSaq3cW?_$dPk1bK~|DQU4thD~o_3vK4Vf{&Kf3o(WwQpWKu=d2& zzg+!=)wiwQvf5sqUiqb!?_GJ>%9G}gEnKni(D@&n|DEOkvHWApcP~GG`GTe2UHZYL zV@s{Ye_Z_4#dj?pU3~W9gBE^o;omIWeU6d)`5!yK0_RuY{0f|3f%7Zy|F;#$J)V@} z^%>sZ4Y_-j@gu|SkKpi_<=_$_bJaF>ckDbeA?(L?`cH7%+ZnaWYXWMFR z=KCtCNU$RdRy~KGi~V>>zC)6pclwBy^Zl6eLlgV4p8T{k_T$sWQ`=JtCU)jh;Qt?7 zy0*0b(6vifpStpt<<`=5i$@pUI{%@$-5ZwUWzR7r>Cfwr+_HM`;ANLx zb;)9mA{(|b_K056F7+Sic^Ix(&j5c7{BYf`p^;LEsqwNm?muu%{{`C^u^bP)|Kzco zzIGcWcJ0>j+YX;N0mQv=^4P|m0T(`z|MiJGZoBQ!@w+CLxgb#4tF}z{@vWC#l;zXy zV^)n&GB@>l4opbkHUq(lsi!x!B1A>Utps||wFmcIKD_bDYcAh^?H$L#@1F;Jf9&|- zn+_exxdl7tdef1uL&rCcz2d~N920ot*sUAKbKv)h?E^Da&}3hIb4>QKTQ44e+mxp1 zDXq9qf7>%Y>Xln}ZXG$XbE5f2g_b_DdGhdWTN{T@;cz!@I(Em=lP7lGJ_XHr>n3x4 z++{ajG@f$`5mI-hzFyH<*y?o`QZw*#18}ZBo*C{c;8V4CV7ULljr*@UaP5Ju6DK#~ ze{5V1@%WlUckP_=$>WD_y>;vO#@20z4jXqJ3JxzS{ zv`je6`TmZxyx%#!>6IJZI|4<1;J7ckbMCLcBfjmS{I8wj6Y+z^eBRf;>T68x35^>c zIsUw0%605ZnAdvr0>sh84WPr&>7w=G59>3khcf}g$+(XzHQyzBCZR_i7S+IKCutOV% zb56t=DV%~V%}p;c#V56{+O{+)G8-v}Z)_oM%#YvLxZH7kYvc6%Cbnp9YYyFX)7FU- z8?Mk-o-EkJ9KFFTpWN8L?dH;c(tI8-wA&-=YD$K;IkdE>*colwPTzU8H<=%E`#4B?r@k`|Be$(|P zy!o81ZU2BeGu@20wZ(YZPHYs|17hjwAe&WM7G@*fVr&@3NcxoO*k@%Fmpboldnr#QHf6nZ5C zQ8bAqK}eGF5c8e=2FjhK{07QtgmD9HTzmZ3Er)lx4`b`)!KQQx`Q_q;XfU5sDXa9BSmYHA9etP}?nZ9TKCF?)3{;jKzTY6UZ z_L=ug->~x6+0psxbYFI@#4>}Ts-yRsqb9-?BY9DN^@I_kDb0@`K5CYS*}f2)_!8) zua=hm9OP^W0b?w<}7cN{kJDU3R)Q4C9+tQCOJZ1hL zm)^Ykk=5_RD|p`2cg}xm_A#d5Pl!0CczU@~e2T}EE5*Zwoz?qzSv)%BD$gb5Q!d1p z#BWTwQ*MZXDYp3fd|-mfdT|U)IoB`B2PPbi7tTJWba6^=kz7c>9enauIC<#)fk@`8 zBgqqP7!5*DM@prCG#;&05IG&nr$iQoFlACty{4W*!&X_3Rkvxm*SRepsOx=I?-fnf z!-0g3#@KO?N%VxolNLn1?vWT+$4RQy^zzlWOPo;WM)&Fs%EKLEs-tGJN+|Ga^MRBU zTeT1jBfJ;yh*`}Ujke0n{-8^sq4}y9SZiwFNy(P%4sDl^RG}CIZCxe`gR6!#U#lOE zf%5`U$ZX0Jnxe_|ncb6xBaKsQN4A1*(iKP0Inj}b@&Y4Qc|rU}J+}M9aK!t1thx0< zC55)C&oj_nw@DysSk0wWQyR@*k^ja7DU`eyLP|&7%i}jvNEEcwG#t<^w%|QldVH@< zy{UD*Z}qTo8>1MQ(j8?u|EcVGqc)+WzFDJQN61*UJqBW{UavXm^g0!qiuFPKMyi$N znucatglQB*vL>MP784D06lxvZfqFj%QvcDIWx#8q>ub@PEz5uG-5r;%GI;LUD`(WOxsWu{!j2E7oc*T!v!#9U+0u5>%kiGgI{NDsmYV)@#SU4%Wn0Pvx;v*$6FK(F%!= z5!FG`7_<&YwP(gao!p15kq%+K%FsY$V`xROT({Q=jUB}C`sf+?K)R~+p#>l5_~a~WMw0elTHtr~>zJqSfY7O|P6%_5puoD9tT9HwCApGN< z#)m7O7}?u6pdMj~Vqjn>^*Gd@7|+j9@S;RLXlgPSp>L{-*`O5zsnArLp=2`bmmSxT z?QB>5NT@s2L;j=R8a87fSTS)BM#6E7$>e<%iTUX?F8g4gHE@jcfwofHSKZ`sgr(33 zj^3OuKYd?}Ol-A3?A4dvoP8v^sMsU5&3-O`Z8Y^Z9$um3L&#y5-g;f-pH(ae)0?iGh7$Nm6D^ z${^_QJu!JNmX!<{%|VtNow}S4td^@Jj#!*#H#RP(O6R{2vg27HPYzo3{$=^V3a)YA zXKr?)*`&@!QdI!lp%~5bx7v+<bI zeQ`*sftj_V2-fXzQJ1CrtVbV7qlZow9!uGih9K8pBBdbL#_UJ?US zn(pNS^lqnNuY#@9X~#N-D5Ha?nNy$YPP0XGsNcFc|BXaZD-AJ4C70={4wxOHc4kct ze%KtaYTc*AK+dT{PY{C%_TlVk$3;{^NR^a?)JV12e{w$1eNFFk7}K4)+Lf=0-#E~Gk=}MEL}0yRzM=@X@uA99he6A$*5hMf-|3gu#NO9>+s4hU zIV;p8kmdn2JTKfC8g?CrUz=kArqvg^F1z;w{th<$HYbi&~;Cs#T;KCS4l*mH5 zi@J0Ood4TXFrbIMKo|)u5Z6Cw!fszS`5U0(PWZojyArw5D5j|EpM;dkBgCh zeyy__1N(9rbi(aI0tD94)zn6lakyzobD~_n?Uj6>5(t4L!XWNF13enc$~!h)5L66& zpw4nWka?*qg2pntXEYeL9}x~clm-#~X}zeIVxZg2zp_(1p(d)a0g8Pb+-A)O6pqyh zXfDP;2BxpPR%65CC{&|CpSHuyWwjA9uQpF_AqKV`N6DDBKC^^%^qG~Y%e8*#b#z%# z@}Q0R7$~f#09WE0zjVE}zO?q4wI5%5^ZX5Kx2!#D?VE&uTHH!r_r?mOph zp8vpdb$M~=_vW6l^kYl^d}(XxnM)5@{QTn2EWTszg2iL=_biSUAG7e63%|VZo`t)T zfCc9VonL|TD{y`V&ac3icm?8$8M;*}atAJ!ko(S+%_;&vV$LF3n%}in?kS4O9+op39hRv^X6q; zf{q%GxT+;g_(sw3Vi00IeG#k3DfHQn$8Qw<5EP8b)LfMzV;RATELRs@vc~5V7Uo^f z#I}7NS-d0HO*Vv3Z!5Y`iQJGswX)HPYyiz-wcNUN(PlvuPW|v3G$;c zx@btsz1rL#Uq;KSZE`8%d2T(x>itUdG? zkOsFz18cbqlJHVpkQ>sYJth57t=t@pGqcG_xXw^TFHh1(KDrsLK{Z=}KZ}Mqd;tM= z-jx)3PzM=G4-z=M6q)i+o{?Z{R)<|^w~ ziO#8PtKQe--#z-uy#HTme`)Qrs~=nWH_P9;^wPy=FFa{}dG^m|J~92nQ*X(R`^&xk zx%<(h2Zs{>V>Yi_Sb%eTqb`kJFh?%D_-)z9JC8v`uhQSXoV`xptyibhkl&2nR- zQhR!>_Vh;Q(zY%Rl~%}AS1v7=pPv7}yDu8%%k#L)UU2X7D5yw7DU!4LDOz85$DtzuU%le!iQBhsLN7S=FK#+^^yKklM{;!0 z;qhPI4{7`_zB=mYJ6GpXgYo96Ts~f%?jwQHz39;Ktydm{0p0EmaP0X0lh9U&j^DiTntT81*zNbTZNL{_8{MoX z7UyyOd~u#|*;QvO4s1eA=CM`}0PqMFfGLgqVzu6vZWZEa)mzNsyy&X^H;&OqXROb- zLB#R#f1TJkbmYiC-3kF;O{~yHZ@hgje#yNn)bIEDy`f^|NJ#7AX^J`r6GpeI@difXB{42B0mNvP3E7xjVRu3Ca=i8{M04JA4#s zmDu;Ok!?+}u&rzb!H*iiS!`fx9omQ_xRWpQ=D~oZarEtjzv{ ztnv%5*dL1n^u7NMcK6Q1C)nie|Al6u(a9Dv!k5w@hXnV;!d&$1@xp9gaeB+B!zt-e ziO-?n16Engs?0cS=|hK4qb^v~!bHoV8;4gP*mr#ZWjBWI?YlPmN4AcC?RHa}I7lORc_(UnHHHUvD!P=w*r*OC zY?~BMXK=;-Yr6;f&l~O=96oXTp_8ar{bTy!96lQ6=G28XMiQSwPmfLX&D-O*zBC6+ zX?Sv_p1EtKMy=kkrN5MpI?*pU&^{JORo=go1Za_MOOh8)5l(nOu2icbIlF65J?t5~ zOp*3ol_n8+n3I4dNWUso*LAaFg<2JzPurDIQ9|qw1XE;TbX%Of5U%xD80nmevvpA?UzjuFEpo|t=kOVT3(l5u zhf@af))`eah8;@%fD|T3_otk#2Tr^d2Wm1Jjre~x>y*}hcl9S%-oAVi`~T8~<+;zz z{yen*H%-0NU!DK+g;wD1jV_YN!GjOSggIEdWTnx9sU3l=V~g?>HL@0m$@?@EkB}C1 zmIR+nxY$(G&Kl39Tz=(Cr5w(c5Oa?H!Q{!?c<#<6()Xj6x3UKBa_Xv3?2=x(<~Dgo zkTFntDNB@+OiFPp9}r9QoE=L99~4YLeNacyzdB%kV%6H_uv>><*X>%{)|4emNtt}b z?4oDySfUC{v8pUASHBflt}3o?0Ki*%Z)q{pD(iWavP3Cqfv=b)de)95!hFFum5xMn zQriG!mRRh{y!SNf?&$E_Y9*x;Q_=z{2d#Q(`O9yMp1EU*=od695aA;EfrA#neDn}b z5)flctPe6N<#G1rJ*@p%U96NYG!KX+dd7|=8VF*ss5Hq2>jlG9`PbH3JR%Dc88f*1 zBqyoeY_+xh6S+Y1bI(;j2;s;zaYNyVVe%E*5*m#?Ak6jCcP?DATNsuF1%`{NK~|pBsIy~<`a}5X z=w02e>58ak1||bV6oBtRxC45sCdD8Ct5EBCzhjh_I-(uey8|nwP-KBM*I=wa=%E^=*rJ3u_$y|KT054=Qb@GYPznt#UmwW4 z8*BiW=0;CLQ6=PZ$`aw(w5+G!(~F2$$9DNk=eJTDlsVoY{WvKJ-J~862d%kdiG&@| zGafrg#YbihEF5MI*31FKwrhaIC-r*y zwQyaMmMA4P_Z6~4OAF=FV@o%bmZoQqtbaQdz;~@ZW%Yy0zrFgbm0z6s>-nFYA7rDM zf421Bm+qST)wvgE|JU^QEN@M{e`aZ_>W4l#``PKou3WeH*2PCJyw5M%@#l`YgW17@ zQz?$tObV(o_^)Vq%%YQOeT9@eTOAa6EF9(a~pnxsp#GUmn9(BuY|^i2ammqM#YOtJ=)oxR2>s>j@ru+|5nZ|tmT-^e; zOqXfkF|hM0Ik$EI%SY`NxdvHmbXmVBc)Zc{S)Ke1z-q`_Kt{K79 z2U{~2-_SXW;$e5hFp>=HK#(C)qmDN*{u|EgaDeX@LRT^fh?>Zxt>%y% z$pC48l)YvzPfgJhf=aJ1TwPLbtJnt-a&{Siw`zN5XRy7f#77r%_!EaA-|$0?7~<>Zn~V>+p#IZRl@!_rS}&o$|( z@>})^lEtu_pGAisKN|9oNMaQ?7(25`$6z=_P|?Udq}Xt)0~%?LV7j^c#D5 zY6=a$(y*U`nyinVIwkE%mjie}oOD7`lpCs}(tG#t)Tv;=XgeS36EP3mVbl^ajqOvT zA%nUPS4eJo&m4Z&8>u$i2pQmbv%26}{tEz1$L{8qry4&0)=QO-{l)3%-7O;t8kepp|3bLn@s zpPJ+zrU>d{5ZQSQ@1PZ;3NGWZwrQtD5TjAAc87yn_QAb8HASdtfI>8s5Hr^b7qUIX zKyLDz%5tpO)Im&dX8&q0PfgM7so1L&8CEslCR=8({_Nv0Hf$+@882yrvMG& zt`ZIBDmQ)sm@-JbMJr29kpObXlvQZzlpgwoLTn_|`EF=#c3eqV=GM5?$o}^|Javu} zE&bEAX=@=o0O;!ZfSpu^`owS)_2IU$$O-2-FBR)b4MNP;R%X*UPrk>&#!JiIw}+=r zH%y7#2Dd-N7!mZTF@zK@G*?+0fbe@mRCI(h1*`8WsNY{#@6#9eZ2;<)Y#9`TqC5evk-0RuvPE9qzcbOXwM`Vg(Goi%$ zZfr3v5UM%@5q8t+)d4X(!yd_nP;4DqV3jS`TjYpYj8)r>Q}3GnFHR90^QxKW&OChjPqM$xK0f`C)kE2PR$J5WTwR?$ zHa(oaDEqppKhLh7`sl(93+09RmEWKL-T4pBf7|?x^R@L)Wlt>q-TDXC?_R%t{j&Ah zwclR*(X}_My=<*k`sCVTXRssbP`Y^f;8ZS0Mi340q2VeB@v2VW%BwYpZgoY|fo}Gv zF_OTRrHD(8gvFQ`LPiH(hWt>OC=wB&O7QU#w>4iOa*r;(7M9UL15eZ(^y5g8g(vhT}BauGOtl*mGiq~794 zP_78!7vZurT)kIsXYU-3tkuF4MaqZgA8ulyixX!7};-kWo-m$aHxh? z8J1o&S)~V~V5OwTf|foJWE=Sf;Sxul`Zt?LVM`jA$sKYzmA&|amGrJ2vxf=~u@in+-}UE~YS^bdVzR+Tdo9vmvrhvbxvKzGggijg$CdL&84+96F^Yuwk^<7x)&TImh>$eLnG zM7*eX;YKqJUECtUwyI08+2Fdf2j?TJ)T7uVEZz{v>IytVBxDoHTlSj(j$~Svi_i2a z<0Y>O-Qt+(@EStbN*3o>YKOv!oIcnIWB=%VX2YO6X|OWztJ zdn5ZoC+=`i!xxPrPMZa*3NR?FM=7mW`jzoW2cY7p@t}-s@o_2bl(F@^cHb6NYs+pN zk2L4LN-ohDi$s?=bW?&gE_u}C9RN_2KAw+65BG!YFbO1QBV>1k0eo3S-zN1UC8p9Z zk4M72^FS<3Wc@m7GxWC1Xy6-HsX>wREB#VF(pKuy?epuRpnyT{9)(5_NN|=a{Hl6C zTi+g8vGxjKLC*l~aN`CUis=y9cNF@%qwMARNRom5&~_nR=;;x#N!Zo5`L0T3_Wr_D54x@XXv|}TC zX+Dy29woGqGaO&Dirn69G80t(+{$F-YaFh(=Oan>3>r#ubZ}y1s1qUIIz>Tyuh zi}KL;SK7PP5UZqe=&V9#%lfxj9WrI5kH$zYI^`}VvmDZLA+!m0B$L4SBc;(s ztTn6I_vRz}(nsYG%CnxXRk0aYlY7u2b;V$b6Z_fs#K^uNlfr?ilgA48g^{PbDE}i3 z(g5=!@mTt{i>LFkF6pQ``G^r%Me0PB#GN$YId z4a8fr?^uQ($VYa`-^cfp9*W-?yaXCa)}J`2YEK{8vh>OE$TB^v@NovxB}|cZ;?+y( zs5*A|n>^|)XX8^WssI>8F$gg89NXC-cbWk_E~D6Y$zp7}HOU_`a}sNfRxZ)Vd+GR%LiJ9d=W4#D zybgf{Dp9n>Z4lx{@oW~z?k9I95^w`C#_sNLFu7KJ$uTCmQu>zh$Y6N5`r;fgp0X$s z`Sc;2-5$C2CUMGabvzPyFCyPYo?{2GvZ-8hB(O-J*(>hkz-bXhG>BP^2ht$R38Imqt;^j> zSMb5fGe+o6$Wn?ne|Am&eHfL9F8cyD;djG=Gba+Nyk;Wv&C)OAzfzFRnWuyq5gp44 zMAnvcrs~TThzO?H$dw=65R#grE;b=t6$J;5It5c-TtqN%yY{N%XNQ~4!Z(PMns@j# z2&CY%Bhgab&ORg$zq3! z0EF<7>XMq5?;;LZ$$urIQL#O=?+A?%w^YQYbnIxaQH*nI)?205k4K7Jod3;QSP&c2 z!=ZRW^{X*NjiYkd%~rNYs-noLDI1$muw;1(A}whSNqm9>H@4X%E zIzqWvI7HTwgdEA%%fFf~ z?Gj~S69l}}Co8^Qwv>X1qATj5OZY{dfDgmk*p6YyPGitU{sbJ@c}>0hOv)ZJow%y`>*4XEh$@n1*u{O zOWH9T^lC^fw_85=fG?VTE=CgVBv$A!y2isr@qOHgE+khl1aW^@E0?oB%14H1CSg{F zB4jecf$Y>FBHWUVvP2m8Q8W9)7)ddZTjfHpA>I3uuFEb6WhC`GAsgLvIcI+mBSS|O zX-f=1KH1^rDRjikn>9}*syo6vsb>$%M;a%U9V2aAH&qA&hTqRxv22l*Y-T?=9@$Va zuz<9#y{EotiWJQDVv7J03~m za~Q6K?G$N{aHY8W%}&=*5=Z&3?81Dc+F{_iWl-b03oYzTx0Vc?6enXLi=O2YlLHP4 z-Et%?sgeF2Hk z1fbzeNy;A9oTY4h7zf8S|bbGscK_;GLv)6r@W_$&Q46!k@g9?D&*?> z+zU`Zb>V1$V?WXhZMmis%9Jxjm7fcn=*+}KSq4^}gPfz)B4I#cKa3DJz(?(oNJ|0z zB_UK8O~S#UV=Gnei@MsRxWFlxXt+_nWJSZe@t=uZyZ0|Ty>p^Wy9e-J8q8qC^IRVH znH;xmi+N68UHo`ET3ICS+Tq&t$u)=17B`-7C7z<~63u0ibx{+YnwY4>s?Y|6 zo`kd879+e(UN$oG%B85Rl%tcjZc!M9!X}#SoTzA_E1knW@{H&?X~1E6q6vnu#`Wnaa*H2> z01MIB7%8-!o!?Sab+Dj$Zr%M26Cad}pLkTTDt~V?NpZHN2m-Xx6D9NPLyY|FA9NeJvR& zB`0y&&=w)(i>CC(QZG@`;0{3&*gn-rk2D408q7#HOTt$f-lD$fT{|br!4q*rnGU5j zd=+_?KrP5*N)waT8B|)VKVs*CL#~~*?whpWMFH-MS#a_H!zU)1P;g6{Xi*sd!Y2B#iHS-yo~;U) zWc^dxs>0gHal2FT79#J+5#*amLqu9|bJSI_v*{q;oexp3!1Q!0)ru|-l4HK?lJ zMisWHLm~?tZ3#&#@mn=VL9VPyD4qIqzbE?8iHRl@OjEw-v_i59BFv~7uKQBaz;|NxGMPP%zZhaYTAtE&Tjmj|y0@bcAf8azmn<77 zcSIk6ahXtw0?nq5U z9VEr-&ZKmt$H7ntVQZtw8!z7-gBGGHBijlQ1a#H|{2Bkb-&=QeVxnb8f=J4_EZyM{ zA~L?NHoZ27xnzR8S<*yP+G_xUj=Isaj$Q%F2ezj8nIpLx(Q8+hTdA|CiLUINC}0*O zrs~uPvCAC}2BB@bDJ#=y^9Qr$Hcz--3L_yFHqqsYi8ck@%8=2c(ey%$j^wqDqMC=$ z#uPIv>n0TSW>UUrq_UMjr2#8}C6kkb3}o^9u(KM>_50CRwwNz^^F2!@dPa^8vP$tT zC{01$Rj~|qYXJi8|#z}i{VAvIL=2L;V@ z>z>7l4~n*c!YHfe; z2IatJNIR5)--`L5TlXyNoG3t@X1H*(;Ab-1qrh9VRES*yZA?N0J6%K*j%Z zj{o1Vv;O}B3qL;piMcJ06G9&$Phrtz>cMDtR{R`5SiRxJX$73$p;7xo(S@3P~mG5Bsx2D ztgwkbd19jc1{5QH3X8#B__m=f&XdsG0OLXtC8_*h7+I~biGKCOMBOjt2J}7cM`nuZ zn%xA~!=;dUhUf*XO8KHmrNtC}TwxP^(!@k*ceR>uBvB*bafk6&ezM~f6~h)nLZ@7E z(?EmlxA5PcDL%qG75Q@uASpGQQ|Q5v8#R^RnjqAmvC7?nppIkH8dDkYE-7F2skX- zrvsL_0*kxOpD;1e1iV~|qE}Lu8v;pcO05=UIDk)4X9atcQm1ZR!C?V_KJ`iOcWCid zJ13fgxJ&t>Ghk*ovQaEsy3hsYFXGM$$C^-_iJKQ?%Am z2C8BG6t{IBH!;x!+F+8)os#Fk(B=YxK?qTOQPYk*2bc`BIPwoDstCNOFZ$Sti6*cf zQ|{5i7(H@gq6viMq zg>x@1t-opg!nJp=U9$S&)$Yp2S6;aMd&{p{`pcznTv|A#?l(V}d-42F&i(3+xc&S; zoL_K!3vhRG=b6g71M0?6z5prc{-ANTOo8LmaY zO{NR^J)RnTRpcBP7TG3@HLNcQSi}zX6krZ_)GLC(hDvqxI=*4HXyUQOl*z?cu3YY!pJXXamD6 zGFl-cRhR9fx7ey-n>522XEv~0#38x*Yb7#cB!8V8oH1A%Y~fb6bn2-MMC#By3Oyu; z8yA7?gNUyLf=y0|8qaJyc=912OXkMvsXVd(%2V_*h=M>MD|71WMkAEK!w~D2ZOZfR zD3$mq5`M~La820_dwA*$71xL=%#dJ$?@9+&vr}11+;$GY^ zE5lY%gY^#tO*+l&vb{XjcMD4{Q)t)7>nrb?T@(QDGKi(%FrwzdN9Zr9&=6fCLx`6q zkN@a`1o}j@E(h833FF}pXSAGgV%!a`1jdebWo#78_#qAwZPkXH>`aq>Q(rkOjG^yJ zbeHvKa#hVjri!Y?KvY>#r?Ii!T`XeYzGDNSh(X&BDnVLk+_sm8LY7dcu)wB9_yK`g z{ECl7(!#l+Z$vPO7!DDED5!Ts(2a-)7L)q|p_#TDbr;BmTletLDT(yp(F9Y;s*>4= zEIYaqejAgn4TtQoHjC;h#3HwOc!6jU06h5O&Mvv8tQAO|` z;5`IK5%Ira4-ZZHrjVMgfFpzz@nH%6Sy?h&Lri&E1mu4z-8;9pFD7)~|0y~sXX?Lekr%*g?MYo49&KkRk!m72w!O7oRCJJvI zN_Nuu1KZuM2%YCZy<)cD-q#GT6bCHnOiYv~%o(#IhoCGJTpfd^vN(Mb7b?;uSaXsa zU6hkoSgNo+IZ+5cdl7mkY9@-r5FK1!-lg7&dMvnqQaYE-=9p`VFh?!~wIVwPbJU>~ zGi2V|U^xxCXKZ-fDpZGT{?I6nw$#qKhB4%QLK^1OpZi^jYE4Ykp^t)}HY0%~?BzNK zjDyB?DxEqX$*WyWn4=Sb6a$2&66(b`P39*hHwDefA04etxrr;NCP$rd7+XSH4{u!%?h}v z4peI(DByM_Omq%ef?yv{yc%LzE>Z?JG(%gCgOoj~Lehnl6S>daFMe|PJE$E)x@!b- zM})Ivz{|yz#cPujC60!<;SLTP5_(5SzTQ+wY^I3hb#Ucm9VX9xO9 z@Zy=sJWS{k@WL&w2VPOxK-3uyO$}a_u5Bm27C+Utq;}Q=dFT5e&6BT3ey6L) zOwlQeRYkv|)C1r_5K-Wup-XYbs4|}D!s1vjXjG*Ns)9*eFzElBn~*k>N+M^`l;9vD zMGi9oiJCG|JD5maXe9>VE~m*bx)8Y3qj;`zQkYRioze2d7bTkRJDOuiNQffX2V!)a zB*fVY00c1E6n5E63SY2at)RDR96-B~i9K z!n$HzVZELc5EeitWU^c~30rrXC`2e4yMfW^)eQr;(RL¯o4=#$+Gr9N7}ZZKkf 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 diff --git a/src/apps/ums.api/Ums.Presentation/umsdev.db.backup b/src/apps/ums.api/Ums.Presentation/umsdev.db.backup deleted file mode 100644 index 69dd1c633d587bc0c061bf8601fb337bc7315484..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1372160 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 $EMN`3vX1 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 diff --git a/src/apps/ums.api/Ums.Presentation/umsdev.db.pre-fs12-cleanup-20260604 b/src/apps/ums.api/Ums.Presentation/umsdev.db.pre-fs12-cleanup-20260604 deleted file mode 100644 index d1ce6aaf308605fad7a46c93b835bd7b80d08039..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1486848 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 g@Vv4V8A{grIFz8djNU6f(i7cPf&xisMtD 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%0A;b=!J0ZrqidqG}dUkNxo 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 +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.ReadModels; + +#nullable disable + +namespace Ums.ReadModels.Migrations +{ + [DbContext(typeof(ReadModelDbContext))] + [Migration("20260607025649_InitReadModels")] + partial class InitReadModels + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Authorization") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.ReadModels.Models.PermissionTemplateItemReadModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId"); + + b.ToTable("PermissionTemplateItemReadModels", "Authorization"); + }); + + modelBuilder.Entity("Ums.ReadModels.Models.PermissionTemplateReadModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Version") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PermissionTemplateReadModels", "Authorization"); + }); + + modelBuilder.Entity("Ums.ReadModels.Models.PermissionTemplateItemReadModel", b => + { + b.HasOne("Ums.ReadModels.Models.PermissionTemplateReadModel", null) + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.ReadModels.Models.PermissionTemplateReadModel", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs b/src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs new file mode 100644 index 00000000..fe8d6855 --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.ReadModels.Migrations +{ + /// + public partial class InitReadModels : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "Authorization"); + + migrationBuilder.CreateTable( + name: "PermissionTemplateReadModels", + schema: "Authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + SystemSuiteId = table.Column(type: "uuid", nullable: false), + Version = table.Column(type: "text", nullable: false), + CreatedBy = table.Column(type: "text", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "text", nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AuditTimeSpan = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PermissionTemplateReadModels", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PermissionTemplateItemReadModels", + schema: "Authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TemplateId = table.Column(type: "uuid", nullable: false), + TargetTypeId = table.Column(type: "integer", nullable: false), + TargetId = table.Column(type: "uuid", nullable: false), + ActionId = table.Column(type: "uuid", nullable: false), + IsAllowed = table.Column(type: "boolean", nullable: false), + IsDenied = table.Column(type: "boolean", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PermissionTemplateItemReadModels", x => x.Id); + table.ForeignKey( + name: "FK_PermissionTemplateItemReadModels_PermissionTemplateReadMode~", + column: x => x.TemplateId, + principalSchema: "Authorization", + principalTable: "PermissionTemplateReadModels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PermissionTemplateItemReadModels_TemplateId", + schema: "Authorization", + table: "PermissionTemplateItemReadModels", + column: "TemplateId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PermissionTemplateItemReadModels", + schema: "Authorization"); + + migrationBuilder.DropTable( + name: "PermissionTemplateReadModels", + schema: "Authorization"); + } + } +} diff --git a/src/apps/ums.api/Ums.ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs b/src/apps/ums.api/Ums.ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs new file mode 100644 index 00000000..23aa15f0 --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs @@ -0,0 +1,117 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.ReadModels; + +#nullable disable + +namespace Ums.ReadModels.Migrations +{ + [DbContext(typeof(ReadModelDbContext))] + partial class ReadModelDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Authorization") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.ReadModels.Models.PermissionTemplateItemReadModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId"); + + b.ToTable("PermissionTemplateItemReadModels", "Authorization"); + }); + + modelBuilder.Entity("Ums.ReadModels.Models.PermissionTemplateReadModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Version") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PermissionTemplateReadModels", "Authorization"); + }); + + modelBuilder.Entity("Ums.ReadModels.Models.PermissionTemplateItemReadModel", b => + { + b.HasOne("Ums.ReadModels.Models.PermissionTemplateReadModel", null) + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.ReadModels.Models.PermissionTemplateReadModel", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateItemReadModel.cs b/src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateItemReadModel.cs new file mode 100644 index 00000000..ddcbb3c6 --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateItemReadModel.cs @@ -0,0 +1,13 @@ +namespace Ums.ReadModels.Models; + +public sealed class PermissionTemplateItemReadModel +{ + public Guid Id { get; set; } + public Guid TemplateId { get; set; } + public int TargetTypeId { get; set; } + public Guid TargetId { get; set; } + public Guid ActionId { get; set; } + public bool IsAllowed { get; set; } + public bool IsDenied { get; set; } + public bool IsActive { get; set; } +} diff --git a/src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateReadModel.cs b/src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateReadModel.cs new file mode 100644 index 00000000..86dd17b6 --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateReadModel.cs @@ -0,0 +1,16 @@ +namespace Ums.ReadModels.Models; + +public sealed class PermissionTemplateReadModel +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public Guid RoleId { get; set; } + public Guid SystemSuiteId { get; set; } + public string Version { get; set; } = string.Empty; + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public string? UpdatedBy { get; set; } + public DateTime? UpdatedAtUtc { get; set; } + public string AuditTimeSpan { get; set; } = string.Empty; + public ICollection Items { get; set; } = new List(); +} diff --git a/src/apps/ums.api/Ums.ReadModels/Projections/PermissionTemplateProjectionHandler.cs b/src/apps/ums.api/Ums.ReadModels/Projections/PermissionTemplateProjectionHandler.cs new file mode 100644 index 00000000..1d0f5436 --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/Projections/PermissionTemplateProjectionHandler.cs @@ -0,0 +1,78 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Ums.Domain.Events; +using Ums.ReadModels.Models; +using Ums.ReadModels; +using Microsoft.EntityFrameworkCore; +namespace Ums.ReadModels.Projections; + +public sealed class PermissionTemplateProjectionHandler : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler +{ + private readonly ReadModelDbContext _dbContext; + + public PermissionTemplateProjectionHandler(ReadModelDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(PermissionTemplateCreatedEvent notification, CancellationToken cancellationToken) + { + var readModel = new PermissionTemplateReadModel + { + Id = notification.TemplateId, + TenantId = notification.TenantId, + RoleId = notification.RoleId, + SystemSuiteId = notification.SystemSuiteId, + Version = notification.Version, + CreatedBy = "system", + CreatedAtUtc = DateTime.UtcNow, + AuditTimeSpan = "", + Items = [] + }; + await _dbContext.Set().AddAsync(readModel, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task Handle(PermissionTemplateMutatedEvent notification, CancellationToken cancellationToken) + { + var entity = await _dbContext.Set() + .FirstOrDefaultAsync(p => p.Id == notification.TemplateId, cancellationToken); + if (entity != null) + { + entity.Version = notification.Version; + entity.UpdatedAtUtc = DateTime.UtcNow; + entity.UpdatedBy = "system"; + await _dbContext.SaveChangesAsync(cancellationToken); + } + } + + public async Task Handle(PermissionTemplatePublishedEvent notification, CancellationToken cancellationToken) + { + // For simplicity, treat publish as a version update (same as mutate) + var entity = await _dbContext.Set() + .FirstOrDefaultAsync(p => p.Id == notification.TemplateId, cancellationToken); + if (entity != null) + { + entity.Version = notification.Version; + entity.UpdatedAtUtc = DateTime.UtcNow; + entity.UpdatedBy = "system"; + await _dbContext.SaveChangesAsync(cancellationToken); + } + } + + public async Task Handle(PermissionTemplateDeletedEvent notification, CancellationToken cancellationToken) + { + var entity = await _dbContext.Set() + .FirstOrDefaultAsync(p => p.Id == notification.TemplateId, cancellationToken); + if (entity != null) + { + _dbContext.Set().Remove(entity); + await _dbContext.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/src/apps/ums.api/Ums.ReadModels/ReadModelDbContext.cs b/src/apps/ums.api/Ums.ReadModels/ReadModelDbContext.cs new file mode 100644 index 00000000..79241f52 --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/ReadModelDbContext.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using Ums.ReadModels.Models; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Ums.ReadModels; + +public sealed class ReadModelDbContext : DbContext +{ + public ReadModelDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.ConfigureWarnings(warnings => + warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); + } + + public DbSet PermissionTemplates { get; set; } = null!; + public DbSet PermissionTemplateItems { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Map to the same schema as the write model (Authorization) + modelBuilder.HasDefaultSchema("Authorization"); + + modelBuilder.Entity(builder => + { + builder.ToTable("PermissionTemplateReadModels"); + builder.HasKey(x => x.Id); + builder.HasMany(x => x.Items) + .WithOne() + .HasForeignKey(x => x.TemplateId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("PermissionTemplateItemReadModels"); + builder.HasKey(x => x.Id); + }); + } +} diff --git a/src/apps/ums.api/Ums.ReadModels/ReadModelDbContextFactory.cs b/src/apps/ums.api/Ums.ReadModels/ReadModelDbContextFactory.cs new file mode 100644 index 00000000..d53bd53a --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/ReadModelDbContextFactory.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Ums.ReadModels; + +public class ReadModelDbContextFactory : IDesignTimeDbContextFactory +{ + public ReadModelDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + // Cadena de solo diseño: la usa exclusivamente el tooling de EF Core (`dotnet ef migrations`), + // nunca el runtime — que resuelve la conexión por configuración. Misma convención que + // UmsPlatformDbContextFactory y TenantProjectionDbContextFactory. +#pragma warning disable S2068 // Cadena de conexión de DISEÑO (EF Core tooling), solo local; el runtime resuelve por configuración. No es un secreto de producción. + optionsBuilder.UseNpgsql("Host=localhost;Port=5433;Database=UmsReadModel;Username=postgres;Password=postgres"); +#pragma warning restore S2068 + return new ReadModelDbContext(optionsBuilder.Options); + } +} diff --git a/src/apps/ums.api/Ums.ReadModels/Ums.ReadModels.csproj b/src/apps/ums.api/Ums.ReadModels/Ums.ReadModels.csproj new file mode 100644 index 00000000..1b0f9335 --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/Ums.ReadModels.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + all + + + diff --git a/src/apps/ums.api/Ums.sln b/src/apps/ums.api/Ums.sln index 4b08cae2..b68fd63d 100644 --- a/src/apps/ums.api/Ums.sln +++ b/src/apps/ums.api/Ums.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -18,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ums.Domain.Test", "Ums.Doma EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ums.Application.Test", "Ums.Application.Test\Ums.Application.Test.csproj", "{5E7BC5A3-BD5B-43F3-8798-D5ADA31DD31E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ums.Presentation.IntegrationTest", "Ums.Presentation.IntegrationTest\Ums.Presentation.IntegrationTest.csproj", "{739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ums.ContractTest", "Ums.ContractTest\Ums.ContractTest.csproj", "{B1478498-36AF-4952-85EA-DE1D9140ADD5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -112,8 +117,32 @@ Global {5E7BC5A3-BD5B-43F3-8798-D5ADA31DD31E}.Release|x64.Build.0 = Release|Any CPU {5E7BC5A3-BD5B-43F3-8798-D5ADA31DD31E}.Release|x86.ActiveCfg = Release|Any CPU {5E7BC5A3-BD5B-43F3-8798-D5ADA31DD31E}.Release|x86.Build.0 = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|x64.ActiveCfg = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|x64.Build.0 = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|x86.ActiveCfg = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|x86.Build.0 = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|Any CPU.Build.0 = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|x64.ActiveCfg = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|x64.Build.0 = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|x86.ActiveCfg = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|x86.Build.0 = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|x64.Build.0 = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|x86.Build.0 = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|Any CPU.Build.0 = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|x64.ActiveCfg = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|x64.Build.0 = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|x86.ActiveCfg = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection -EndGlobal \ No newline at end of file +EndGlobal diff --git a/src/apps/ums.api/Ums.slnx b/src/apps/ums.api/Ums.slnx deleted file mode 100644 index 284a6cdc..00000000 --- a/src/apps/ums.api/Ums.slnx +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - From ea65c514c90f3880c6a293e028424f34a8734d2f Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 9 Aug 2026 20:16:52 -0500 Subject: [PATCH 02/35] feat(sdk): resincroniza los SDK .NET/TypeScript/NestJS con la plataforma de origen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trae el árbol de libs/sdk rebrandeado: Unimar.Ums.Sdk.* -> Ums.Sdk.*, Unimar.Shells.Net.Aop* -> BeyondNetCode.Shell.Aop* (el namespace del instalador de DI no es el id del paquete: BeyondNetCode.Shell.Aop.DI como paquete, BeyondNetCode.Shell.Aop.Microsoft.Extensions.DependencyInjection.Aspects.Installer como namespace), @unimar-peru/ums-sdk-* -> @ums/sdk-* y a BeyondNetCode. Validación: build 0 errores; Ums.Sdk.Tests 53/53. Co-Authored-By: Claude Opus 5 --- src/libs/sdk/CHANGELOG.md | 4 +- src/libs/sdk/contracts/SCHEMA_VERSIONING.md | 46 +- src/libs/sdk/contracts/auth-graph.schema.json | 859 ++++++++++++++---- src/libs/sdk/contracts/error-codes.yaml | 29 + src/libs/sdk/contracts/fixtures/README.md | 22 +- .../client-authenticate.envelope.json | 9 + .../sdk/contracts/fixtures/deny-wins.json | 123 ++- .../contracts/fixtures/empty-permissions.json | 51 +- .../sdk/contracts/fixtures/expired-graph.json | 53 +- .../fixtures/feature-flag-matched.json | 59 +- .../fixtures/feature-flag-missed-context.json | 52 +- .../contracts/fixtures/idp-auth-success.json | 91 +- .../fixtures/local-auth-success.json | 163 ++-- .../fixtures/multi-tenant-rejection.json | 55 +- .../fixtures/no-profile-in-system.json | 53 ++ .../contracts/fixtures/override-allow.json | 71 +- .../fixtures/schema-minor-ahead.json | 61 +- .../contracts/fixtures/schema-missing.json | 49 +- .../fixtures/schema-unsupported-major.json | 51 +- .../system-filtered-single-profile.json | 172 ++++ .../Ums.Sdk.Authorization.Aop.csproj | 11 +- .../CacheDeJwks.cs | 372 ++++++++ .../README.md | 3 + .../Ums.Sdk.Authorization.AspNetCore.csproj | 9 +- .../UmsAuthGraphMiddleware.cs | 133 ++- .../UmsAuthGraphMiddlewareOptions.cs | 40 +- .../AuthGraphBuilder.cs | 62 +- .../Ums.Sdk.Authorization.Testing.csproj | 9 +- .../AuthorizationValidator.cs | 82 +- .../Ums.Sdk.Authorization/GraphIndex.cs | 99 ++ .../dotnet/Ums.Sdk.Authorization/README.md | 2 +- .../Ums.Sdk.Authorization.csproj | 9 +- .../Ums.Sdk.Client/ClientAuthEnvelope.cs | 30 + .../dotnet/Ums.Sdk.Client/ClientAuthResult.cs | 9 +- .../Ums.Sdk.Client/Ums.Sdk.Client.csproj | 9 +- .../dotnet/Ums.Sdk.Client/UmsAuthClient.cs | 48 +- .../Ums.Sdk.Contracts/AuthorizationGraph.cs | 203 +++-- .../dotnet/Ums.Sdk.Contracts/SchemaVersion.cs | 6 +- .../Ums.Sdk.Contracts.csproj | 9 +- .../AuthorizationValidatorTests.cs | 4 +- .../Ums.Sdk.Tests/SchemaVersionTests.cs | 43 +- .../Ums.Sdk.Tests/UmsAuthClientTests.cs | 78 +- .../UmsAuthGraphMiddlewareRs256Tests.cs | 365 ++++++++ .../UmsAuthGraphMiddlewareTests.cs | 75 +- src/libs/sdk/nestjs/package-lock.json | 716 +++++++-------- src/libs/sdk/nestjs/package.json | 8 +- src/libs/sdk/nestjs/sdk-nestjs/README.md | 2 +- .../sdk/nestjs/sdk-nestjs/src/middleware.ts | 69 +- src/libs/sdk/nestjs/sdk-nestjs/src/module.ts | 21 +- src/libs/sdk/nestjs/sdk-nestjs/src/options.ts | 41 +- .../sdk-nestjs/tests/firma-rs256.test.ts | 178 ++++ .../sdk/nestjs/sdk-nestjs/tests/guard.test.ts | 63 +- .../typescript/sdk-authorization/README.md | 6 +- .../src/authorization-validator.ts | 122 ++- .../sdk-authorization/src/graph-index.ts | Bin 0 -> 4482 bytes .../typescript/sdk-authorization/src/index.ts | 2 + .../typescript/sdk-authorization/src/jwks.ts | 309 +++++++ .../src/verificacion-de-firma.ts | 252 +++++ .../sdk-authorization/tests/validator.test.ts | 2 +- .../tests/verificacion-de-firma.test.ts | 361 ++++++++ .../sdk/typescript/sdk-client/src/client.ts | 51 +- .../sdk/typescript/sdk-client/src/types.ts | 25 + .../sdk-client/tests/client.test.ts | 62 +- .../sdk-contracts/src/auth-graph.ts | 198 ++-- .../sdk-contracts/src/error-codes.ts | 7 +- .../sdk-contracts/src/schema-version.ts | 6 +- .../tests/schema-version.test.ts | 38 +- src/libs/sdk/typescript/sdk-express/README.md | 4 +- .../typescript/sdk-express/src/middleware.ts | 117 ++- .../sdk-express/tests/firma.test.ts | 134 +++ .../sdk-express/tests/middleware.test.ts | 68 +- .../sdk-testing/src/auth-graph-builder.ts | 124 +-- 72 files changed, 5333 insertions(+), 1436 deletions(-) create mode 100644 src/libs/sdk/contracts/fixtures/client-authenticate.envelope.json create mode 100644 src/libs/sdk/contracts/fixtures/no-profile-in-system.json create mode 100644 src/libs/sdk/contracts/fixtures/system-filtered-single-profile.json create mode 100644 src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/CacheDeJwks.cs create mode 100644 src/libs/sdk/dotnet/Ums.Sdk.Authorization/GraphIndex.cs create mode 100644 src/libs/sdk/dotnet/Ums.Sdk.Client/ClientAuthEnvelope.cs create mode 100644 src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthGraphMiddlewareRs256Tests.cs create mode 100644 src/libs/sdk/nestjs/sdk-nestjs/tests/firma-rs256.test.ts create mode 100644 src/libs/sdk/typescript/sdk-authorization/src/graph-index.ts create mode 100644 src/libs/sdk/typescript/sdk-authorization/src/jwks.ts create mode 100644 src/libs/sdk/typescript/sdk-authorization/src/verificacion-de-firma.ts create mode 100644 src/libs/sdk/typescript/sdk-authorization/tests/verificacion-de-firma.test.ts create mode 100644 src/libs/sdk/typescript/sdk-express/tests/firma.test.ts diff --git a/src/libs/sdk/CHANGELOG.md b/src/libs/sdk/CHANGELOG.md index 1cfc7ef7..30b0b6ee 100644 --- a/src/libs/sdk/CHANGELOG.md +++ b/src/libs/sdk/CHANGELOG.md @@ -1,7 +1,7 @@ # UMS SDK — Changelog All notable changes to the UMS SDK family (`.NET`, `TypeScript`, `NestJS`) are recorded here. -The schema versioning policy is described in [ADR-0074](../../../docs/architecture/adrs/0074-auth-graph-schema-versioning.md). +The schema versioning policy is described in [ADR-0074](../../../reference/architecture/adrs/UMS-074-versionado-esquema-grafo-autorizacion.es.md). ## [1.0.0] — Initial release @@ -45,4 +45,4 @@ The schema versioning policy is described in [ADR-0074](../../../docs/architectu | `@ums/sdk-* 1.0.x` | ✅ | | `@ums/sdk-nestjs 1.0.x` | ✅ | -Full matrix at [`docs/sdk/contracts/compatibility-matrix.md`](../../../docs/sdk/contracts/compatibility-matrix.md). +Full matrix at [`docs/sdk/contracts/compatibility-matrix.md`](../../../reference/sdk/contracts/matriz-compatibilidad.md). diff --git a/src/libs/sdk/contracts/SCHEMA_VERSIONING.md b/src/libs/sdk/contracts/SCHEMA_VERSIONING.md index 43da89c5..ce120a7b 100644 --- a/src/libs/sdk/contracts/SCHEMA_VERSIONING.md +++ b/src/libs/sdk/contracts/SCHEMA_VERSIONING.md @@ -1,12 +1,52 @@ # Auth Graph Schema Versioning — Operational Summary > Operational summary inside the source tree. The full architectural decision is in -> [ADR-0074](../../../../docs/architecture/adrs/0074-auth-graph-schema-versioning.md). -> Developer-facing guide: [`docs/sdk/contracts/versioning.md`](../../../../docs/sdk/contracts/versioning.md). +> [ADR-0074](../../../../reference/architecture/adrs/UMS-074-versionado-esquema-grafo-autorizacion.es.md). +> Developer-facing guide: [`docs/sdk/contracts/versioning.md`](../../../../reference/sdk/contracts/versionado.md). + +## Errata 2026-08-01 — el esquema no describía la realidad (G-167) + +Hasta esta fecha `auth-graph.schema.json` declaraba una forma que **ningún endpoint emitió jamás**: `id` obligatorios en cada nodo, nombres bajo `name`/`label`, y envoltorios `{ "module": … }` y `{ "resource": … }`. La API emitía otra —`value` para todo nombre, listas planas, ids omitidos— y además no una sino **dos**, porque el login web serializaba el record de dominio y el endpoint de cliente proyectaba su propio modelo. + +Se corrigió como **errata sobre `1.0.0`, sin bump**: no hubo ruptura de compatibilidad porque no existía un `1.0.0` funcional que romper — un cliente que hubiera integrado leyendo el contrato publicado recibía `undefined` en cada nombre y en cada id. Lo que cambió es la descripción, no el comportamiento; y donde sí cambió el comportamiento fue para **eliminar la divergencia**: hoy ambos endpoints proyectan con el mismo mapeador (`AuthGraphPayload`), que además corrige tres defectos del emisor de clientes (faltaba `sortOrder`, `resourceCode` se llamaba `code`, y `mfaAllowedMethods` no viajaba). + +La regla que evita la recaída: `Ums.Application.Test/Authorization/Graph/AuthGraphPayloadTests.cs` compara la proyección real contra las claves del contrato. Si divergen, falla la construcción. Ver [D-025](../../../../DECISIONS.md) y G-167 en [GAPS.md](../../../../GAPS.md). ## Current State -- **Current schema version:** `1.0.0` (initial release) +- **Current schema version:** `2.3.0` +- **Rango de compatibilidad de los SDK:** `[2.0.0, 3.0.0)` +- **Historial:** + - `1.0.0` — release inicial. + - `1.1.0` (2026-08-01) — bloque `profiles` con los perfiles activos del usuario. Aditivo, por eso MINOR. + - `2.0.0` (2026-08-01) — **MAJOR**. Dos cambios que rompen a cualquier consumidor: + 1. **Árbol de navegación recursivo.** `menuAccess[].menus[].subMenus[].options[]` pasa a + `menuAccess[].nodes[].children[]`, con las acciones agrupadas en el nodo. La forma anterior + tenía tres niveles fijos y perdía en silencio los nodos que no encajaban en ella (G-171). + 2. **Solo viaja lo alcanzable.** Las filas `NotGranted` desaparecen del cable, en navegación y + en objetos de dominio: la ausencia ya significaba denegación (fail-closed, G-039), así que + enviarlas era repetir en cada login lo que el contrato dice una vez. Medido sobre capturas + reales: el perfil más restringido baja de 42,0 a 9,5 KB. + + `Deny` sí sigue viajando: no es lo mismo que la ausencia — gana sobre cualquier concesión + heredada y el cliente debe poder pintarlo como bloqueado, no como inexistente. + - `2.1.0` (2026-08-01) — bloque `settings`: los ajustes del sistema marcados como visibles para + el cliente, agrupados por espacio de nombres (`settings.brand.logo_url`, `settings.ui.home_route`, + `settings.locale.language`…). Aditivo, por eso MINOR. + + **Es el punto de extensión del contrato.** Es el único sitio con `additionalProperties: true`: + añadir un espacio de nombres nuevo —notificaciones, informes, integraciones— no cambia la forma + del payload ni obliga a un bump. El resto del esquema sigue cerrado, que es donde la validación + estricta protege. + - `2.2.0` (2026-08-01) — `icon` y `route` en cada nodo de navegación. Aditivo, por eso MINOR. + Sin ellos el cliente sabía qué texto poner en el menú, pero no qué icono pintar ni a dónde + llevar al usuario: la promesa de «inicializar sin llamadas adicionales» no se sostenía. + `icon` es un IDENTIFICADOR, no un recurso — el catálogo gráfico lo resuelve el cliente; + guardar una URL o un SVG ataría el servidor a la biblioteca de iconos de un frontend concreto. + - `2.3.0` (2026-08-01) — `icon` en el módulo. Aditivo, por eso MINOR. El módulo era el único + nivel de la navegación sin presentación propia: el cliente sabía cómo se llamaba pero no con + qué pintarlo, y acababa resolviendo el icono por código, que es la tabla estática que el grafo + vino a eliminar (G-181, G-182). - **Schema file:** [`auth-graph.schema.json`](./auth-graph.schema.json) - **Error codes catalog:** [`error-codes.yaml`](./error-codes.yaml) - **Golden fixtures:** [`fixtures/`](./fixtures/) diff --git a/src/libs/sdk/contracts/auth-graph.schema.json b/src/libs/sdk/contracts/auth-graph.schema.json index 8974d48e..9affd8e3 100644 --- a/src/libs/sdk/contracts/auth-graph.schema.json +++ b/src/libs/sdk/contracts/auth-graph.schema.json @@ -1,13 +1,16 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ums.beyondnetcode.io/schemas/auth-graph/1.0.0", + "$id": "https://ums.beyondnetcode.io/schemas/auth-graph/2.4.0", "title": "AuthorizationGraph", - "description": "Canonical schema for the UMS AuthorizationGraph payload returned by POST /api/v1/client/authenticate. See ADR-0071 and ADR-0073.", + "description": "Forma canónica del grafo de autorización de UMS. Es UNA sola: la emiten por igual POST /api/v1/client/authenticate (sistemas satélite) y POST /api/v1/auth/login y sus variantes de refresco (frontend web, bajo la clave `authorizationGraph`). Ambos endpoints proyectan el grafo con el mismo mapeador, `AuthGraphPayload`. Ver ADR-UMS-088 (modelo), ADR-0073 y ADR-0074 (versionado). v2.0.0 (2026-08-01): el árbol de navegación pasa a ser RECURSIVO —`menuAccess[].nodes[].children[]`— porque la forma anterior, de tres niveles fijos, perdía en silencio los nodos que no encajaban en ella; y solo viajan los nodos y acciones ALCANZABLES: la ausencia significa no concedido. v2.1.0 (2026-08-01): bloque `settings` con los ajustes del sistema visibles para el cliente. v2.2.0 (2026-08-01): `icon` y `route` en los nodos de navegación. v2.3.0 (2026-08-01): `icon` también en el módulo, que era el único nivel de la navegación sin presentación propia. v2.4.0 (2026-08-02): `accessState` como discriminador cerrado del estado de acceso, `context.requestedSystem` como eco del sistema pedido, y `profiles[].id` pasa a estar siempre presente — sin él el cliente recibe una lista de perfiles y no tiene nada que enviar para cambiar a ninguno. Ver ADR-0156.", "type": "object", "additionalProperties": false, "required": [ "schemaVersion", + "onboardingPending", + "accessState", "context", + "profiles", "authentication", "actions", "menuAccess", @@ -16,43 +19,96 @@ "effectiveConfig", "scopes", "generatedAt", - "validUntil" + "validUntil", + "settings" ], "properties": { "schemaVersion": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", - "const": "1.0.0", - "description": "Semver of the graph payload schema. See ADR-0074." + "const": "2.4.0", + "description": "Semver del esquema del payload. Ver ADR-0074." + }, + "onboardingPending": { + "type": "boolean", + "description": "G-043: true cuando el usuario aún no tiene perfil/suite activos (grafo 'lobby' de onboarding). En ese caso `context.systemSuite`, `context.role` y `context.profile` son null y las colecciones llegan vacías. Siempre presente." + }, + "accessState": { + "type": "string", + "enum": [ + "Granted", + "NoProfileInSystem", + "OnboardingPending" + ], + "description": "v2.4.0 — estado de acceso. `Granted`: hay perfil vigente y el grafo lleva su navegación y sus permisos. `NoProfileInSystem`: el usuario TIENE perfiles pero ninguno en el sistema pedido —o el sistema pedido no existe, que es indistinguible por construcción—; `onboardingPending` es false porque la cuenta sí está dada de alta. `OnboardingPending`: no tiene NINGÚN perfil activo en el inquilino, y equivale a `onboardingPending: true`, que se conserva para los consumidores de 2.0–2.3. Los dos no pueden divergir: el productor los deriva de la misma entrada." + }, + "context": { + "$ref": "#/$defs/PrincipalContext" + }, + "authentication": { + "$ref": "#/$defs/AuthenticationMetadata" }, - "context": { "$ref": "#/$defs/PrincipalContext" }, - "authentication": { "$ref": "#/$defs/AuthenticationMetadata" }, "actions": { + "description": "Catálogo completo de acciones registradas en el sistema, ordenado por `code`.", "type": "array", - "items": { "$ref": "#/$defs/Action" } + "items": { + "$ref": "#/$defs/Action" + } + }, + "profiles": { + "description": "Perfiles activos del usuario en este inquilino, con el vigente marcado (`isCurrent`). Un perfil ata un usuario, un rol y —por el rol— un sistema, opcionalmente acotado a una sucursal. Permite al cliente ofrecer el cambio de perfil sin una llamada adicional. Vacía en el grafo lobby. Ordenada por nivel de jerarquía del rol, código de sistema y código de rol: el primero es el que el servidor habría elegido.", + "type": "array", + "items": { + "$ref": "#/$defs/ProfileOption" + } }, "menuAccess": { + "description": "Módulos con navegación alcanzable. Solo viaja lo CONCEDIDO o DENEGADO explícitamente: una hoja sin acciones resueltas se omite, una rama sin hojas se omite con ella, y un módulo sin nodos no aparece. La ausencia significa «no concedido» (fail-closed, G-039). El árbol es recursivo: recorre `children` hasta agotarlo, sin presuponer una profundidad.", "type": "array", - "items": { "$ref": "#/$defs/MenuModule" } + "items": { + "$ref": "#/$defs/MenuModule" + } }, "domainPermissions": { + "description": "Recursos de dominio con sus acciones resueltas. Igual que la navegación, solo viajan las acciones `Allow` o `Deny`: un recurso sin ninguna se omite. Fail-closed (G-039): la ausencia es denegación.", "type": "array", - "items": { "$ref": "#/$defs/DomainResourcePermissions" } + "items": { + "$ref": "#/$defs/DomainResourcePermissions" + } }, "featureFlags": { "type": "array", - "items": { "$ref": "#/$defs/FeatureFlagState" } + "items": { + "$ref": "#/$defs/FeatureFlagState" + } + }, + "effectiveConfig": { + "$ref": "#/$defs/EffectiveConfig" }, - "effectiveConfig": { "$ref": "#/$defs/EffectiveConfig" }, "scopes": { + "description": "Scopes estilo OAuth2 derivados de TODOS los efectos Allow (opciones y recursos de dominio), en minúsculas, deduplicados y ordenados alfabéticamente. Formato: `codigo.accion`.", "type": "array", "items": { "type": "string", - "pattern": "^[A-Z0-9_]+\\.[A-Z0-9_]+$" + "pattern": "^[a-z0-9_]+\\.[a-z0-9_]+$" } }, - "generatedAt": { "$ref": "#/$defs/IsoTimestamp" }, - "validUntil": { "$ref": "#/$defs/IsoTimestamp" } + "generatedAt": { + "$ref": "#/$defs/IsoTimestamp" + }, + "validUntil": { + "$ref": "#/$defs/IsoTimestamp" + }, + "settings": { + "description": "Ajustes del sistema visibles para el cliente, agrupados por espacio de nombres: branding, tema, disposición, idioma, parámetros funcionales… Permite inicializar la aplicación sin llamadas adicionales. La clave `ESPACIO_RESTO` del ajuste se proyecta como `settings.espacio.resto`, ambos en minúsculas: `BRAND_LOGO_URL` viaja como `settings.brand.logo_url`. Solo viaja lo marcado explícitamente como visible: `AppSetting` es una bolsa clave/valor donde también puede haber secretos (G-178). ESTE es el punto de extensión del contrato: añadir un espacio de nombres nuevo NO cambia la forma.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } }, "$defs": { "Uuid": { @@ -63,20 +119,97 @@ "type": "string", "format": "date-time" }, + "OptionalId": { + "description": "Identificador técnico. OPCIONAL y OMITIDO por defecto: solo aparece cuando el inquilino activa el parámetro AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA (por defecto `false`). Se omite la clave entera; nunca llega como null. Un cliente no debe depender de él: la clave de negocio es `code`.", + "$ref": "#/$defs/Uuid" + }, + "Status": { + "description": "Nombre del estado tal como lo expone el dominio. Enumeración abierta: pueden añadirse valores sin romper el contrato.", + "type": "string", + "minLength": 1 + }, "PrincipalContext": { "type": "object", "additionalProperties": false, - "required": ["user", "tenant", "systemSuite", "role", "profile", "branch"], + "required": [ + "user", + "tenant", + "systemSuite", + "role", + "profile", + "branch", + "requestedSystem" + ], "properties": { - "user": { "$ref": "#/$defs/UserSummary" }, - "tenant": { "$ref": "#/$defs/TenantSummary" }, - "systemSuite": { "$ref": "#/$defs/SystemSuiteSummary" }, - "role": { "$ref": "#/$defs/RoleSummary" }, - "profile": { "$ref": "#/$defs/ProfileSummary" }, + "user": { + "$ref": "#/$defs/UserSummary" + }, + "tenant": { + "$ref": "#/$defs/TenantSummary" + }, + "systemSuite": { + "description": "null en el grafo lobby (ver `onboardingPending`).", + "oneOf": [ + { + "$ref": "#/$defs/SystemSuiteSummary" + }, + { + "type": "null" + } + ] + }, + "role": { + "description": "null en el grafo lobby.", + "oneOf": [ + { + "$ref": "#/$defs/RoleSummary" + }, + { + "type": "null" + } + ] + }, + "profile": { + "description": "null en el grafo lobby.", + "oneOf": [ + { + "$ref": "#/$defs/ProfileSummary" + }, + { + "type": "null" + } + ] + }, "branch": { + "description": "null cuando el perfil es OrgWide.", "oneOf": [ - { "$ref": "#/$defs/BranchSummary" }, - { "type": "null" } + { + "$ref": "#/$defs/BranchSummary" + }, + { + "type": "null" + } + ] + }, + "requestedSystem": { + "description": "v2.4.0 — eco literal del sistema que el cliente pidió al autenticarse, ya normalizado, o null si no pidió ninguno (portal multiproducto). NO es una lectura del catálogo: devuelve la entrada del propio llamante, así que no revela nada que no supiera. Permite al cliente decir «no tiene acceso a X» sin llevar su configuración al navegador, y hace autodescriptivo un grafo capturado como evidencia.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "code" + ], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$" + } + } + }, + { + "type": "null" + } ] } } @@ -84,242 +217,530 @@ "UserSummary": { "type": "object", "additionalProperties": false, - "required": ["id", "email", "username", "displayName", "status"], + "required": [ + "email", + "username", + "value", + "status" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "email": { "type": "string", "format": "email" }, - "username": { "type": "string", "minLength": 1 }, - "displayName": { "type": "string" }, - "status": { "type": "string", "enum": ["ACTIVE", "PENDING", "BLOCKED"] } + "id": { + "$ref": "#/$defs/OptionalId" + }, + "email": { + "type": "string", + "format": "email" + }, + "username": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "Nombre para mostrar." + }, + "status": { + "$ref": "#/$defs/Status", + "examples": [ + "Pending", + "Active", + "Blocked", + "Deleted", + "Denied" + ] + } } }, "TenantSummary": { "type": "object", "additionalProperties": false, - "required": ["id", "code", "name", "status"], + "required": [ + "code", + "value", + "status", + "isManagementOwner" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string", "pattern": "^[A-Z0-9_]+$" }, - "name": { "type": "string" }, - "status": { "type": "string", "enum": ["ACTIVE", "SUSPENDED", "ARCHIVED"] } + "id": { + "$ref": "#/$defs/OptionalId" + }, + "code": { + "type": "string", + "pattern": "^[A-Z0-9_]+$" + }, + "value": { + "type": "string", + "description": "Razón social o nombre del inquilino." + }, + "status": { + "$ref": "#/$defs/Status", + "examples": [ + "Active", + "Suspended", + "Archived" + ] + }, + "isManagementOwner": { + "type": "boolean", + "description": "true solo para el inquilino operador de la plataforma (internal-admin)." + } } }, "SystemSuiteSummary": { "type": "object", "additionalProperties": false, - "required": ["id", "code", "name", "status"], + "required": [ + "code", + "value", + "status" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string", "pattern": "^[A-Z0-9_]+$" }, - "name": { "type": "string" }, - "status": { "type": "string", "enum": ["DRAFT", "PUBLISHED", "RETIRED"] } + "id": { + "$ref": "#/$defs/OptionalId" + }, + "code": { + "type": "string", + "pattern": "^[A-Z0-9_]+$" + }, + "value": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/Status", + "examples": [ + "Active", + "Maintenance", + "Deprecated" + ] + } } }, "RoleSummary": { "type": "object", "additionalProperties": false, - "required": ["id", "code", "name", "hierarchyLevel", "parentRoleId"], + "required": [ + "code", + "value", + "hierarchyLevel" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string", "pattern": "^[A-Z0-9_]+$" }, - "name": { "type": "string" }, - "hierarchyLevel": { "type": "integer", "minimum": 0 }, - "parentRoleId": { - "oneOf": [{ "$ref": "#/$defs/Uuid" }, { "type": "null" }] + "id": { + "$ref": "#/$defs/OptionalId" + }, + "code": { + "type": "string", + "pattern": "^[A-Z0-9_]+$" + }, + "value": { + "type": "string" + }, + "hierarchyLevel": { + "type": "integer", + "minimum": 0 } } }, "ProfileSummary": { "type": "object", "additionalProperties": false, - "required": ["id", "scope", "isActive"], + "required": [ + "scope", + "isActive" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "scope": { "type": "string", "enum": ["OrgWide", "BranchScoped"] }, - "isActive": { "type": "boolean" } + "id": { + "$ref": "#/$defs/OptionalId" + }, + "scope": { + "type": "string", + "enum": [ + "OrgWide", + "BranchScoped" + ] + }, + "isActive": { + "type": "boolean" + } } }, "BranchSummary": { "type": "object", "additionalProperties": false, - "required": ["id", "code", "name"], + "required": [ + "code", + "value" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string" }, - "name": { "type": "string" } + "id": { + "$ref": "#/$defs/OptionalId" + }, + "code": { + "type": "string" + }, + "value": { + "type": "string" + } } }, "AuthenticationMetadata": { "type": "object", "additionalProperties": false, - "required": ["method", "provider", "mfaRequired", "issuedAt", "sessionExpiresAt"], + "required": [ + "method", + "provider", + "mfaRequired", + "issuedAt", + "sessionExpiresAt" + ], "properties": { - "method": { "type": "string", "enum": ["Local", "IDP"] }, + "method": { + "type": "string", + "enum": [ + "Local", + "IDP" + ] + }, "provider": { + "description": "null cuando `method` es Local.", "oneOf": [ - { "$ref": "#/$defs/IdpProviderRef" }, - { "type": "null" } + { + "$ref": "#/$defs/IdpProviderRef" + }, + { + "type": "null" + } ] }, - "mfaRequired": { "type": "boolean" }, - "issuedAt": { "$ref": "#/$defs/IsoTimestamp" }, - "sessionExpiresAt": { "$ref": "#/$defs/IsoTimestamp" } + "mfaRequired": { + "type": "boolean" + }, + "issuedAt": { + "$ref": "#/$defs/IsoTimestamp" + }, + "sessionExpiresAt": { + "$ref": "#/$defs/IsoTimestamp" + } } }, "IdpProviderRef": { "type": "object", "additionalProperties": false, - "required": ["name", "code", "strategy"], + "required": [ + "code", + "name", + "value" + ], "properties": { - "name": { "type": "string" }, - "code": { "type": "string" }, - "strategy": { + "id": { + "$ref": "#/$defs/OptionalId" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { "type": "string", - "description": "Matches IdpStrategyHint from BC-A. Open enumeration." + "description": "Estrategia del proveedor (AzureAd, Okta, GenericOidc…). Enumeración abierta." } } }, "Action": { "type": "object", "additionalProperties": false, - "required": ["id", "code", "name"], + "required": [ + "code", + "value" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string" }, - "name": { "type": "string" } + "code": { + "type": "string" + }, + "value": { + "type": "string", + "description": "Nombre legible de la acción." + } } }, "AccessEffect": { "type": "string", - "enum": ["Allow", "Deny", "NotGranted"] + "enum": [ + "Allow", + "Deny", + "NotGranted" + ] }, "PermissionSource": { "type": "string", - "enum": ["Template", "Override"] - }, - "MenuModule": { - "type": "object", - "additionalProperties": false, - "required": ["module", "menus"], - "properties": { - "module": { "$ref": "#/$defs/ModuleSummary" }, - "menus": { - "type": "array", - "items": { "$ref": "#/$defs/Menu" } - } - } + "enum": [ + "Template", + "Override" + ] }, - "ModuleSummary": { + "ProfileOption": { "type": "object", "additionalProperties": false, - "required": ["id", "code", "name", "sortOrder", "status"], - "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string" }, - "name": { "type": "string" }, - "sortOrder": { "type": "integer", "minimum": 0 }, - "status": { "type": "string", "enum": ["DRAFT", "PUBLISHED", "RETIRED"] } - } - }, - "Menu": { - "type": "object", - "additionalProperties": false, - "required": ["id", "code", "label", "sortOrder", "subMenus"], + "required": [ + "id", + "system", + "role", + "branch", + "scope", + "isCurrent" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string" }, - "label": { "type": "string" }, - "sortOrder": { "type": "integer", "minimum": 0 }, - "subMenus": { - "type": "array", - "items": { "$ref": "#/$defs/SubMenu" } + "id": { + "type": "string", + "format": "uuid", + "description": "v2.4.0 — SIEMPRE presente, a diferencia de los ids técnicos de módulos, nodos, recursos y banderas, que siguen sujetos a `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA`. Aquí el id no es decorativo: es la clave de `POST /client/switch-profile`, una operación que el propio grafo invita a ejecutar. Un selector semántico no serviría porque (TenantId, UserId, RoleId, BranchId) no es único." + }, + "system": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "value" + ], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Z0-9_]+$" + }, + "value": { + "type": "string" + } + } + }, + "role": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "value", + "hierarchyLevel" + ], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Z0-9_]+$" + }, + "value": { + "type": "string" + }, + "hierarchyLevel": { + "type": "integer", + "minimum": 0 + } + } + }, + "branch": { + "description": "null cuando el perfil es OrgWide.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "value" + ], + "properties": { + "code": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "scope": { + "type": "string", + "enum": [ + "OrgWide", + "BranchScoped" + ] + }, + "isCurrent": { + "type": "boolean", + "description": "Exactamente un elemento lleva true, salvo en el grafo lobby, donde la lista está vacía." } } }, - "SubMenu": { + "MenuModule": { "type": "object", "additionalProperties": false, - "required": ["id", "code", "label", "sortOrder", "options"], + "required": [ + "code", + "value", + "sortOrder", + "status", + "icon", + "nodes" + ], "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string" }, - "label": { "type": "string" }, - "sortOrder": { "type": "integer", "minimum": 0 }, - "options": { + "id": { + "$ref": "#/$defs/OptionalId" + }, + "code": { + "type": "string" + }, + "value": { + "type": "string" + }, + "sortOrder": { + "type": "integer", + "minimum": 0 + }, + "status": { + "$ref": "#/$defs/Status", + "examples": [ + "Active", + "Inactive" + ] + }, + "icon": { + "description": "Identificador de icono del módulo, con el mismo criterio que el del nodo: no es un recurso, el catálogo gráfico lo resuelve el cliente. null si no se configuró.", + "type": [ + "string", + "null" + ] + }, + "nodes": { "type": "array", - "items": { "$ref": "#/$defs/MenuOption" } + "items": { + "$ref": "#/$defs/NavigationNode" + } } } }, - "MenuOption": { - "type": "object", - "additionalProperties": false, - "required": ["id", "code", "label", "actionCode", "effect", "source"], - "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "code": { "type": "string" }, - "label": { "type": "string" }, - "actionCode": { "type": "string" }, - "effect": { "$ref": "#/$defs/AccessEffect" }, - "source": { "$ref": "#/$defs/PermissionSource" } - } - }, "DomainResourcePermissions": { "type": "object", "additionalProperties": false, - "required": ["resource", "actions"], + "required": [ + "resourceType", + "resourceCode", + "value", + "actions" + ], "properties": { - "resource": { "$ref": "#/$defs/DomainResource" }, + "resourceId": { + "$ref": "#/$defs/OptionalId" + }, + "resourceType": { + "type": "string", + "enum": [ + "Aggregate", + "Entity", + "DomainMethod" + ] + }, + "resourceCode": { + "type": "string" + }, + "value": { + "type": "string" + }, + "moduleId": { + "description": "Solo con metadatos técnicos activados; puede ser null si el recurso no cuelga de un módulo.", + "oneOf": [ + { + "$ref": "#/$defs/Uuid" + }, + { + "type": "null" + } + ] + }, + "parentResourceId": { + "description": "Solo con metadatos técnicos activados; null si el recurso es raíz.", + "oneOf": [ + { + "$ref": "#/$defs/Uuid" + }, + { + "type": "null" + } + ] + }, "actions": { "type": "array", - "items": { "$ref": "#/$defs/DomainActionResolution" } - } - } - }, - "DomainResource": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "code", "name", "moduleId"], - "properties": { - "id": { "$ref": "#/$defs/Uuid" }, - "type": { "type": "string", "enum": ["Aggregate", "Entity"] }, - "code": { "type": "string" }, - "name": { "type": "string" }, - "moduleId": { - "oneOf": [{ "$ref": "#/$defs/Uuid" }, { "type": "null" }] + "items": { + "$ref": "#/$defs/DomainActionResolution" + } } } }, "DomainActionResolution": { "type": "object", "additionalProperties": false, - "required": ["actionId", "actionCode", "actionName", "effect", "source"], + "required": [ + "actionCode", + "value", + "effect", + "source" + ], "properties": { - "actionId": { "$ref": "#/$defs/Uuid" }, - "actionCode": { "type": "string" }, - "actionName": { "type": "string" }, - "effect": { "$ref": "#/$defs/AccessEffect" }, - "source": { "$ref": "#/$defs/PermissionSource" } + "actionCode": { + "type": "string" + }, + "value": { + "type": "string", + "description": "Nombre legible de la acción." + }, + "effect": { + "type": "string", + "enum": [ + "Allow", + "Deny" + ] + }, + "source": { + "$ref": "#/$defs/PermissionSource" + } } }, "FeatureFlagState": { "type": "object", "additionalProperties": false, - "required": ["flagCode", "systemSuiteId", "isEnabled", "matchedCriteriaType"], + "required": [ + "flagCode", + "isEnabled", + "matchedCriteriaType" + ], "properties": { - "flagCode": { "type": "string" }, - "systemSuiteId": { "$ref": "#/$defs/Uuid" }, - "isEnabled": { "type": "boolean" }, + "systemSuiteId": { + "$ref": "#/$defs/OptionalId" + }, + "flagCode": { + "type": "string" + }, + "isEnabled": { + "type": "boolean" + }, "matchedCriteriaType": { + "description": "Tipo de criterio que activó el flag; null cuando ningún criterio aplicó. Siempre presente.", "oneOf": [ { "type": "string", "enum": [ - "TenantId", "BranchId", "UserProfileId", "RoleCode", - "Environment", "DateRange", "PercentageHash", "CustomRule" + "TenantId", + "BranchId", + "UserProfileId", + "RoleCode", + "Environment", + "DateRange", + "PercentageHash", + "CustomRule" ] }, - { "type": "null" } + { + "type": "null" + } ] } } @@ -332,16 +753,130 @@ "maxLoginAttempts", "minPasswordLength", "mfaRequiredForAdmin", + "mfaAllowedMethods", "accessTokenDurationMs", "authUseExternalIdp" ], "properties": { - "sessionTimeoutMinutes": { "type": "integer", "minimum": 1 }, - "maxLoginAttempts": { "type": "integer", "minimum": 1 }, - "minPasswordLength": { "type": "integer", "minimum": 1 }, - "mfaRequiredForAdmin": { "type": "boolean" }, - "accessTokenDurationMs": { "type": "integer", "minimum": 1 }, - "authUseExternalIdp": { "type": "boolean" } + "sessionTimeoutMinutes": { + "type": "integer", + "minimum": 1 + }, + "maxLoginAttempts": { + "type": "integer", + "minimum": 1 + }, + "minPasswordLength": { + "type": "integer", + "minimum": 1 + }, + "mfaRequiredForAdmin": { + "type": "boolean" + }, + "mfaAllowedMethods": { + "description": "Métodos MFA que el inquilino admite (Totp, Sms, Email…). Enumeración abierta.", + "type": "array", + "items": { + "type": "string" + } + }, + "accessTokenDurationMs": { + "type": "integer", + "minimum": 1 + }, + "authUseExternalIdp": { + "type": "boolean" + } + } + }, + "NavigationNode": { + "description": "Nodo del árbol de navegación. `kind` clasifica su papel sin fijar su profundidad: una opción puede colgar de un menú, de un submenú o de otro nivel intermedio. `children` viaja siempre, aunque venga vacío.", + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "value", + "kind", + "sortOrder", + "icon", + "route", + "actions", + "children" + ], + "properties": { + "id": { + "$ref": "#/$defs/OptionalId" + }, + "code": { + "type": "string" + }, + "value": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "Menu", + "SubMenu", + "Option" + ] + }, + "sortOrder": { + "type": "integer", + "minimum": 0 + }, + "icon": { + "description": "Identificador de icono, no un recurso: el catálogo gráfico lo elige el cliente. null si no se configuró.", + "type": [ + "string", + "null" + ] + }, + "route": { + "description": "Ruta relativa a la que lleva el nodo. null en los nodos que solo agrupan. Sin ella el cliente sabe qué pintar pero no a dónde llevar.", + "type": [ + "string", + "null" + ] + }, + "actions": { + "description": "Acciones resueltas sobre este nodo. Vacía en los nodos de agrupación.", + "type": "array", + "items": { + "$ref": "#/$defs/NodeAction" + } + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/NavigationNode" + } + } + } + }, + "NodeAction": { + "description": "Una acción con su efecto resuelto. Solo `Allow` y `Deny`: `NotGranted` no viaja porque es lo que significa la ausencia. `Deny` sí viaja, porque una denegación explícita gana sobre cualquier concesión heredada y el cliente debe poder pintarla como bloqueada, no como inexistente.", + "type": "object", + "additionalProperties": false, + "required": [ + "actionCode", + "effect", + "source" + ], + "properties": { + "actionCode": { + "type": "string" + }, + "effect": { + "type": "string", + "enum": [ + "Allow", + "Deny" + ] + }, + "source": { + "$ref": "#/$defs/PermissionSource" + } } } } diff --git a/src/libs/sdk/contracts/error-codes.yaml b/src/libs/sdk/contracts/error-codes.yaml index 0ae10777..6882b2f3 100644 --- a/src/libs/sdk/contracts/error-codes.yaml +++ b/src/libs/sdk/contracts/error-codes.yaml @@ -14,6 +14,10 @@ categories: range: "AUTH_011..AUTH_019" description: "Server-emitted errors raised while resolving the active IDP for a tenant." + - id: profile + range: "AUTH_020..AUTH_039" + description: "Server-emitted errors raised while resolving or switching the active profile." + - id: authorization range: "AUTH_100..AUTH_199" description: "SDK-emitted errors for declarative authorization decisions." @@ -123,6 +127,31 @@ codes: emittedBy: [server] deprecated: false + # --- Profile (server-emitted) ------------------------------------------- + # AUTH_020 y AUTH_021 los emitia el servidor desde que existe el cambio de perfil, sin + # figurar en este catalogo: un consumidor que lo leyera como la lista completa se + # equivocaba. Se documentan aqui por primera vez (G-226); no son codigos nuevos. + - code: AUTH_020 + httpStatus: 404 + title: "Profile not found" + message: "The requested profile does not exist, or does not belong to the authenticated user and tenant. Both cases return this same code on purpose: distinguishing them would let a caller enumerate other users' profiles." + emittedBy: [server] + deprecated: false + + - code: AUTH_021 + httpStatus: 409 + title: "Profile inactive" + message: "The requested profile exists and belongs to the user, but is not active." + emittedBy: [server] + deprecated: false + + - code: AUTH_036 + httpStatus: 409 + title: "Profile outside the requested system" + message: "The requested profile does not belong to the system supplied in `systemCode`. Coherence guard: it never fires in the normal flow, where `profiles[]` is already scoped to the caller's system." + emittedBy: [server] + deprecated: false + # --- Authorization (SDK-emitted) ---------------------------------------- - code: AUTH_101 title: "Scope not granted" diff --git a/src/libs/sdk/contracts/fixtures/README.md b/src/libs/sdk/contracts/fixtures/README.md index 31dedbfe..d4444326 100644 --- a/src/libs/sdk/contracts/fixtures/README.md +++ b/src/libs/sdk/contracts/fixtures/README.md @@ -2,7 +2,7 @@ This directory contains representative `AuthorizationGraph` payloads used as the **executable contract** for the UMS SDK. Every SDK runtime and the UMS server must agree on how to interpret these fixtures. -See [`docs/sdk/contracts/fixtures.md`](../../../../../docs/sdk/contracts/fixtures.md) for the policy, conventions and CI integration details. +See [`docs/sdk/contracts/fixtures.md`](../../../../../reference/sdk/contracts/fixtures.md) for the policy, conventions and CI integration details. ## Inventory @@ -28,6 +28,26 @@ See [`docs/sdk/contracts/fixtures.md`](../../../../../docs/sdk/contracts/fixture | `schema-minor-ahead.json` | `schemaVersion: "1.99.0"` with unknown optional fields — accept-with-warning, preserve in `extensions` | | `schema-missing.json` | No `schemaVersion` field at all — `AUTH_204` | +## El sobre de `/client/authenticate` (G-207) + +`client-authenticate.envelope.json` no es un grafo: es el **sobre completo** que devuelve +`POST /api/v1/client/authenticate`, y es el único fixture de este directorio **capturado de la API +viva** en vez de escrito a mano. + +Lo genera `ContratoDeClientAuthenticateTests` (suite de integración de la API), que además compara +la respuesta real contra el fichero comprometido y falla si divergen. Los campos volátiles —token, +`issuedAt`, `requestId`— se sustituyen por un valor fijo **del mismo tipo**: el fixture lo +deserializan los dos SDK de cliente, así que un marcador en prosa donde va una fecha los haría +fallar por el fixture y no por el contrato. + +Existe porque los dos SDK tipaban `graph` como objeto cuando la API lo devuelve como **cadena +serializada** —el formato lo elige el inquilino, y un objeto JSON no puede transportar XML—. Sus +pruebas usaban sobres escritos a mano con la forma que el SDK suponía, así que pasaban mientras +cualquier login real fallaba con `AUTH_204`. Un fixture escrito a mano comprueba lo que uno cree; +uno capturado comprueba lo que hay. + +Para regenerarlo: borra el fichero y ejecuta esa prueba. + ## CI Behavior Phase B CI workflow (`.github/workflows/sdk-contract-validation.yml`, to be added): diff --git a/src/libs/sdk/contracts/fixtures/client-authenticate.envelope.json b/src/libs/sdk/contracts/fixtures/client-authenticate.envelope.json new file mode 100644 index 00000000..555c8762 --- /dev/null +++ b/src/libs/sdk/contracts/fixtures/client-authenticate.envelope.json @@ -0,0 +1,9 @@ +{ + "token": "PORTADOR.FIJADO.EN.EL.FIXTURE", + "tokenType": "Bearer", + "expiresIn": 3600, + "issuedAt": "2026-01-01T00:00:00.0000000+00:00", + "format": "JSON", + "graph": "{\n \"schemaVersion\": \"2.4.0\",\n \"onboardingPending\": false,\n \"accessState\": \"Granted\",\n \"context\": {\n \"user\": {\n \"email\": \"usuario.impo@comexandina.com.pe\",\n \"username\": \"usuario.impo@comexandina.com.pe\",\n \"value\": \"usuario.impo@comexandina.com.pe\",\n \"status\": \"Active\"\n },\n \"tenant\": {\n \"code\": \"COMEX_ANDINA\",\n \"value\": \"Comercializadora Andina S.A.C.\",\n \"status\": \"Active\",\n \"isManagementOwner\": false\n },\n \"systemSuite\": {\n \"code\": \"PORTAL_CLIENTE\",\n \"value\": \"Portal del Cliente\",\n \"status\": \"Active\"\n },\n \"role\": {\n \"code\": \"CLIENTE_EXTERNO\",\n \"value\": \"Cliente Externo\",\n \"hierarchyLevel\": 0\n },\n \"profile\": {\n \"scope\": \"OrgWide\",\n \"isActive\": true\n },\n \"branch\": null,\n \"requestedSystem\": null\n },\n \"authentication\": {\n \"method\": \"Local\",\n \"provider\": null,\n \"mfaRequired\": false,\n \"issuedAt\": \"2026-01-01T00:00:00.0000000Z\",\n \"sessionExpiresAt\": \"2099-12-31T23:59:59.0000000Z\"\n },\n \"actions\": [\n {\n \"code\": \"APPROVE\",\n \"value\": \"Aprobar\"\n },\n {\n \"code\": \"CREATE\",\n \"value\": \"Crear\"\n },\n {\n \"code\": \"DELETE\",\n \"value\": \"Eliminar\"\n },\n {\n \"code\": \"READ\",\n \"value\": \"Leer\"\n },\n {\n \"code\": \"SEARCH\",\n \"value\": \"Buscar\"\n },\n {\n \"code\": \"UPDATE\",\n \"value\": \"Actualizar\"\n },\n {\n \"code\": \"VIEW\",\n \"value\": \"Ver\"\n }\n ],\n \"profiles\": [\n {\n \"id\": \"00000000-0000-0000-0000-000000000001\",\n \"system\": {\n \"code\": \"PORTAL_CLIENTE\",\n \"value\": \"Portal del Cliente\"\n },\n \"role\": {\n \"code\": \"CLIENTE_EXTERNO\",\n \"value\": \"Cliente Externo\",\n \"hierarchyLevel\": 0\n },\n \"branch\": null,\n \"scope\": \"OrgWide\",\n \"isCurrent\": true\n }\n ],\n \"menuAccess\": [\n {\n \"code\": \"QUERY\",\n \"value\": \"Consultas\",\n \"sortOrder\": 1,\n \"status\": \"Active\",\n \"icon\": \"inbox\",\n \"nodes\": [\n {\n \"code\": \"FILE_STATUS\",\n \"value\": \"Estado de Expediente\",\n \"kind\": \"Menu\",\n \"sortOrder\": 1,\n \"icon\": \"layout-grid\",\n \"route\": null,\n \"actions\": [],\n \"children\": [\n {\n \"code\": \"FILE_STATUS_LIST\",\n \"value\": \"Estado de Expediente\",\n \"kind\": \"SubMenu\",\n \"sortOrder\": 1,\n \"icon\": null,\n \"route\": null,\n \"actions\": [],\n \"children\": [\n {\n \"code\": \"VIEW_FILE_STATUS\",\n \"value\": \"Ver Estado de Expediente\",\n \"kind\": \"Option\",\n \"sortOrder\": 1,\n \"icon\": null,\n \"route\": \"/query/view-file-status\",\n \"actions\": [\n {\n \"actionCode\": \"VIEW\",\n \"effect\": \"Allow\",\n \"source\": \"Template\"\n }\n ],\n \"children\": []\n },\n {\n \"code\": \"MANAGE_FILE_STATUS\",\n \"value\": \"Gestionar Estado de Expediente\",\n \"kind\": \"Option\",\n \"sortOrder\": 2,\n \"icon\": null,\n \"route\": \"/query/manage-file-status\",\n \"actions\": [\n {\n \"actionCode\": \"UPDATE\",\n \"effect\": \"Allow\",\n \"source\": \"Template\"\n }\n ],\n \"children\": []\n }\n ]\n }\n ]\n },\n {\n \"code\": \"DOCUMENTS\",\n \"value\": \"Documentos\",\n \"kind\": \"Menu\",\n \"sortOrder\": 2,\n \"icon\": \"layout-grid\",\n \"route\": null,\n \"actions\": [],\n \"children\": [\n {\n \"code\": \"DOCUMENTS_LIST\",\n \"value\": \"Documentos\",\n \"kind\": \"SubMenu\",\n \"sortOrder\": 1,\n \"icon\": null,\n \"route\": null,\n \"actions\": [],\n \"children\": [\n {\n \"code\": \"VIEW_DOCUMENTS\",\n \"value\": \"Ver Documentos\",\n \"kind\": \"Option\",\n \"sortOrder\": 1,\n \"icon\": null,\n \"route\": \"/query/view-documents\",\n \"actions\": [\n {\n \"actionCode\": \"VIEW\",\n \"effect\": \"Allow\",\n \"source\": \"Template\"\n }\n ],\n \"children\": []\n },\n {\n \"code\": \"MANAGE_DOCUMENTS\",\n \"value\": \"Gestionar Documentos\",\n \"kind\": \"Option\",\n \"sortOrder\": 2,\n \"icon\": null,\n \"route\": \"/query/manage-documents\",\n \"actions\": [\n {\n \"actionCode\": \"UPDATE\",\n \"effect\": \"Allow\",\n \"source\": \"Template\"\n }\n ],\n \"children\": []\n }\n ]\n }\n ]\n }\n ]\n },\n {\n \"code\": \"NOTIF\",\n \"value\": \"Notificaciones\",\n \"sortOrder\": 2,\n \"status\": \"Active\",\n \"icon\": \"bell\",\n \"nodes\": [\n {\n \"code\": \"NOTICES\",\n \"value\": \"Avisos\",\n \"kind\": \"Menu\",\n \"sortOrder\": 1,\n \"icon\": \"bell\",\n \"route\": null,\n \"actions\": [],\n \"children\": [\n {\n \"code\": \"NOTICES_LIST\",\n \"value\": \"Avisos\",\n \"kind\": \"SubMenu\",\n \"sortOrder\": 1,\n \"icon\": null,\n \"route\": null,\n \"actions\": [],\n \"children\": [\n {\n \"code\": \"VIEW_NOTICES\",\n \"value\": \"Ver Avisos\",\n \"kind\": \"Option\",\n \"sortOrder\": 1,\n \"icon\": null,\n \"route\": \"/notif/view-notices\",\n \"actions\": [\n {\n \"actionCode\": \"VIEW\",\n \"effect\": \"Allow\",\n \"source\": \"Template\"\n }\n ],\n \"children\": []\n },\n {\n \"code\": \"MANAGE_NOTICES\",\n \"value\": \"Gestionar Avisos\",\n \"kind\": \"Option\",\n \"sortOrder\": 2,\n \"icon\": null,\n \"route\": \"/notif/manage-notices\",\n \"actions\": [\n {\n \"actionCode\": \"UPDATE\",\n \"effect\": \"Allow\",\n \"source\": \"Template\"\n }\n ],\n \"children\": []\n }\n ]\n }\n ]\n }\n ]\n }\n ],\n \"domainPermissions\": [],\n \"featureFlags\": [],\n \"effectiveConfig\": {\n \"sessionTimeoutMinutes\": 30,\n \"maxLoginAttempts\": 5,\n \"minPasswordLength\": 12,\n \"mfaRequiredForAdmin\": false,\n \"mfaAllowedMethods\": [\n \"Totp\",\n \"WebAuthn\",\n \"SmsOtp\",\n \"EmailOtp\"\n ],\n \"accessTokenDurationMs\": 3600000,\n \"authUseExternalIdp\": false\n },\n \"settings\": {\n \"locale\": {\n \"currency\": \"PEN\",\n \"timezone\": \"America/Lima\",\n \"language\": \"es-PE\"\n },\n \"ui\": {\n \"density\": \"comfortable\",\n \"home_route\": \"/\",\n \"layout\": \"nav-rail\"\n },\n \"theme\": {\n \"mode\": \"system\",\n \"accent\": \"#27ae60\",\n \"primary\": \"#0f3e67\"\n },\n \"brand\": {\n \"icon_url\": \"/branding/portal_cliente/icon.svg\",\n \"logo_url\": \"/branding/portal_cliente/logo.svg\",\n \"tagline\": \"Portal de consulta acotado para clientes externos\",\n \"short_name\": \"PORTAL_CLIENTE\",\n \"display_name\": \"Portal del Cliente\"\n }\n },\n \"scopes\": [\n \"manage_documents.update\",\n \"manage_file_status.update\",\n \"manage_notices.update\",\n \"view_documents.view\",\n \"view_file_status.view\",\n \"view_notices.view\"\n ],\n \"generatedAt\": \"2026-01-01T00:00:00.0000000Z\",\n \"validUntil\": \"2099-12-31T23:59:59.0000000Z\"\n}", + "requestId": "0HN7QMV8KJLDT:00000001" +} \ No newline at end of file diff --git a/src/libs/sdk/contracts/fixtures/deny-wins.json b/src/libs/sdk/contracts/fixtures/deny-wins.json index c615342a..e7d466ea 100644 --- a/src/libs/sdk/contracts/fixtures/deny-wins.json +++ b/src/libs/sdk/contracts/fixtures/deny-wins.json @@ -1,39 +1,54 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0003-4f00-9a00-100000000003", "email": "deny.test@logistics-corp.example", "username": "deny.test", - "displayName": "Deny Test User", - "status": "ACTIVE" + "value": "Deny Test User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": null + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-444444444446", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "da932ecd-7792-558a-aabc-c16c1fa40826", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -42,59 +57,69 @@ "sessionExpiresAt": "2026-05-31T15:30:00Z" }, "actions": [ - { "id": "a0000001-0000-4000-8000-000000000004", "code": "DELETE", "name": "Delete" } + { + "code": "DELETE", + "value": "Delete" + } ], "menuAccess": [ { - "module": { - "id": "50000001-0000-4000-8000-000000000001", - "code": "INVENTORY", - "name": "Inventory", - "sortOrder": 1, - "status": "PUBLISHED" - }, - "menus": [ + "code": "INVENTORY", + "value": "Inventory", + "sortOrder": 1, + "status": "Active", + "icon": "layout-grid", + "nodes": [ { - "id": "60000001-0000-4000-8000-000000000001", "code": "STOCK", - "label": "Stock Management", + "value": "Stock Management", + "kind": "Menu", "sortOrder": 1, - "subMenus": [ + "actions": [], + "children": [ { - "id": "70000001-0000-4000-8000-000000000001", "code": "STOCK_OPS", - "label": "Operations", + "value": "Operations", + "kind": "SubMenu", "sortOrder": 1, - "options": [ + "actions": [], + "children": [ { - "id": "80000001-0000-4000-8000-000000000003", "code": "STOCK_DELETE", - "label": "Delete Stock Record", - "actionCode": "DELETE", - "effect": "Deny", - "source": "Override" + "value": "Delete Stock Record", + "kind": "Option", + "sortOrder": 1, + "actions": [ + { + "actionCode": "DELETE", + "effect": "Deny", + "source": "Override" + } + ], + "children": [], + "icon": null, + "route": null } - ] + ], + "icon": null, + "route": null } - ] + ], + "icon": null, + "route": null } ] } ], "domainPermissions": [ { - "resource": { - "id": "90000001-0000-4000-8000-000000000001", - "type": "Aggregate", - "code": "PURCHASE_ORDER", - "name": "Purchase Order", - "moduleId": "50000001-0000-4000-8000-000000000001" - }, + "resourceType": "Aggregate", + "resourceCode": "PURCHASE_ORDER", + "value": "Purchase Order", "actions": [ { - "actionId": "a0000001-0000-4000-8000-000000000004", "actionCode": "DELETE", - "actionName": "Delete", + "value": "Delete", "effect": "Deny", "source": "Override" } @@ -107,9 +132,13 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/empty-permissions.json b/src/libs/sdk/contracts/fixtures/empty-permissions.json index 5ddc5ed7..31a1548f 100644 --- a/src/libs/sdk/contracts/fixtures/empty-permissions.json +++ b/src/libs/sdk/contracts/fixtures/empty-permissions.json @@ -1,39 +1,54 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0005-4f00-9a00-100000000005", "email": "newbie@logistics-corp.example", "username": "newbie", - "displayName": "New User", - "status": "ACTIVE" + "value": "New User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333335", "code": "READ_ONLY_VIEWER", - "name": "Read-Only Viewer", - "hierarchyLevel": 1, - "parentRoleId": null + "value": "Read-Only Viewer", + "hierarchyLevel": 1 }, "profile": { - "id": "44444444-4444-4444-8444-444444444448", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "32d97185-ece8-5e0f-af34-60a87a1fec09", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "READ_ONLY_VIEWER", + "value": "Read-Only Viewer", + "hierarchyLevel": 1 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -50,9 +65,13 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/expired-graph.json b/src/libs/sdk/contracts/fixtures/expired-graph.json index 8e65b752..bacefba3 100644 --- a/src/libs/sdk/contracts/fixtures/expired-graph.json +++ b/src/libs/sdk/contracts/fixtures/expired-graph.json @@ -1,39 +1,54 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0006-4f00-9a00-100000000006", "email": "expired.session@logistics-corp.example", "username": "expired.session", - "displayName": "Expired Session User", - "status": "ACTIVE" + "value": "Expired Session User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": null + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-444444444449", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "fd5c97ed-1170-5888-acbb-aa1ed0668e0d", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -50,11 +65,15 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [ - "PURCHASE_ORDER.VIEW" + "purchase_order.view" ], "generatedAt": "2020-01-01T00:00:00Z", "validUntil": "2020-01-01T01:00:00Z" diff --git a/src/libs/sdk/contracts/fixtures/feature-flag-matched.json b/src/libs/sdk/contracts/fixtures/feature-flag-matched.json index 53c16d5f..299917bf 100644 --- a/src/libs/sdk/contracts/fixtures/feature-flag-matched.json +++ b/src/libs/sdk/contracts/fixtures/feature-flag-matched.json @@ -1,43 +1,60 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0007-4f00-9a00-100000000007", "email": "flag.user@logistics-corp.example", "username": "flag.user", - "displayName": "Flag User", - "status": "ACTIVE" + "value": "Flag User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333334", "code": "BRANCH_MANAGER", - "name": "Branch Manager", - "hierarchyLevel": 4, - "parentRoleId": null + "value": "Branch Manager", + "hierarchyLevel": 4 }, "profile": { - "id": "44444444-4444-4444-8444-44444444444a", "scope": "BranchScoped", "isActive": true }, "branch": { - "id": "55555555-5555-4555-8555-555555555555", "code": "CALLAO_DC", - "name": "Callao Distribution Center" - } + "value": "Callao Distribution Center" + }, + "requestedSystem": null }, + "profiles": [ + { + "id": "120cbe81-3fa8-51a0-a7a4-b7eb39d319a3", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "BRANCH_MANAGER", + "value": "Branch Manager", + "hierarchyLevel": 4 + }, + "branch": { + "code": "CALLAO_DC", + "value": "Callao Distribution Center" + }, + "scope": "BranchScoped", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -51,13 +68,11 @@ "featureFlags": [ { "flagCode": "WMS_NEW_PICKING_UI", - "systemSuiteId": "22222222-2222-4222-8222-222222222222", "isEnabled": true, "matchedCriteriaType": "BranchId" }, { "flagCode": "WMS_EXPRESS_CHECKOUT", - "systemSuiteId": "22222222-2222-4222-8222-222222222222", "isEnabled": true, "matchedCriteriaType": "RoleCode" } @@ -67,9 +82,13 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/feature-flag-missed-context.json b/src/libs/sdk/contracts/fixtures/feature-flag-missed-context.json index 1bba16e3..c79c2b00 100644 --- a/src/libs/sdk/contracts/fixtures/feature-flag-missed-context.json +++ b/src/libs/sdk/contracts/fixtures/feature-flag-missed-context.json @@ -1,39 +1,54 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0008-4f00-9a00-100000000008", "email": "missed.context@logistics-corp.example", "username": "missed.context", - "displayName": "Missed Context User", - "status": "ACTIVE" + "value": "Missed Context User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": null + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-44444444444b", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "6bd5fdd8-207a-5186-ac7c-508eaa78cdc7", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -47,7 +62,6 @@ "featureFlags": [ { "flagCode": "WMS_NEW_PICKING_UI", - "systemSuiteId": "22222222-2222-4222-8222-222222222222", "isEnabled": false, "matchedCriteriaType": null } @@ -57,9 +71,13 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/idp-auth-success.json b/src/libs/sdk/contracts/fixtures/idp-auth-success.json index 01da577a..f772c3d2 100644 --- a/src/libs/sdk/contracts/fixtures/idp-auth-success.json +++ b/src/libs/sdk/contracts/fixtures/idp-auth-success.json @@ -1,80 +1,97 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0002-4f00-9a00-100000000002", "email": "carlos.mendoza@logistics-corp.example", "username": "carlos.mendoza", - "displayName": "Carlos Mendoza", - "status": "ACTIVE" + "value": "Carlos Mendoza", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333334", "code": "BRANCH_MANAGER", - "name": "Branch Manager", - "hierarchyLevel": 4, - "parentRoleId": "33333333-3333-4333-8333-333333333330" + "value": "Branch Manager", + "hierarchyLevel": 4 }, "profile": { - "id": "44444444-4444-4444-8444-444444444445", "scope": "BranchScoped", "isActive": true }, "branch": { - "id": "55555555-5555-4555-8555-555555555555", "code": "CALLAO_DC", - "name": "Callao Distribution Center" - } + "value": "Callao Distribution Center" + }, + "requestedSystem": null }, + "profiles": [ + { + "id": "d5979fa0-2d1d-5393-a70e-524db63ac6e4", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "BRANCH_MANAGER", + "value": "Branch Manager", + "hierarchyLevel": 4 + }, + "branch": { + "code": "CALLAO_DC", + "value": "Callao Distribution Center" + }, + "scope": "BranchScoped", + "isCurrent": true + } + ], "authentication": { "method": "IDP", "provider": { - "name": "Azure AD - Logistics", "code": "AZURE_AD_LOGISTICS", - "strategy": "AZURE_AD" + "name": "Azure AD - Logistics", + "value": "AZURE_AD" }, "mfaRequired": true, "issuedAt": "2026-05-31T14:30:00Z", "sessionExpiresAt": "2026-05-31T15:30:00Z" }, "actions": [ - { "id": "a0000001-0000-4000-8000-000000000001", "code": "VIEW", "name": "View" }, - { "id": "a0000001-0000-4000-8000-000000000005", "code": "APPROVE", "name": "Approve" } + { + "code": "VIEW", + "value": "View" + }, + { + "code": "APPROVE", + "value": "Approve" + } ], "menuAccess": [], "domainPermissions": [ { - "resource": { - "id": "90000001-0000-4000-8000-000000000002", - "type": "Aggregate", - "code": "BRANCH_INVENTORY", - "name": "Branch Inventory", - "moduleId": null - }, + "resourceType": "Aggregate", + "resourceCode": "BRANCH_INVENTORY", + "value": "Branch Inventory", "actions": [ { - "actionId": "a0000001-0000-4000-8000-000000000001", "actionCode": "VIEW", - "actionName": "View", + "value": "View", "effect": "Allow", "source": "Template" }, { - "actionId": "a0000001-0000-4000-8000-000000000005", "actionCode": "APPROVE", - "actionName": "Approve", + "value": "Approve", "effect": "Allow", "source": "Override" } @@ -87,12 +104,16 @@ "maxLoginAttempts": 5, "minPasswordLength": 14, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": true }, + "settings": {}, "scopes": [ - "BRANCH_INVENTORY.VIEW", - "BRANCH_INVENTORY.APPROVE" + "branch_inventory.view", + "branch_inventory.approve" ], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/local-auth-success.json b/src/libs/sdk/contracts/fixtures/local-auth-success.json index 14fdecbf..1377ba7d 100644 --- a/src/libs/sdk/contracts/fixtures/local-auth-success.json +++ b/src/libs/sdk/contracts/fixtures/local-auth-success.json @@ -1,39 +1,54 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0001-4f00-9a00-100000000001", "email": "ana.flores@logistics-corp.example", "username": "ana.flores", - "displayName": "Ana Flores", - "status": "ACTIVE" + "value": "Ana Flores", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": "33333333-3333-4333-8333-333333333330" + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-444444444444", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "691877f6-1cb1-5fa8-a978-1ecef82a6cc9", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -42,80 +57,87 @@ "sessionExpiresAt": "2026-05-31T15:30:00Z" }, "actions": [ - { "id": "a0000001-0000-4000-8000-000000000001", "code": "VIEW", "name": "View" }, - { "id": "a0000001-0000-4000-8000-000000000002", "code": "CREATE", "name": "Create" }, - { "id": "a0000001-0000-4000-8000-000000000003", "code": "UPDATE", "name": "Update" }, - { "id": "a0000001-0000-4000-8000-000000000004", "code": "DELETE", "name": "Delete" }, - { "id": "a0000001-0000-4000-8000-000000000005", "code": "APPROVE", "name": "Approve" } + { + "code": "VIEW", + "value": "View" + }, + { + "code": "CREATE", + "value": "Create" + }, + { + "code": "UPDATE", + "value": "Update" + }, + { + "code": "DELETE", + "value": "Delete" + }, + { + "code": "APPROVE", + "value": "Approve" + } ], "menuAccess": [ { - "module": { - "id": "50000001-0000-4000-8000-000000000001", - "code": "INVENTORY", - "name": "Inventory", - "sortOrder": 1, - "status": "PUBLISHED" - }, - "menus": [ + "code": "INVENTORY", + "value": "Inventory", + "sortOrder": 1, + "status": "Active", + "icon": "layout-grid", + "nodes": [ { - "id": "60000001-0000-4000-8000-000000000001", "code": "STOCK", - "label": "Stock Management", + "value": "Stock Management", + "kind": "Menu", "sortOrder": 1, - "subMenus": [ + "actions": [], + "children": [ { - "id": "70000001-0000-4000-8000-000000000001", "code": "STOCK_OPS", - "label": "Operations", + "value": "Operations", + "kind": "SubMenu", "sortOrder": 1, - "options": [ + "actions": [], + "children": [ { - "id": "80000001-0000-4000-8000-000000000001", "code": "STOCK_VIEW", - "label": "View Stock", - "actionCode": "VIEW", - "effect": "Allow", - "source": "Template" - }, - { - "id": "80000001-0000-4000-8000-000000000002", - "code": "STOCK_ADJUST", - "label": "Adjust Stock", - "actionCode": "UPDATE", - "effect": "NotGranted", - "source": "Template" + "value": "View Stock", + "kind": "Option", + "sortOrder": 1, + "actions": [ + { + "actionCode": "VIEW", + "effect": "Allow", + "source": "Template" + } + ], + "children": [], + "icon": null, + "route": null } - ] + ], + "icon": null, + "route": null } - ] + ], + "icon": null, + "route": null } ] } ], "domainPermissions": [ { - "resource": { - "id": "90000001-0000-4000-8000-000000000001", - "type": "Aggregate", - "code": "PURCHASE_ORDER", - "name": "Purchase Order", - "moduleId": "50000001-0000-4000-8000-000000000001" - }, + "resourceType": "Aggregate", + "resourceCode": "PURCHASE_ORDER", + "value": "Purchase Order", "actions": [ { - "actionId": "a0000001-0000-4000-8000-000000000001", "actionCode": "VIEW", - "actionName": "View", + "value": "View", "effect": "Allow", "source": "Template" - }, - { - "actionId": "a0000001-0000-4000-8000-000000000005", - "actionCode": "APPROVE", - "actionName": "Approve", - "effect": "NotGranted", - "source": "Template" } ] } @@ -123,7 +145,6 @@ "featureFlags": [ { "flagCode": "WMS_BULK_EXPORT", - "systemSuiteId": "22222222-2222-4222-8222-222222222222", "isEnabled": false, "matchedCriteriaType": null } @@ -133,12 +154,16 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [ - "STOCK_VIEW.VIEW", - "PURCHASE_ORDER.VIEW" + "stock_view.view", + "purchase_order.view" ], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/multi-tenant-rejection.json b/src/libs/sdk/contracts/fixtures/multi-tenant-rejection.json index 23117f20..6b707887 100644 --- a/src/libs/sdk/contracts/fixtures/multi-tenant-rejection.json +++ b/src/libs/sdk/contracts/fixtures/multi-tenant-rejection.json @@ -1,39 +1,54 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0009-4f00-9a00-100000000009", "email": "alice@acme-retail.example", "username": "alice", - "displayName": "Alice Retail", - "status": "ACTIVE" + "value": "Alice Retail", + "status": "Active" }, "tenant": { - "id": "99999999-9999-4999-8999-999999999999", "code": "ACME_RETAIL", - "name": "Acme Retail", - "status": "ACTIVE" + "value": "Acme Retail", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "88888888-8888-4888-8888-888888888888", "code": "POS_SUITE", - "name": "Point of Sale Suite", - "status": "PUBLISHED" + "value": "Point of Sale Suite", + "status": "Active" }, "role": { - "id": "77777777-7777-4777-8777-777777777777", "code": "CASHIER", - "name": "Cashier", - "hierarchyLevel": 1, - "parentRoleId": null + "value": "Cashier", + "hierarchyLevel": 1 }, "profile": { - "id": "66666666-6666-4666-8666-666666666666", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "4a8c05fc-a7fb-503b-ad86-aac4f2141c32", + "system": { + "code": "POS_SUITE", + "value": "Point of Sale Suite" + }, + "role": { + "code": "CASHIER", + "value": "Cashier", + "hierarchyLevel": 1 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -50,12 +65,16 @@ "maxLoginAttempts": 3, "minPasswordLength": 8, "mfaRequiredForAdmin": false, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 1800000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [ - "SALE.CREATE", - "SALE.VIEW" + "sale.create", + "sale.view" ], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:00:00Z" diff --git a/src/libs/sdk/contracts/fixtures/no-profile-in-system.json b/src/libs/sdk/contracts/fixtures/no-profile-in-system.json new file mode 100644 index 00000000..e15cc93b --- /dev/null +++ b/src/libs/sdk/contracts/fixtures/no-profile-in-system.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "NoProfileInSystem", + "context": { + "user": { + "email": "ana.flores@logistics-corp.example", + "username": "ana.flores", + "value": "Ana Flores", + "status": "Active" + }, + "tenant": { + "code": "LOGISTICS_CORE", + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false + }, + "systemSuite": null, + "role": null, + "profile": null, + "branch": null, + "requestedSystem": { + "code": "TMS_SUITE" + } + }, + "profiles": [], + "authentication": { + "method": "Local", + "provider": null, + "mfaRequired": false, + "issuedAt": "2026-05-31T14:30:00Z", + "sessionExpiresAt": "2026-05-31T15:30:00Z" + }, + "actions": [], + "menuAccess": [], + "domainPermissions": [], + "featureFlags": [], + "effectiveConfig": { + "sessionTimeoutMinutes": 60, + "maxLoginAttempts": 5, + "minPasswordLength": 12, + "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], + "accessTokenDurationMs": 3600000, + "authUseExternalIdp": false + }, + "settings": {}, + "scopes": [], + "generatedAt": "2026-05-31T14:30:00Z", + "validUntil": "2026-05-31T15:30:00Z" +} diff --git a/src/libs/sdk/contracts/fixtures/override-allow.json b/src/libs/sdk/contracts/fixtures/override-allow.json index a027e4a9..1728c955 100644 --- a/src/libs/sdk/contracts/fixtures/override-allow.json +++ b/src/libs/sdk/contracts/fixtures/override-allow.json @@ -1,39 +1,54 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-0004-4f00-9a00-100000000004", "email": "override.test@logistics-corp.example", "username": "override.test", - "displayName": "Override Test User", - "status": "ACTIVE" + "value": "Override Test User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": null + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-444444444447", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "2e84b15c-b7fe-5818-a985-480090a6f018", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -42,23 +57,21 @@ "sessionExpiresAt": "2026-05-31T15:30:00Z" }, "actions": [ - { "id": "a0000001-0000-4000-8000-000000000005", "code": "APPROVE", "name": "Approve" } + { + "code": "APPROVE", + "value": "Approve" + } ], "menuAccess": [], "domainPermissions": [ { - "resource": { - "id": "90000001-0000-4000-8000-000000000001", - "type": "Aggregate", - "code": "PURCHASE_ORDER", - "name": "Purchase Order", - "moduleId": null - }, + "resourceType": "Aggregate", + "resourceCode": "PURCHASE_ORDER", + "value": "Purchase Order", "actions": [ { - "actionId": "a0000001-0000-4000-8000-000000000005", "actionCode": "APPROVE", - "actionName": "Approve", + "value": "Approve", "effect": "Allow", "source": "Override" } @@ -71,11 +84,15 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [ - "PURCHASE_ORDER.APPROVE" + "purchase_order.approve" ], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/schema-minor-ahead.json b/src/libs/sdk/contracts/fixtures/schema-minor-ahead.json index b4e75dc4..f9c0498c 100644 --- a/src/libs/sdk/contracts/fixtures/schema-minor-ahead.json +++ b/src/libs/sdk/contracts/fixtures/schema-minor-ahead.json @@ -1,40 +1,54 @@ { - "schemaVersion": "1.99.0", + "schemaVersion": "2.99.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-000b-4f00-9a00-10000000000b", "email": "minor.ahead@logistics-corp.example", "username": "minor.ahead", - "displayName": "Minor Ahead User", - "status": "ACTIVE", - "locale": "es-PE" + "value": "Minor Ahead User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": null + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-44444444444d", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "3f79a458-95bc-54df-adbf-ad638f55ec4e", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -51,17 +65,14 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [], "generatedAt": "2026-05-31T14:30:00Z", - "validUntil": "2026-05-31T15:30:00Z", - "delegations": [ - { - "delegationId": "ee000000-0000-4000-8000-000000000001", - "delegatedFrom": "manager.user@example.com", - "validUntil": "2026-06-30T23:59:59Z" - } - ] + "validUntil": "2026-05-31T15:30:00Z" } diff --git a/src/libs/sdk/contracts/fixtures/schema-missing.json b/src/libs/sdk/contracts/fixtures/schema-missing.json index 15e253f9..a1de2f7f 100644 --- a/src/libs/sdk/contracts/fixtures/schema-missing.json +++ b/src/libs/sdk/contracts/fixtures/schema-missing.json @@ -1,38 +1,53 @@ { + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-000c-4f00-9a00-10000000000c", "email": "legacy@logistics-corp.example", "username": "legacy", - "displayName": "Legacy User", - "status": "ACTIVE" + "value": "Legacy User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": null + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-44444444444e", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "775f159b-2c83-506d-aabd-7aa0689aa1ad", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -49,9 +64,13 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/schema-unsupported-major.json b/src/libs/sdk/contracts/fixtures/schema-unsupported-major.json index 790464b0..60bc0770 100644 --- a/src/libs/sdk/contracts/fixtures/schema-unsupported-major.json +++ b/src/libs/sdk/contracts/fixtures/schema-unsupported-major.json @@ -1,39 +1,54 @@ { - "schemaVersion": "2.0.0", + "schemaVersion": "3.0.0", + "onboardingPending": false, + "accessState": "Granted", "context": { "user": { - "id": "7a1d4e22-000a-4f00-9a00-10000000000a", "email": "future.user@logistics-corp.example", "username": "future.user", - "displayName": "Future User", - "status": "ACTIVE" + "value": "Future User", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "LOGISTICS_CORE", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": null + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-44444444444c", "scope": "OrgWide", "isActive": true }, - "branch": null + "branch": null, + "requestedSystem": null }, + "profiles": [ + { + "id": "d9755356-86b1-55b7-adb6-31d0d93df2e7", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], "authentication": { "method": "Local", "provider": null, @@ -50,9 +65,13 @@ "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, + "settings": {}, "scopes": [], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" diff --git a/src/libs/sdk/contracts/fixtures/system-filtered-single-profile.json b/src/libs/sdk/contracts/fixtures/system-filtered-single-profile.json new file mode 100644 index 00000000..7b60cb32 --- /dev/null +++ b/src/libs/sdk/contracts/fixtures/system-filtered-single-profile.json @@ -0,0 +1,172 @@ +{ + "schemaVersion": "2.4.0", + "onboardingPending": false, + "accessState": "Granted", + "context": { + "user": { + "email": "bruno.salas@logistics-corp.example", + "username": "bruno.salas", + "value": "Bruno Salas", + "status": "Active" + }, + "tenant": { + "code": "LOGISTICS_CORE", + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": false + }, + "systemSuite": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite", + "status": "Active" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "profile": { + "scope": "OrgWide", + "isActive": true + }, + "branch": null, + "requestedSystem": { + "code": "WMS_SUITE" + } + }, + "profiles": [ + { + "id": "c4d9e1f2-6a3b-5c7d-a8e9-0f1b2c3d4e5f", + "system": { + "code": "WMS_SUITE", + "value": "Warehouse Management Suite" + }, + "role": { + "code": "WAREHOUSE_SUPERVISOR", + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 + }, + "branch": null, + "scope": "OrgWide", + "isCurrent": true + } + ], + "authentication": { + "method": "Local", + "provider": null, + "mfaRequired": false, + "issuedAt": "2026-05-31T14:30:00Z", + "sessionExpiresAt": "2026-05-31T15:30:00Z" + }, + "actions": [ + { + "code": "VIEW", + "value": "View" + }, + { + "code": "CREATE", + "value": "Create" + }, + { + "code": "UPDATE", + "value": "Update" + }, + { + "code": "DELETE", + "value": "Delete" + }, + { + "code": "APPROVE", + "value": "Approve" + } + ], + "menuAccess": [ + { + "code": "INVENTORY", + "value": "Inventory", + "sortOrder": 1, + "status": "Active", + "icon": "layout-grid", + "nodes": [ + { + "code": "STOCK", + "value": "Stock Management", + "kind": "Menu", + "sortOrder": 1, + "actions": [], + "children": [ + { + "code": "STOCK_OPS", + "value": "Operations", + "kind": "SubMenu", + "sortOrder": 1, + "actions": [], + "children": [ + { + "code": "STOCK_VIEW", + "value": "View Stock", + "kind": "Option", + "sortOrder": 1, + "actions": [ + { + "actionCode": "VIEW", + "effect": "Allow", + "source": "Template" + } + ], + "children": [], + "icon": null, + "route": null + } + ], + "icon": null, + "route": null + } + ], + "icon": null, + "route": null + } + ] + } + ], + "domainPermissions": [ + { + "resourceType": "Aggregate", + "resourceCode": "PURCHASE_ORDER", + "value": "Purchase Order", + "actions": [ + { + "actionCode": "VIEW", + "value": "View", + "effect": "Allow", + "source": "Template" + } + ] + } + ], + "featureFlags": [ + { + "flagCode": "WMS_BULK_EXPORT", + "isEnabled": false, + "matchedCriteriaType": null + } + ], + "effectiveConfig": { + "sessionTimeoutMinutes": 60, + "maxLoginAttempts": 5, + "minPasswordLength": 12, + "mfaRequiredForAdmin": true, + "mfaAllowedMethods": [ + "Totp" + ], + "accessTokenDurationMs": 3600000, + "authUseExternalIdp": false + }, + "settings": {}, + "scopes": [ + "stock_view.view", + "purchase_order.view" + ], + "generatedAt": "2026-05-31T14:30:00Z", + "validUntil": "2026-05-31T15:30:00Z" +} diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Aop/Ums.Sdk.Authorization.Aop.csproj b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Aop/Ums.Sdk.Authorization.Aop.csproj index c0dc709e..8bf0dc59 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Aop/Ums.Sdk.Authorization.Aop.csproj +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Aop/Ums.Sdk.Authorization.Aop.csproj @@ -6,14 +6,7 @@ enable true Ums.Sdk.Authorization.Aop - 1.0.1 - - - https://github.com/beyondnetcode/ums - git - true + 1.0.0 UMS SDK — Declarative authorization attributes ([RequiresScope], [RequiresMenuOption], [RequiresDomainAccess], [RequiresFeatureFlag]) and DispatchProxy aspect built on BeyondNetCode.Shell.Aop. See ADR-0073. BeyondNetCode https://github.com/beyondnetcode/ums @@ -36,7 +29,7 @@ - + diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/CacheDeJwks.cs b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/CacheDeJwks.cs new file mode 100644 index 00000000..e3138f52 --- /dev/null +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/CacheDeJwks.cs @@ -0,0 +1,372 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace Ums.Sdk.Authorization.AspNetCore; + +///

Cómo terminó la búsqueda de una clave. Distingue «no está» de «no pude mirar». +public enum EstadoDeClave +{ + /// La clave se encontró y es utilizable. + Ok, + + /// El kid no está entre las claves que publica el emisor. + Desconocida, + + /// No se pudo consultar el JWKS y no hay copia previa. + Inaccesible, +} + +/// Resultado de buscar una clave por su kid. +/// Cómo terminó la búsqueda. +/// Parámetros públicos de la clave RSA, si se encontró. +public sealed record ResultadoDeClave(EstadoDeClave Estado, RSAParameters? Clave); + +/// +/// Caché del JWKS del emisor: las claves públicas con las que se verifica un portador RS256. +/// +/// +/// +/// Existe por dos razones que tiran en direcciones opuestas y hay que conciliar: +/// +/// +/// 1. No se puede ir a la red en cada verificación. La verificación está en la ruta caliente +/// de cada petición; un GET al JWKS por token convertiría al emisor en el cuello de botella de todo +/// el ecosistema, y en su punto único de fallo. +/// +/// +/// 2. Las claves rotan. Con una caché eterna, una rotación dejaría a todos los satélites +/// rechazando tokens legítimos hasta que alguien los reiniciara. ADR-0157 §4.4 permite el solape +/// para que la rotación no sea un corte, pero el solape solo sirve si el consumidor va a buscar la +/// clave nueva cuando la ve. +/// +/// +/// La conciliación es traer el JWKS cuando aparece un kid desconocido. Y ahí está el riesgo +/// que esta clase existe para acotar: ese refresco lo dispara un desconocido, porque el +/// kid viaja en la cabecera de un token todavía sin verificar. Sin límite, basta con mandar +/// tokens con kid inventados —uno distinto cada vez— para que el satélite bombardee al +/// emisor: un amplificador de denegación de servicio construido con nuestras propias manos. +/// +/// +/// Por eso el refresco está acotado por tres mecanismos que se complementan: cadencia mínima +/// entre refrescos disparados por un kid desconocido, memoria de lo ya descartado para +/// que repetir el mismo kid falso no consuma esa cadencia, y una sola petición en vuelo +/// para que N verificaciones concurrentes no produzcan N peticiones. +/// +/// +public sealed class CacheDeJwks : IDisposable +{ + /// + /// Tamaño mínimo de clave RSA aceptado, en bytes del módulo (2048 bits). + /// + /// + /// Es el mismo suelo que UMS se impone al arrancar (ADR-0157 §4.7). Se repite aquí porque el + /// consumidor no puede dar por hecho que el otro extremo lo cumple: una clave corta verifica + /// sin error y sin aviso, y deja el sistema débil sin que nada falle. + /// + private const int BytesMinimosDeModulo = 256; + + private const int MaxBytes = 512 * 1024; + + private readonly Uri uri; + private readonly HttpClient http; + private readonly bool httpEsPropio; + private readonly TimeSpan ttl; + private readonly TimeSpan esperaEntreRefrescos; + private readonly TimeSpan memoriaDeDescartados; + private readonly TimeSpan tiempoDeEspera; + private readonly Func reloj; + private readonly SemaphoreSlim cerrojo = new(1, 1); + + private Dictionary claves = []; + private DateTimeOffset traidoEn = DateTimeOffset.MinValue; + + /// + /// Momento del último refresco disparado por un kid desconocido con la copia fresca. + /// + /// + /// Se lleva aparte del refresco por caducidad a propósito: si un único contador gobernara los + /// dos, el refresco del arranque —programado, nuestro, inevitable— gastaría el presupuesto del + /// siguiente, y una rotación legítima esperaría la ventana entera sin motivo. El presupuesto + /// que hay que racionar es solo el que puede disparar un desconocido. + /// + private DateTimeOffset ultimoRefrescoPorKid = DateTimeOffset.MinValue; + + /// Momento del último intento fallido. Impide martillear un emisor caído. + private DateTimeOffset ultimoFallo = DateTimeOffset.MinValue; + + private readonly Dictionary kidsDescartados = []; + + /// Construye la caché. + /// Ajustes del JWKS. + public CacheDeJwks(OpcionesDeJwks opciones) + { + ArgumentNullException.ThrowIfNull(opciones); + if (string.IsNullOrWhiteSpace(opciones.Uri)) + { + throw new ArgumentException("Falta la URI del JWKS del emisor.", nameof(opciones)); + } + + uri = new Uri(opciones.Uri, UriKind.Absolute); + httpEsPropio = opciones.HttpClient is null; + http = opciones.HttpClient ?? new HttpClient(); + ttl = opciones.Ttl; + esperaEntreRefrescos = opciones.EsperaEntreRefrescos; + memoriaDeDescartados = opciones.MemoriaDeDescartados; + tiempoDeEspera = opciones.TiempoDeEspera; + reloj = opciones.Reloj ?? (() => DateTimeOffset.UtcNow); + } + + /// + /// Devuelve la clave de ese kid, trayendo el JWKS solo si hace falta y solo si toca. + /// + /// + /// Nunca devuelve «alguna» clave: sin kid que case, se rechaza. Probar el token contra + /// todas las claves parece inofensivo —o firma o no firma— pero convierte cada rotación en una + /// ventana en la que una clave retirada sigue siendo válida, y borra la única señal que dice + /// con qué clave se emitió realmente. + /// + /// Identificador de la clave, tomado de la cabecera del token. + /// Cancelación de la petición en curso. + /// La clave, o el motivo por el que no se pudo dar. + public async Task ClaveParaAsync(string kid, CancellationToken cancelacion = default) + { + if (string.IsNullOrEmpty(kid)) return new ResultadoDeClave(EstadoDeClave.Desconocida, null); + + await cerrojo.WaitAsync(cancelacion).ConfigureAwait(false); + try + { + var ahora = reloj(); + + // Lo ya descartado se responde sin red y sin gastar la cadencia. Es lo que impide que + // un `kid` falso repetido monopolice el refresco que necesita una rotación de verdad. + if (kidsDescartados.TryGetValue(kid, out var descartadoEn) + && ahora - descartadoEn < memoriaDeDescartados) + { + return new ResultadoDeClave(EstadoDeClave.Desconocida, null); + } + + var hayCopia = traidoEn != DateTimeOffset.MinValue; + var copiaFresca = hayCopia && ahora - traidoEn < ttl; + var estaba = claves.TryGetValue(kid, out var enCache); + if (estaba && copiaFresca) return new ResultadoDeClave(EstadoDeClave.Ok, enCache); + + // Hay que ir a la red, y el motivo cambia quién está al mando: + // - Copia fría o caducada: lo dispara el reloj, no quien llama. Nadie de fuera puede + // acelerar ese reloj, así que como mucho ocurre una vez por TTL. + // - Copia fresca y `kid` sin conocer: lo dispara la cabecera de un token SIN VERIFICAR. + // Es el caso que hay que racionar, y el único. + var loDisparaUnDesconocido = copiaFresca; + var refrescado = await RefrescarAsync(ahora, loDisparaUnDesconocido, cancelacion).ConfigureAwait(false); + + if (claves.TryGetValue(kid, out var trasRefresco)) + { + return new ResultadoDeClave(EstadoDeClave.Ok, trasRefresco); + } + + if (refrescado) + { + // Se miró de verdad y no estaba: se recuerda, para no volver a mirar por este `kid`. + kidsDescartados[kid] = reloj(); + PodarDescartados(); + return new ResultadoDeClave(EstadoDeClave.Desconocida, null); + } + + // Copia caducada y refresco frenado: se sirve lo viejo antes que rechazar un token + // legítimo por un problema de red. La clave sigue siendo del emisor, solo sin revalidar. + if (estaba) return new ResultadoDeClave(EstadoDeClave.Ok, enCache); + + return new ResultadoDeClave( + hayCopia ? EstadoDeClave.Desconocida : EstadoDeClave.Inaccesible, null); + } + finally + { + _ = cerrojo.Release(); + } + } + + /// Libera el propio, si lo hay. + public void Dispose() + { + cerrojo.Dispose(); + if (httpEsPropio) http.Dispose(); + } + + /// + /// Trae el JWKS si toca. El cerrojo de ya garantiza que solo hay + /// una petición en vuelo: N verificaciones concurrentes con la caché fría producen UNA + /// petición, no N, que es el caso del arranque de un satélite con tráfico encima. + /// + private async Task RefrescarAsync( + DateTimeOffset ahora, bool loDisparaUnDesconocido, CancellationToken cancelacion) + { + if (loDisparaUnDesconocido + && ultimoRefrescoPorKid != DateTimeOffset.MinValue + && ahora - ultimoRefrescoPorKid < esperaEntreRefrescos) + { + return false; + } + + // Si el emisor acaba de fallar, no se insiste: reintentar sin freno contra algo caído + // convierte una caída ajena en una propia. + if (ultimoFallo != DateTimeOffset.MinValue && ahora - ultimoFallo < esperaEntreRefrescos) + { + return false; + } + + if (loDisparaUnDesconocido) ultimoRefrescoPorKid = ahora; + + var ok = await TraerAsync(cancelacion).ConfigureAwait(false); + if (!ok) ultimoFallo = reloj(); + return ok; + } + + private async Task TraerAsync(CancellationToken cancelacion) + { + try + { + using var limite = CancellationTokenSource.CreateLinkedTokenSource(cancelacion); + limite.CancelAfter(tiempoDeEspera); + + using var peticion = new HttpRequestMessage(HttpMethod.Get, uri); + peticion.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + + using var respuesta = await http.SendAsync(peticion, limite.Token).ConfigureAwait(false); + if (!respuesta.IsSuccessStatusCode) return false; + + var texto = await respuesta.Content.ReadAsStringAsync(limite.Token).ConfigureAwait(false); + + // Un JWKS legítimo son unos cientos de bytes. Un documento enorme aquí es un error de + // enrutado o algo peor, y no se gana nada intentando interpretarlo. + if (texto.Length > MaxBytes) return false; + + using var documento = JsonDocument.Parse(texto); + if (!documento.RootElement.TryGetProperty("keys", out var listaDeClaves) + || listaDeClaves.ValueKind != JsonValueKind.Array) + { + return false; + } + + var nuevas = new Dictionary(StringComparer.Ordinal); + foreach (var jwk in listaDeClaves.EnumerateArray()) + { + if (Convertir(jwk) is (string kid, RSAParameters parametros)) nuevas[kid] = parametros; + } + + // Solo se reemplaza si se obtuvo algo utilizable: un JWKS vacío o ilegible no debe + // borrar las claves buenas que ya se tenían. + if (nuevas.Count == 0) return false; + + claves = nuevas; + traidoEn = reloj(); + + // Una rotación puede reintroducir un `kid` que antes no estaba; la memoria de + // descartados dejaría de decir la verdad, así que se limpia junto con la copia. + kidsDescartados.Clear(); + return true; + } + catch (Exception error) when ( + error is HttpRequestException or TaskCanceledException or OperationCanceledException + or JsonException or FormatException or UriFormatException) + { + // Cualquier fallo —red, tiempo agotado, JSON ilegible, HTML del enrutador equivocado— + // es un refresco que no ocurrió. Nunca una excepción hacia arriba: quien verifica + // decide qué hacer con la ausencia de clave, y lo hace con un rechazo. + return false; + } + } + + /// + /// Convierte un JWK en parámetros RSA, descartando lo que no sirva. + /// + /// + /// Solo pasan claves RSA de firma: aceptar una simétrica desde un documento público sería + /// exactamente la degradación que todo esto quiere evitar —el emisor publicaría, sin querer, + /// con qué firmar—. + /// + private static (string, RSAParameters)? Convertir(JsonElement jwk) + { + if (jwk.ValueKind != JsonValueKind.Object) return null; + if (!Texto(jwk, "kty").Equals("RSA", StringComparison.Ordinal)) return null; + + var uso = Texto(jwk, "use"); + if (uso.Length > 0 && !uso.Equals("sig", StringComparison.Ordinal)) return null; + + var kid = Texto(jwk, "kid"); + if (kid.Length == 0) return null; + + // Si la clave declara algoritmo, es el que manda. Si no lo declara, RS256 es lo único que + // UMS emite (ADR-0157) y lo único que este verificador sabe comprobar. + var alg = Texto(jwk, "alg"); + if (alg.Length > 0 && !alg.Equals("RS256", StringComparison.Ordinal)) return null; + + var n = Texto(jwk, "n"); + var e = Texto(jwk, "e"); + if (n.Length == 0 || e.Length == 0) return null; + + try + { + var modulo = Base64UrlDecode(n); + if (modulo.Length < BytesMinimosDeModulo) return null; + + return (kid, new RSAParameters { Modulus = modulo, Exponent = Base64UrlDecode(e) }); + } + catch (FormatException) + { + return null; + } + } + + private static string Texto(JsonElement objeto, string propiedad) => + objeto.TryGetProperty(propiedad, out var valor) && valor.ValueKind == JsonValueKind.String + ? valor.GetString() ?? string.Empty + : string.Empty; + + private static byte[] Base64UrlDecode(string input) + { + var normalized = input.Replace('-', '+').Replace('_', '/'); + switch (normalized.Length % 4) + { + case 2: normalized += "=="; break; + case 3: normalized += "="; break; + case 1: throw new FormatException("Invalid base64url length."); + default: break; + } + + return Convert.FromBase64String(normalized); + } + + private void PodarDescartados() + { + var ahora = reloj(); + var caducados = kidsDescartados + .Where(par => ahora - par.Value >= memoriaDeDescartados) + .Select(par => par.Key) + .ToList(); + foreach (var kid in caducados) _ = kidsDescartados.Remove(kid); + } +} + +/// Ajustes de la caché del JWKS. +public sealed class OpcionesDeJwks +{ + /// URI absoluta del JWKS del emisor. + public string Uri { get; set; } = string.Empty; + + /// Cliente HTTP. Se inyecta para poder probar sin red. + public HttpClient? HttpClient { get; set; } + + /// Cuánto se considera fresca la copia. Por defecto 10 min, el max-age de UMS. + public TimeSpan Ttl { get; set; } = TimeSpan.FromMinutes(10); + + /// Cadencia mínima entre refrescos disparados por un kid desconocido. + public TimeSpan EsperaEntreRefrescos { get; set; } = TimeSpan.FromSeconds(60); + + /// Cuánto se recuerda un kid que no estaba en el JWKS. + public TimeSpan MemoriaDeDescartados { get; set; } = TimeSpan.FromMinutes(10); + + /// Tiempo máximo de la petición al JWKS. + public TimeSpan TiempoDeEspera { get; set; } = TimeSpan.FromSeconds(5); + + /// Reloj inyectable, para que las pruebas no dependan del paso real del tiempo. + public Func? Reloj { get; set; } +} diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/README.md b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/README.md index 28c9e4b1..abc8dd93 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/README.md +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/README.md @@ -21,6 +21,9 @@ var app = builder.Build(); // Decodes JWT body → AuthorizationGraph → HttpContext.Items. app.UseUmsAuthGraph(options => { + // Obligatorio: sin el secreto no se puede comprobar que el portador lo firmara UMS, y el + // grafo de autorizacion seria el que quisiera quien llama (G-217). + options.SigningSecret = builder.Configuration["Jwt:Secret"]!; options.JwtBodyClaim = "graph"; // claim name carrying the serialized graph options.RejectExpiredGraphs = true; }); diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/Ums.Sdk.Authorization.AspNetCore.csproj b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/Ums.Sdk.Authorization.AspNetCore.csproj index ca7ad2c8..a21c079d 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/Ums.Sdk.Authorization.AspNetCore.csproj +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/Ums.Sdk.Authorization.AspNetCore.csproj @@ -6,14 +6,7 @@ enable true Ums.Sdk.Authorization.AspNetCore - 1.0.1 - - - https://github.com/beyondnetcode/ums - git - true + 1.0.0 UMS SDK — ASP.NET Core integration: UseUmsAuthGraph middleware that decodes the per-request authorization graph and stores it on HttpContext.Items for the validator/aspect to consume. See ADR-0073. BeyondNetCode https://github.com/beyondnetcode/ums diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/UmsAuthGraphMiddleware.cs b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/UmsAuthGraphMiddleware.cs index 018004b0..b910105a 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/UmsAuthGraphMiddleware.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/UmsAuthGraphMiddleware.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; @@ -6,6 +7,7 @@ using Ums.Sdk.Authorization; using Ums.Sdk.Contracts; + namespace Ums.Sdk.Authorization.AspNetCore; /// @@ -29,6 +31,12 @@ public sealed class UmsAuthGraphMiddleware private readonly IOptions _options; private readonly ILogger? _logger; + /// + /// Caché del JWKS del emisor. Se crea UNA vez, con el middleware, porque solo sirve de algo si + /// sobrevive entre peticiones: creada por petición haría un GET al emisor por cada token. + /// + private readonly CacheDeJwks? _jwks; + public UmsAuthGraphMiddleware( RequestDelegate next, IOptions options, @@ -37,12 +45,20 @@ public UmsAuthGraphMiddleware( _next = next; _options = options; _logger = logger; + + ArgumentNullException.ThrowIfNull(options); + var uriDelJwks = options.Value.ResolverJwksUri(); + if (uriDelJwks.Length > 0) + { + options.Value.Jwks.Uri = uriDelJwks; + _jwks = new CacheDeJwks(options.Value.Jwks); + } } public async Task InvokeAsync(HttpContext context) { var opt = _options.Value; - var graph = ExtractGraph(context, opt); + var graph = await ExtractGraphAsync(context, opt).ConfigureAwait(false); if (graph is not null) { @@ -79,14 +95,23 @@ await Reject(context, "AUTH_205", await _next(context).ConfigureAwait(false); } - private AuthorizationGraph? ExtractGraph(HttpContext context, UmsAuthGraphMiddlewareOptions opt) + private async Task ExtractGraphAsync(HttpContext context, UmsAuthGraphMiddlewareOptions opt) { string? token = ExtractBearerToken(context); if (token is null) return null; - // Three-segment compact JWT. + // Tres segmentos: cabecera, cuerpo y FIRMA. El tercero es el que hasta G-217 nadie miraba. var segments = token.Split('.'); - if (segments.Length < 2) return null; + if (segments.Length != 3) return null; + + // La firma PRIMERO. Sin esto, todo lo que viene después —el grafo, la versión de esquema, + // la caducidad— se comprueba sobre un contenido que cualquiera pudo escribir. + if (!await FirmaValidaAsync(segments, opt, context.RequestAborted).ConfigureAwait(false)) + { + _logger?.LogWarning( + "UmsAuthGraphMiddleware: el portador no supera la verificación de firma; se descarta (G-217)."); + return null; + } byte[] payload; try @@ -139,6 +164,106 @@ await Reject(context, "AUTH_205", return token.Length == 0 ? null : token; } + /// + /// Comprueba que el portador lo firmó quien dice. + /// + /// + /// El algoritmo del token no decide cómo se comprueba. Solo elige entre las vías que + /// el consumidor habilitó por configuración: RS256 si declaró el JWKS, HS256 si declaró el + /// secreto. Aceptar lo que el token declare es la confusión de algoritmo clásica —basta con + /// declarar none para que la firma deje de comprobarse—, y por eso none se + /// rechaza explícitamente y cualquier otro algoritmo cae por no estar habilitado. + /// Un satélite configurado solo para RS256 rechaza un token HS256 aunque el token insista: + /// esa es la degradación que no puede ocurrir cuando las dos vías conviven. + /// + private async Task FirmaValidaAsync( + string[] segments, UmsAuthGraphMiddlewareOptions opt, CancellationToken cancelacion) + { + var aceptaHs256 = !string.IsNullOrEmpty(opt.SigningSecret); + var aceptaRs256 = _jwks is not null; + + // Sin material de verificación no se acepta nada. Es justamente la omisión de G-217. + if (!aceptaHs256 && !aceptaRs256) return false; + + string alg; + string kid; + try + { + using var cabeceraJson = JsonDocument.Parse(Base64UrlDecode(segments[0])); + alg = cabeceraJson.RootElement.TryGetProperty("alg", out var a) ? a.GetString() ?? string.Empty : string.Empty; + kid = cabeceraJson.RootElement.TryGetProperty("kid", out var k) ? k.GetString() ?? string.Empty : string.Empty; + } + catch (Exception error) when (error is JsonException or FormatException) + { + return false; + } + + // `none` se rechaza aquí, explícitamente. Caería igual por no estar habilitado, pero se + // deja escrito: es LA manera de desactivar la firma sin tocarla, y debe verse contemplada. + if (alg.Length == 0 || string.Equals(alg, "none", StringComparison.OrdinalIgnoreCase)) return false; + + var contenidoFirmado = Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"); + + if (string.Equals(alg, "RS256", StringComparison.Ordinal)) + { + return aceptaRs256 + && await Rs256ValidaAsync(kid, contenidoFirmado, segments[2], cancelacion).ConfigureAwait(false); + } + + if (string.Equals(alg, "HS256", StringComparison.Ordinal)) + { + return aceptaHs256 && Hs256Valida(contenidoFirmado, segments[2], opt.SigningSecret); + } + + return false; + } + + private async Task Rs256ValidaAsync( + string kid, byte[] contenidoFirmado, string firmaB64, CancellationToken cancelacion) + { + // Sin `kid` no se verifica. La alternativa sería probar contra todas las claves publicadas, + // y eso convierte cada rotación en una ventana donde la clave retirada sigue valiendo. + if (kid.Length == 0) return false; + + var buscada = await _jwks!.ClaveParaAsync(kid, cancelacion).ConfigureAwait(false); + if (buscada.Estado != EstadoDeClave.Ok || buscada.Clave is null) return false; + + try + { + using var rsa = RSA.Create(); + rsa.ImportParameters(buscada.Clave.Value); + return rsa.VerifyData( + contenidoFirmado, + Base64UrlDecode(firmaB64), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + } + catch (Exception error) when (error is CryptographicException or FormatException) + { + return false; + } + } + + /// + /// HS256 en tiempo constante: comparar byte a byte con salida temprana filtra, por lo que tarda + /// en fallar, cuántos bytes acertó quien lo intenta. + /// + private static bool Hs256Valida(byte[] contenidoFirmado, string firmaB64, string secreto) + { + try + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secreto)); + var esperada = hmac.ComputeHash(contenidoFirmado); + var recibida = Base64UrlDecode(firmaB64); + + return CryptographicOperations.FixedTimeEquals(recibida, esperada); + } + catch (FormatException) + { + return false; + } + } + private static byte[] Base64UrlDecode(string input) { var normalized = input.Replace('-', '+').Replace('_', '/'); diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/UmsAuthGraphMiddlewareOptions.cs b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/UmsAuthGraphMiddlewareOptions.cs index 057d898a..446f2dc8 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/UmsAuthGraphMiddlewareOptions.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.AspNetCore/UmsAuthGraphMiddlewareOptions.cs @@ -3,11 +3,40 @@ namespace Ums.Sdk.Authorization.AspNetCore; /// Behavior knobs for . public sealed class UmsAuthGraphMiddlewareOptions { + /// + /// Secreto con el que UMS firma sus portadores en HS256. + /// + /// + /// Ya no es lo habitual: desde ADR-0157 UMS firma en RS256 y publica su clave pública, así + /// que lo normal es declarar y no custodiar secreto ninguno. Sigue + /// existiendo para los despliegues que todavía no han cruzado. + /// Hay que declarar al menos uno: este o /. + /// Sin ninguno, el middleware descarta todo portador: sin material de verificación no puede + /// distinguir uno legítimo de uno fabricado, que es el agujero de G-217. + /// + public string SigningSecret { get; set; } = string.Empty; + + /// + /// Base de UMS. De ella sale su JWKS ({base}/.well-known/jwks.json), y declararla + /// habilita la verificación RS256. + /// + /// + /// Declarar solo esto es lo deseable: el satélite verifica sin custodiar material de firma, y + /// por tanto no puede emitir portadores aunque quisiera. + /// + public string UmsBaseUrl { get; set; } = string.Empty; + + /// URI del JWKS, si no cuelga de en la ruta estándar. + public string JwksUri { get; set; } = string.Empty; + + /// Ajustes finos de la caché del JWKS (TTL, cadencia de refresco, tiempo de espera). + public OpcionesDeJwks Jwks { get; set; } = new(); + /// JWT body claim that carries the serialized graph. Default: "graph". public string JwtBodyClaim { get; set; } = "graph"; /// When true, the middleware returns 401 + AUTH_201 when the bound graph is expired. - public bool RejectExpiredGraphs { get; set; } = false; + public bool RejectExpiredGraphs { get; set; } /// /// When true, the middleware returns 401 + AUTH_204/AUTH_205 on missing or incompatible @@ -15,4 +44,13 @@ public sealed class UmsAuthGraphMiddlewareOptions /// proceeds without authorization context — downstream code will see Current = null. /// public bool RejectIncompatibleGraphs { get; set; } = true; + + /// Resuelve la URI efectiva del JWKS, o cadena vacía si no se configuró ninguna. + /// La URI del JWKS. + internal string ResolverJwksUri() + { + if (!string.IsNullOrWhiteSpace(JwksUri)) return JwksUri; + if (string.IsNullOrWhiteSpace(UmsBaseUrl)) return string.Empty; + return $"{UmsBaseUrl.TrimEnd('/')}/.well-known/jwks.json"; + } } diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Testing/AuthGraphBuilder.cs b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Testing/AuthGraphBuilder.cs index df899d93..bfe6c4ec 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Testing/AuthGraphBuilder.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Testing/AuthGraphBuilder.cs @@ -86,18 +86,17 @@ public AuthorizationGraph Build() var validUntil = now.Add(_validity); var user = new UserSummary( - Guid.NewGuid(), _userEmail, _userEmail.Split('@')[0], _userEmail, - "ACTIVE"); + "Active"); - var tenant = new TenantSummary(Guid.NewGuid(), _tenantCode, _tenantName, "ACTIVE"); - var suite = new SystemSuiteSummary(Guid.NewGuid(), _systemSuiteCode, _systemSuiteCode, "PUBLISHED"); - var role = new RoleSummary(Guid.NewGuid(), _roleCode, _roleCode, 1, null); - var profile = new ProfileSummary(Guid.NewGuid(), _profileScope, true); + var tenant = new TenantSummary(_tenantCode, _tenantName, "Active", false); + var suite = new SystemSuiteSummary(_systemSuiteCode, _systemSuiteCode, "Active"); + var role = new RoleSummary(_roleCode, _roleCode, 1); + var profile = new ProfileSummary(_profileScope, true); BranchSummary? branch = _branchScoped - ? new BranchSummary(Guid.NewGuid(), _branchCode, _branchCode) + ? new BranchSummary(_branchCode, _branchCode) : null; var context = new PrincipalContext(user, tenant, suite, role, profile, branch); @@ -107,20 +106,23 @@ public AuthorizationGraph Build() var menuAccess = BuildMenuAccess(actions); var domainPermissions = BuildDomainPermissions(actions); var featureFlags = _flags - .Select(f => new FeatureFlagState(f.FlagCode, suite.Id, f.Enabled, f.MatchedCriteriaType)) + .Select(f => new FeatureFlagState(f.FlagCode, f.Enabled, f.MatchedCriteriaType)) .ToList(); - var effectiveConfig = new EffectiveConfig(60, 5, 12, true, 3600000, _authMethod == "IDP"); + var effectiveConfig = new EffectiveConfig(60, 5, 12, true, new[] { "Totp" }, 3600000, _authMethod == "IDP"); return new AuthorizationGraph( _schemaVersion, + false, context, authentication, actions, + [], // profiles: el constructor de pruebas no modela multi-perfil menuAccess, domainPermissions, featureFlags, effectiveConfig, + new Dictionary>(), // settings _scopes.Distinct(StringComparer.Ordinal).ToList(), now, validUntil); @@ -137,21 +139,26 @@ private List BuildMenuAccess(List actions) { if (_menuOptions.Count == 0) return new(); - var moduleId = Guid.NewGuid(); - var menuId = Guid.NewGuid(); - var subId = Guid.NewGuid(); - var options = _menuOptions.Select(m => - { - var existing = actions.FirstOrDefault(a => a.Code == m.ActionCode); - var actionRef = existing ?? new ActionRef(Guid.NewGuid(), m.ActionCode, m.ActionCode); - if (existing is null) actions.Add(actionRef); - return new MenuOption(Guid.NewGuid(), m.OptionCode, m.OptionCode, m.ActionCode, m.Effect, m.Source); - }).ToList(); - - var submenu = new SubMenu(subId, "TEST_SUB", "Test SubMenu", 1, options); - var menu = new Menu(menuId, "TEST_MENU", "Test Menu", 1, new[] { submenu }); - var module = new ModuleSummary(moduleId, "TEST_MODULE", "Test Module", 1, "PUBLISHED"); - return new() { new MenuModule(module, new[] { menu }) }; + // Un nodo hoja por opción, agrupando sus acciones: la forma del contrato v2.0.0. + var hojas = _menuOptions + .GroupBy(m => m.OptionCode, StringComparer.Ordinal) + .Select(grupo => + { + var acciones = grupo.Select(m => + { + var existing = actions.FirstOrDefault(a => a.Code == m.ActionCode); + var actionRef = existing ?? new ActionRef(m.ActionCode, m.ActionCode); + if (existing is null) actions.Add(actionRef); + return new NodeAction(m.ActionCode, m.Effect, m.Source); + }).ToList(); + + return new NavigationNode(grupo.Key, grupo.Key, "Option", 1, null, $"/{grupo.Key.ToLowerInvariant()}", acciones, []); + }) + .ToList(); + + var submenu = new NavigationNode("TEST_SUB", "Test SubMenu", "SubMenu", 1, null, null, [], hojas); + var menu = new NavigationNode("TEST_MENU", "Test Menu", "Menu", 1, null, null, [], [submenu]); + return new() { new MenuModule("TEST_MODULE", "Test Module", 1, "Active", "layout-grid", [menu]) }; } private List BuildDomainPermissions(List actions) @@ -162,15 +169,14 @@ private List BuildDomainPermissions(List a .GroupBy(d => d.Resource) .Select(grp => { - var resource = new DomainResource(Guid.NewGuid(), "Aggregate", grp.Key, grp.Key, null, null); var resolutions = grp.Select(d => { var existing = actions.FirstOrDefault(a => a.Code == d.Action); - var actionRef = existing ?? new ActionRef(Guid.NewGuid(), d.Action, d.Action); + var actionRef = existing ?? new ActionRef(d.Action, d.Action); if (existing is null) actions.Add(actionRef); - return new DomainActionResolution(actionRef.Id, d.Action, d.Action, d.Effect, d.Source); + return new DomainActionResolution(d.Action, d.Action, d.Effect, d.Source); }).ToList(); - return new DomainResourcePermissions(resource, resolutions); + return new DomainResourcePermissions("Aggregate", grp.Key, grp.Key, resolutions); }) .ToList(); } diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Testing/Ums.Sdk.Authorization.Testing.csproj b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Testing/Ums.Sdk.Authorization.Testing.csproj index 39992b26..724360bb 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Testing/Ums.Sdk.Authorization.Testing.csproj +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization.Testing/Ums.Sdk.Authorization.Testing.csproj @@ -6,14 +6,7 @@ enable true Ums.Sdk.Authorization.Testing - 1.0.1 - - - https://github.com/beyondnetcode/ums - git - true + 1.0.0 UMS SDK — Fluent AuthGraphBuilder and TestAuthGraphAccessor for unit-testing consumer code without spinning up UMS. See ADR-0073. BeyondNetCode https://github.com/beyondnetcode/ums diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization/AuthorizationValidator.cs b/src/libs/sdk/dotnet/Ums.Sdk.Authorization/AuthorizationValidator.cs index cc76d45d..ec3b866c 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization/AuthorizationValidator.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization/AuthorizationValidator.cs @@ -39,7 +39,10 @@ public AuthorizationDecision RequireScope(AuthorizationGraph? graph, string scop graphRequestId: null, validUntil: graph!.ValidUntil); } - return graph!.Scopes.Contains(scope, StringComparer.Ordinal) + // Los scopes viajan en minúsculas (los deriva UMS con ToLowerInvariant), pero el + // llamador los escribe con el código tal como lo conoce: "STOCK_VIEW.VIEW". Comparar + // sensible a mayúsculas convertía esa diferencia de forma en un 403 (G-167). + return GraphIndex.For(graph!).TieneScope(scope) ? AuthorizationDecision.Granted(PrimScope, scope, graphRequestId: null, validUntil: graph.ValidUntil) : AuthorizationDecision.NotGranted(PrimScope, scope, UmsErrorCodes.ScopeNotGranted, @@ -52,24 +55,21 @@ public AuthorizationDecision RequireMenuOption(AuthorizationGraph? graph, string var pre = PreCheck(graph, PrimMenu, optionCode); if (pre is not null) return pre; - foreach (var module in graph!.MenuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options) + var opt = GraphIndex.For(graph!).Opcion(optionCode); + if (opt is not null) { - if (!string.Equals(opt.Code, optionCode, StringComparison.Ordinal)) continue; return opt.Effect switch { AccessEffect.Allow => AuthorizationDecision.Granted(PrimMenu, optionCode, - validUntil: graph.ValidUntil), + validUntil: graph!.ValidUntil), AccessEffect.Deny => AuthorizationDecision.Deny(PrimMenu, optionCode, UmsErrorCodes.MenuOptionDenied, $"Menu option '{optionCode}' is explicitly denied (source: {opt.Source}).", - validUntil: graph.ValidUntil), + validUntil: graph!.ValidUntil), _ => AuthorizationDecision.NotGranted(PrimMenu, optionCode, UmsErrorCodes.MenuOptionNotGranted, $"Menu option '{optionCode}' resolves to NotGranted.", - validUntil: graph.ValidUntil) + validUntil: graph!.ValidUntil) }; } @@ -85,26 +85,22 @@ public AuthorizationDecision RequireDomainAccess(AuthorizationGraph? graph, stri var pre = PreCheck(graph, PrimDomain, target); if (pre is not null) return pre; - foreach (var dr in graph!.DomainPermissions) + var act = GraphIndex.For(graph!).Dominio(resourceCode, actionCode); + if (act is not null) { - if (!string.Equals(dr.Resource.Code, resourceCode, StringComparison.Ordinal)) continue; - foreach (var act in dr.Actions) + return act.Effect switch { - if (!string.Equals(act.ActionCode, actionCode, StringComparison.Ordinal)) continue; - return act.Effect switch - { - AccessEffect.Allow => AuthorizationDecision.Granted(PrimDomain, target, - validUntil: graph.ValidUntil), - AccessEffect.Deny => AuthorizationDecision.Deny(PrimDomain, target, - UmsErrorCodes.DomainAccessDenied, - $"Domain access '{target}' is explicitly denied (source: {act.Source}).", - validUntil: graph.ValidUntil), - _ => AuthorizationDecision.NotGranted(PrimDomain, target, - UmsErrorCodes.DomainAccessNotGranted, - $"Domain access '{target}' resolves to NotGranted.", - validUntil: graph.ValidUntil) - }; - } + AccessEffect.Allow => AuthorizationDecision.Granted(PrimDomain, target, + validUntil: graph!.ValidUntil), + AccessEffect.Deny => AuthorizationDecision.Deny(PrimDomain, target, + UmsErrorCodes.DomainAccessDenied, + $"Domain access '{target}' is explicitly denied (source: {act.Source}).", + validUntil: graph!.ValidUntil), + _ => AuthorizationDecision.NotGranted(PrimDomain, target, + UmsErrorCodes.DomainAccessNotGranted, + $"Domain access '{target}' resolves to NotGranted.", + validUntil: graph!.ValidUntil) + }; } return AuthorizationDecision.NotGranted(PrimDomain, target, @@ -169,32 +165,10 @@ private static (string? Resource, string? Action) ParseScope(string scope) return (scope[..idx], scope[(idx + 1)..]); } + /// + /// Denegación explícita en cualquiera de las dos superficies del grafo. Delega en el índice: + /// antes recorría el árbol completo de menús y todos los recursos en cada comprobación. + /// private static bool IsDeniedInGraph(AuthorizationGraph graph, string resourceCode, string actionCode) - { - // Probe menuAccess: any option whose code equals the resource (UI surface) AND actionCode matches. - foreach (var module in graph.MenuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options) - { - if (opt.Effect != AccessEffect.Deny) continue; - if (string.Equals(opt.Code, resourceCode, StringComparison.Ordinal) && - string.Equals(opt.ActionCode, actionCode, StringComparison.Ordinal)) - return true; - } - - // Probe domainPermissions. - foreach (var dr in graph.DomainPermissions) - { - if (!string.Equals(dr.Resource.Code, resourceCode, StringComparison.Ordinal)) continue; - foreach (var act in dr.Actions) - { - if (act.Effect == AccessEffect.Deny && - string.Equals(act.ActionCode, actionCode, StringComparison.Ordinal)) - return true; - } - } - - return false; - } + => GraphIndex.For(graph).EstaDenegado(resourceCode, actionCode); } diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization/GraphIndex.cs b/src/libs/sdk/dotnet/Ums.Sdk.Authorization/GraphIndex.cs new file mode 100644 index 00000000..e8d539f7 --- /dev/null +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization/GraphIndex.cs @@ -0,0 +1,99 @@ +namespace Ums.Sdk.Authorization; + +using System.Runtime.CompilerServices; +using Ums.Sdk.Contracts; + +/// +/// Índice de consulta O(1) sobre un . +/// +/// El grafo es inmutable durante toda la sesión, pero el validador lo recorría entero en CADA +/// comprobación de permiso: cuatro bucles anidados para resolver una opción de menú, dos para un +/// recurso de dominio. Con un catálogo de 500 opciones eso es medio millar de comparaciones de +/// cadena por llamada, y una petición HTTP normal hace varias comprobaciones. Es coste por +/// petición, no por sesión: exactamente donde más duele (G-174, R-7 del informe de arquitectura). +/// +/// El índice se construye una vez por grafo y se memoiza en una +/// : no hay que cambiar la API pública ni pedirle +/// al integrador que recuerde invalidar nada, y cuando el grafo se recolecta, su índice se va con +/// él. La tabla es segura para uso concurrente. +/// +internal sealed class GraphIndex +{ + private static readonly ConditionalWeakTable Cache = new(); + + /// + /// Primera acción encontrada por código de nodo, en el orden de recorrido del árbol. + /// + /// Se conserva a propósito la semántica de «primera coincidencia» del validador original: + /// una opción con varias acciones (VIEW=Allow, DELETE=Deny) resuelve por la primera que + /// aparece. Es discutible —deny-wins sería más prudente— pero cambiarlo aquí sería alterar + /// una decisión de seguridad dentro de un cambio de rendimiento. Para preguntar por una + /// acción concreta existe . + /// + private readonly Dictionary _opcionPorCodigo = new(StringComparer.Ordinal); + + private readonly Dictionary<(string Opcion, string Accion), NodeAction> _opcionAccion = new(); + private readonly Dictionary<(string Recurso, string Accion), DomainActionResolution> _dominio = new(); + private readonly HashSet _scopes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _flags = new(StringComparer.Ordinal); + + private GraphIndex(AuthorizationGraph graph) + { + // El árbol es recursivo desde v2.0.0: se aplana una vez y se indexa por (código, acción). + foreach (var nodo in Aplanar(graph.MenuAccess.SelectMany(m => m.Nodes))) + { + foreach (var accion in nodo.Actions) + { + _opcionPorCodigo.TryAdd(nodo.Code, accion); + _opcionAccion.TryAdd((nodo.Code, accion.ActionCode), accion); + } + } + + foreach (var recurso in graph.DomainPermissions) + { + foreach (var accion in recurso.Actions) + { + _dominio.TryAdd((recurso.ResourceCode, accion.ActionCode), accion); + } + } + + foreach (var scope in graph.Scopes) _scopes.Add(scope); + foreach (var flag in graph.FeatureFlags) _flags.TryAdd(flag.FlagCode, flag); + } + + public static GraphIndex For(AuthorizationGraph graph) + => Cache.GetValue(graph, g => new GraphIndex(g)); + + private static IEnumerable Aplanar(IEnumerable nodos) + { + foreach (var nodo in nodos) + { + yield return nodo; + foreach (var hijo in Aplanar(nodo.Children)) yield return hijo; + } + } + + public NodeAction? Opcion(string codigo) + => _opcionPorCodigo.TryGetValue(codigo, out var opcion) ? opcion : null; + + public NodeAction? OpcionAccion(string codigo, string accion) + => _opcionAccion.TryGetValue((codigo, accion), out var opcion) ? opcion : null; + + public DomainActionResolution? Dominio(string recurso, string accion) + => _dominio.TryGetValue((recurso, accion), out var resolucion) ? resolucion : null; + + public FeatureFlagState? Flag(string codigo) + => _flags.TryGetValue(codigo, out var flag) ? flag : null; + + /// Los scopes viajan en minúsculas; se comparan sin distinguir mayúsculas (G-167). + public bool TieneScope(string scope) => _scopes.Contains(scope); + + /// + /// Denegación explícita del par (código, acción), mirando las dos superficies: una opción de + /// menú cuyo código coincida y un recurso de dominio. Refleja la precedencia de deny del + /// validador original. + /// + public bool EstaDenegado(string codigo, string accion) + => OpcionAccion(codigo, accion)?.Effect == AccessEffect.Deny + || Dominio(codigo, accion)?.Effect == AccessEffect.Deny; +} diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization/README.md b/src/libs/sdk/dotnet/Ums.Sdk.Authorization/README.md index 55039e34..07530289 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization/README.md +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization/README.md @@ -2,7 +2,7 @@ > Part of the [UMS SDK](https://github.com/beyondnetcode/ums/tree/main/docs/sdk) — the official client integration surface for UMS. -Pure authorization validator + accessor port + ASP.NET Core integration. Implements the deny-wins, override-takes-precedence, expiry and schema-compatibility rules documented in [ADR-0071](https://github.com/beyondnetcode/ums/blob/main/docs/architecture/adrs/0071-auth-graph-engine.md). +Pure authorization validator + accessor port + ASP.NET Core integration. Implements the deny-wins, override-takes-precedence, expiry and schema-compatibility rules documented in [ADR-UMS-088](https://github.com/beyondnetcode/ums/blob/main/docs/architecture/adrs/0071-auth-graph-engine.md). No framework lock-in: usable from ASP.NET Core, workers, CLI, Blazor. diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Authorization/Ums.Sdk.Authorization.csproj b/src/libs/sdk/dotnet/Ums.Sdk.Authorization/Ums.Sdk.Authorization.csproj index 29612ef3..5b0a88fd 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Authorization/Ums.Sdk.Authorization.csproj +++ b/src/libs/sdk/dotnet/Ums.Sdk.Authorization/Ums.Sdk.Authorization.csproj @@ -6,14 +6,7 @@ enable true Ums.Sdk.Authorization - 1.0.1 - - - https://github.com/beyondnetcode/ums - git - true + 1.0.0 UMS SDK — Pure authorization validator (deny-wins, override, expiry, schema-version compatibility), accessor port, ASP.NET Core integration and Result-pattern denial behavior. See ADR-0073. BeyondNetCode https://github.com/beyondnetcode/ums diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Client/ClientAuthEnvelope.cs b/src/libs/sdk/dotnet/Ums.Sdk.Client/ClientAuthEnvelope.cs new file mode 100644 index 00000000..4377b656 --- /dev/null +++ b/src/libs/sdk/dotnet/Ums.Sdk.Client/ClientAuthEnvelope.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Ums.Sdk.Client; + +/// +/// El sobre TAL COMO VIAJA por el cable, devuelto por POST /api/v1/client/authenticate. +/// +/// Graph es una cadena serializada, no un objeto: el formato lo elige el +/// inquilino (JSON, XML, YAML o CSV) y un objeto JSON no puede transportar XML. El SDK lo tipaba +/// como AuthorizationGraph y nunca había ejercido el endpoint real, así que +/// parsed.Graph.SchemaVersion reventaba y cualquier login a través del SDK fallaba +/// (G-207). +/// +/// RequestId es el identificador de traza de ASP.NET Core +/// (0HN7QMV8KJLDT:00000001), no un GUID. Tiparlo como hacía que la +/// deserialización lanzara antes siquiera de llegar al grafo: dos defectos distintos que se +/// manifestaban como el mismo fallo. +/// +/// Se distingue de a propósito: este es el cable, aquel es el +/// grafo ya interpretado. Mezclarlos fue lo que permitió que la forma real y la esperada +/// divergieran sin que nada fallara. +/// +public sealed record ClientAuthEnvelope( + [property: JsonPropertyName("token")] string Token, + [property: JsonPropertyName("tokenType")] string TokenType, + [property: JsonPropertyName("expiresIn")] int ExpiresInSeconds, + [property: JsonPropertyName("issuedAt")] DateTimeOffset IssuedAt, + [property: JsonPropertyName("format")] string Format, + [property: JsonPropertyName("graph")] string Graph, + [property: JsonPropertyName("requestId")] string RequestId); diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Client/ClientAuthResult.cs b/src/libs/sdk/dotnet/Ums.Sdk.Client/ClientAuthResult.cs index 587ab6a6..ab062880 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Client/ClientAuthResult.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Client/ClientAuthResult.cs @@ -4,8 +4,11 @@ namespace Ums.Sdk.Client; /// -/// Successful response from POST /api/v1/client/authenticate. Carries the JWT used for -/// subsequent calls, the deserialized graph, and bookkeeping fields. +/// Lo que recibe quien llama: el mismo sobre que devuelve +/// POST /api/v1/client/authenticate, con el grafo ya interpretado. +/// +/// La forma del cable vive en , donde graph es una +/// cadena y requestId no es un GUID (G-207). /// public sealed record ClientAuthResult( [property: JsonPropertyName("token")] string Token, @@ -14,4 +17,4 @@ public sealed record ClientAuthResult( [property: JsonPropertyName("issuedAt")] DateTimeOffset IssuedAt, [property: JsonPropertyName("format")] string Format, [property: JsonPropertyName("graph")] AuthorizationGraph Graph, - [property: JsonPropertyName("requestId")] Guid RequestId); + [property: JsonPropertyName("requestId")] string RequestId); diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Client/Ums.Sdk.Client.csproj b/src/libs/sdk/dotnet/Ums.Sdk.Client/Ums.Sdk.Client.csproj index d58f0726..b6135c5f 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Client/Ums.Sdk.Client.csproj +++ b/src/libs/sdk/dotnet/Ums.Sdk.Client/Ums.Sdk.Client.csproj @@ -6,14 +6,7 @@ enable true Ums.Sdk.Client - 1.0.1 - - - https://github.com/beyondnetcode/ums - git - true + 1.0.0 UMS SDK — Typed HttpClient that calls POST /api/v1/client/authenticate, deserializes the AuthorizationGraph, validates schemaVersion compatibility and returns a typed Result. See ADR-0073. BeyondNetCode https://github.com/beyondnetcode/ums diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Client/UmsAuthClient.cs b/src/libs/sdk/dotnet/Ums.Sdk.Client/UmsAuthClient.cs index d77b982d..527bab62 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Client/UmsAuthClient.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Client/UmsAuthClient.cs @@ -7,9 +7,11 @@ namespace Ums.Sdk.Client; /// -/// Default implementation of backed by . -/// Validates the server's schemaVersion against the SDK's compatibility range and -/// returns AUTH_205 when the server runs a MAJOR the SDK cannot interpret. +/// Implementación por defecto de sobre . +/// +/// Deserializa el grafo —que viaja como cadena, en el formato que eligió el inquilino— y +/// comprueba su schemaVersion contra la ventana de compatibilidad del SDK; un MAJOR +/// incompatible da AUTH_205 sin exponer el contenido. /// public sealed class UmsAuthClient : IUmsAuthClient { @@ -59,29 +61,55 @@ public async Task> AuthenticateAsync(ClientAuthRequest return Result.Failure(code, body); } - ClientAuthResult? parsed; + ClientAuthEnvelope? sobre; try { - parsed = await response.Content.ReadFromJsonAsync(JsonOptions, ct).ConfigureAwait(false); + sobre = await response.Content.ReadFromJsonAsync(JsonOptions, ct).ConfigureAwait(false); } catch (JsonException ex) { return Result.Failure(UmsErrorCodes.AuthGraphMalformed, ex.Message); } - if (parsed is null) + if (sobre is null) return Result.Failure(UmsErrorCodes.AuthGraphMalformed, "Response body was null after deserialization."); - if (string.IsNullOrWhiteSpace(parsed.Graph.SchemaVersion)) + // El grafo viene serializado en el formato que eligió el inquilino. Hay que deserializarlo + // ANTES de mirar nada dentro: esa era la causa de que el SDK rechazara una respuesta + // perfectamente válida. + var formato = (sobre.Format ?? "JSON").ToUpperInvariant(); + if (formato != "JSON") + return Result.Failure(UmsErrorCodes.AuthGraphMalformed, + $"El inquilino sirve el grafo en '{formato}' y este SDK solo interpreta JSON. " + + "Pide 'JSON' en la petición (campo 'format') o configura el inquilino para emitirlo así."); + + AuthorizationGraph? graph; + try + { + graph = JsonSerializer.Deserialize(sobre.Graph, JsonOptions); + } + catch (JsonException ex) + { + return Result.Failure(UmsErrorCodes.AuthGraphMalformed, + $"El campo 'graph' no es JSON deserializable: {ex.Message}"); + } + + if (graph is null) + return Result.Failure(UmsErrorCodes.AuthGraphMalformed, + "El campo 'graph' se deserializó a null."); + + if (string.IsNullOrWhiteSpace(graph.SchemaVersion)) return Result.Failure(UmsErrorCodes.AuthGraphSchemaMissing, "Server response does not carry a schemaVersion field."); - if (!SchemaVersion.IsSupported(parsed.Graph.SchemaVersion)) + if (!SchemaVersion.IsSupported(graph.SchemaVersion)) return Result.Failure(UmsErrorCodes.AuthGraphSchemaUnsupported, - $"Server emitted schemaVersion '{parsed.Graph.SchemaVersion}' which is outside SDK compatibility " + + $"Server emitted schemaVersion '{graph.SchemaVersion}' which is outside SDK compatibility " + $"({SchemaVersion.CompatibilityMinInclusive} ≤ x < {SchemaVersion.CompatibilityMaxExclusive})."); - return Result.Success(parsed); + return Result.Success(new ClientAuthResult( + sobre.Token, sobre.TokenType, sobre.ExpiresInSeconds, sobre.IssuedAt, + sobre.Format, graph, sobre.RequestId)); } private static async Task SafeReadAsync(HttpResponseMessage response, CancellationToken ct) diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Contracts/AuthorizationGraph.cs b/src/libs/sdk/dotnet/Ums.Sdk.Contracts/AuthorizationGraph.cs index 65e65b3e..355490df 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Contracts/AuthorizationGraph.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Contracts/AuthorizationGraph.cs @@ -3,66 +3,103 @@ namespace Ums.Sdk.Contracts; /// -/// Self-contained, immutable snapshot of a user's authorization universe at authentication time. -/// Schema is defined in src/libs/sdk/contracts/auth-graph.schema.json (v1.0.0). -/// See ADR-0071 for the model and ADR-0074 for the versioning policy. +/// Snapshot inmutable y autocontenido del universo de autorización de un usuario +/// en el momento de autenticar. +/// +/// Refleja src/libs/sdk/contracts/auth-graph.schema.json (v1.0.0), que +/// describe lo que emite AuthGraphPayload — el único mapeador del grafo, +/// común al endpoint de sistemas satélite (POST /api/v1/client/authenticate) +/// y al login web (POST /api/v1/auth/login, bajo authorizationGraph). +/// +/// Dos convenciones del contrato: +/// +/// Todo nombre o etiqueta viaja como value. +/// Los identificadores técnicos son OPCIONALES (Guid?, ausentes +/// por defecto): solo llegan si el inquilino activó +/// AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA. La clave de negocio es +/// Code; no construyas lógica que dependa de un id. +/// +/// +/// Ver ADR-UMS-088 (modelo) y ADR-0074 (versionado). /// public sealed record AuthorizationGraph( [property: JsonPropertyName("schemaVersion")] string SchemaVersion, + [property: JsonPropertyName("onboardingPending")] bool OnboardingPending, [property: JsonPropertyName("context")] PrincipalContext Context, [property: JsonPropertyName("authentication")] AuthenticationMetadata Authentication, [property: JsonPropertyName("actions")] IReadOnlyList Actions, + [property: JsonPropertyName("profiles")] IReadOnlyList Profiles, [property: JsonPropertyName("menuAccess")] IReadOnlyList MenuAccess, [property: JsonPropertyName("domainPermissions")] IReadOnlyList DomainPermissions, [property: JsonPropertyName("featureFlags")] IReadOnlyList FeatureFlags, [property: JsonPropertyName("effectiveConfig")] EffectiveConfig EffectiveConfig, + /// Ajustes visibles por espacio de nombres. Punto de extensión del contrato (G-178). + [property: JsonPropertyName("settings")] IReadOnlyDictionary> Settings, [property: JsonPropertyName("scopes")] IReadOnlyList Scopes, [property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt, - [property: JsonPropertyName("validUntil")] DateTimeOffset ValidUntil); - + [property: JsonPropertyName("validUntil")] DateTimeOffset ValidUntil, + /// + /// v2.4.0 — estado de acceso: Granted, NoProfileInSystem u + /// OnboardingPending. Convive con , que se conserva para + /// los consumidores de 2.0–2.3; el productor los deriva de la misma entrada. + /// + /// Se declara al final y con valor por defecto para que un consumidor que construya el record + /// a mano contra 2.3 siga compilando. Al deserializar un grafo 2.4 llega siempre poblado. + /// + [property: JsonPropertyName("accessState")] string AccessState = "Granted"); + +/// SystemSuite, Role y Profile son null en el grafo lobby (G-043). public sealed record PrincipalContext( [property: JsonPropertyName("user")] UserSummary User, [property: JsonPropertyName("tenant")] TenantSummary Tenant, - [property: JsonPropertyName("systemSuite")] SystemSuiteSummary SystemSuite, - [property: JsonPropertyName("role")] RoleSummary Role, - [property: JsonPropertyName("profile")] ProfileSummary Profile, - [property: JsonPropertyName("branch")] BranchSummary? Branch); + [property: JsonPropertyName("systemSuite")] SystemSuiteSummary? SystemSuite, + [property: JsonPropertyName("role")] RoleSummary? Role, + [property: JsonPropertyName("profile")] ProfileSummary? Profile, + [property: JsonPropertyName("branch")] BranchSummary? Branch, + /// + /// v2.4.0 — eco del sistema pedido al autenticarse, o null si no se pidió ninguno. + /// + [property: JsonPropertyName("requestedSystem")] RequestedSystemRef? RequestedSystem = null); + +/// v2.4.0 — referencia al sistema pedido. Solo lleva el código, que es la entrada. +public sealed record RequestedSystemRef( + [property: JsonPropertyName("code")] string Code); public sealed record UserSummary( - [property: JsonPropertyName("id")] Guid Id, [property: JsonPropertyName("email")] string Email, [property: JsonPropertyName("username")] string Username, - [property: JsonPropertyName("displayName")] string DisplayName, - [property: JsonPropertyName("status")] string Status); + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("id")] Guid? Id = null); public sealed record TenantSummary( - [property: JsonPropertyName("id")] Guid Id, [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("status")] string Status); + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("isManagementOwner")] bool IsManagementOwner, + [property: JsonPropertyName("id")] Guid? Id = null); public sealed record SystemSuiteSummary( - [property: JsonPropertyName("id")] Guid Id, [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("status")] string Status); + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("id")] Guid? Id = null); public sealed record RoleSummary( - [property: JsonPropertyName("id")] Guid Id, [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("value")] string Value, [property: JsonPropertyName("hierarchyLevel")] int HierarchyLevel, - [property: JsonPropertyName("parentRoleId")] Guid? ParentRoleId); + [property: JsonPropertyName("id")] Guid? Id = null); public sealed record ProfileSummary( - [property: JsonPropertyName("id")] Guid Id, [property: JsonPropertyName("scope")] string Scope, - [property: JsonPropertyName("isActive")] bool IsActive); + [property: JsonPropertyName("isActive")] bool IsActive, + [property: JsonPropertyName("id")] Guid? Id = null); public sealed record BranchSummary( - [property: JsonPropertyName("id")] Guid Id, [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("name")] string Name); + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("id")] Guid? Id = null); public sealed record AuthenticationMetadata( [property: JsonPropertyName("method")] string Method, @@ -71,78 +108,118 @@ public sealed record AuthenticationMetadata( [property: JsonPropertyName("issuedAt")] DateTimeOffset IssuedAt, [property: JsonPropertyName("sessionExpiresAt")] DateTimeOffset SessionExpiresAt); +/// Value es la estrategia del proveedor: AzureAd, Okta, GenericOidc… public sealed record IdpProviderRef( + [property: JsonPropertyName("code")] string Code, [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("id")] Guid? Id = null); + +/// +/// Un perfil al que el usuario tiene acceso: un rol dentro de un sistema, opcionalmente acotado a +/// una sucursal. Exactamente uno lleva IsCurrent. La lista llega ordenada por nivel de +/// jerarquía, sistema y rol: el primero es el que el servidor habría elegido por defecto. +/// +/// +/// v2.4.0 — SIEMPRE presente, a diferencia del resto de ids del grafo, que siguen sujetos a +/// AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA. Aquí no es decorativo: es la clave de +/// POST /client/switch-profile, la operación que esta misma lista invita a ejecutar. +/// Se mantiene al final del record —donde ya estaba— para no romper a quien construya el tipo +/// por posición, pero deja de admitir null. +/// +public sealed record ProfileOption( + [property: JsonPropertyName("system")] ProfileSystemRef System, + [property: JsonPropertyName("role")] ProfileRoleRef Role, + [property: JsonPropertyName("branch")] ProfileBranchRef? Branch, + [property: JsonPropertyName("scope")] string Scope, + [property: JsonPropertyName("isCurrent")] bool IsCurrent, + [property: JsonPropertyName("id")] Guid Id = default); + +public sealed record ProfileSystemRef( [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("strategy")] string Strategy); + [property: JsonPropertyName("value")] string Value); -public sealed record ActionRef( - [property: JsonPropertyName("id")] Guid Id, +public sealed record ProfileRoleRef( [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("name")] string Name); + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("hierarchyLevel")] int HierarchyLevel); -public sealed record MenuModule( - [property: JsonPropertyName("module")] ModuleSummary Module, - [property: JsonPropertyName("menus")] IReadOnlyList Menus); +public sealed record ProfileBranchRef( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("value")] string Value); -public sealed record ModuleSummary( - [property: JsonPropertyName("id")] Guid Id, +public sealed record ActionRef( [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("sortOrder")] int SortOrder, - [property: JsonPropertyName("status")] string Status); + [property: JsonPropertyName("value")] string Value); -public sealed record Menu( - [property: JsonPropertyName("id")] Guid Id, +/// +/// Módulo con su navegación alcanzable. MenuAccess es una lista plana de estos. +/// +/// v2.0.0: el árbol es RECURSIVO. La forma anterior tenía tres niveles fijos y perdía en silencio +/// los nodos que no encajaban en ella. Recorre Children hasta agotarlo. +/// +public sealed record MenuModule( [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("label")] string Label, + [property: JsonPropertyName("value")] string Value, [property: JsonPropertyName("sortOrder")] int SortOrder, - [property: JsonPropertyName("subMenus")] IReadOnlyList SubMenus); + [property: JsonPropertyName("status")] string Status, + /// v2.3.0: identificador de icono del módulo; el catálogo gráfico lo elige el cliente. + [property: JsonPropertyName("icon")] string? Icon, + [property: JsonPropertyName("nodes")] IReadOnlyList Nodes, + [property: JsonPropertyName("id")] Guid? Id = null); -public sealed record SubMenu( - [property: JsonPropertyName("id")] Guid Id, +/// +/// Nodo del árbol de navegación. Kind clasifica su papel sin fijar su profundidad. +/// Solo viaja lo alcanzable: la ausencia significa «no concedido». +/// +public sealed record NavigationNode( [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("label")] string Label, + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("kind")] string Kind, [property: JsonPropertyName("sortOrder")] int SortOrder, - [property: JsonPropertyName("options")] IReadOnlyList Options); + /// Identificador de icono; el catálogo gráfico lo elige el cliente. + [property: JsonPropertyName("icon")] string? Icon, + /// Ruta relativa destino; null en los nodos que solo agrupan. + [property: JsonPropertyName("route")] string? Route, + [property: JsonPropertyName("actions")] IReadOnlyList Actions, + [property: JsonPropertyName("children")] IReadOnlyList Children, + [property: JsonPropertyName("id")] Guid? Id = null); -public sealed record MenuOption( - [property: JsonPropertyName("id")] Guid Id, - [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("label")] string Label, +/// +/// Acción con su efecto resuelto. NotGranted no viaja: es lo que significa la ausencia. +/// +public sealed record NodeAction( [property: JsonPropertyName("actionCode")] string ActionCode, [property: JsonPropertyName("effect")] AccessEffect Effect, [property: JsonPropertyName("source")] PermissionSource Source); +/// Recurso de dominio con su resolución por acción; lista plana, sin envoltorio. public sealed record DomainResourcePermissions( - [property: JsonPropertyName("resource")] DomainResource Resource, - [property: JsonPropertyName("actions")] IReadOnlyList Actions); - -public sealed record DomainResource( - [property: JsonPropertyName("id")] Guid Id, - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("moduleId")] Guid? ModuleId, - [property: JsonPropertyName("parentResourceId")] Guid? ParentResourceId); + [property: JsonPropertyName("resourceType")] string ResourceType, + [property: JsonPropertyName("resourceCode")] string ResourceCode, + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("actions")] IReadOnlyList Actions, + [property: JsonPropertyName("resourceId")] Guid? ResourceId = null, + [property: JsonPropertyName("moduleId")] Guid? ModuleId = null, + [property: JsonPropertyName("parentResourceId")] Guid? ParentResourceId = null); public sealed record DomainActionResolution( - [property: JsonPropertyName("actionId")] Guid ActionId, [property: JsonPropertyName("actionCode")] string ActionCode, - [property: JsonPropertyName("actionName")] string ActionName, + [property: JsonPropertyName("value")] string Value, [property: JsonPropertyName("effect")] AccessEffect Effect, [property: JsonPropertyName("source")] PermissionSource Source); public sealed record FeatureFlagState( [property: JsonPropertyName("flagCode")] string FlagCode, - [property: JsonPropertyName("systemSuiteId")] Guid SystemSuiteId, [property: JsonPropertyName("isEnabled")] bool IsEnabled, - [property: JsonPropertyName("matchedCriteriaType")] string? MatchedCriteriaType); + [property: JsonPropertyName("matchedCriteriaType")] string? MatchedCriteriaType, + [property: JsonPropertyName("systemSuiteId")] Guid? SystemSuiteId = null); public sealed record EffectiveConfig( [property: JsonPropertyName("sessionTimeoutMinutes")] int SessionTimeoutMinutes, [property: JsonPropertyName("maxLoginAttempts")] int MaxLoginAttempts, [property: JsonPropertyName("minPasswordLength")] int MinPasswordLength, [property: JsonPropertyName("mfaRequiredForAdmin")] bool MfaRequiredForAdmin, + [property: JsonPropertyName("mfaAllowedMethods")] IReadOnlyList MfaAllowedMethods, [property: JsonPropertyName("accessTokenDurationMs")] long AccessTokenDurationMs, [property: JsonPropertyName("authUseExternalIdp")] bool AuthUseExternalIdp); diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Contracts/SchemaVersion.cs b/src/libs/sdk/dotnet/Ums.Sdk.Contracts/SchemaVersion.cs index 45e33d94..315de207 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Contracts/SchemaVersion.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Contracts/SchemaVersion.cs @@ -7,13 +7,13 @@ namespace Ums.Sdk.Contracts; public static class SchemaVersion { /// Current canonical schema version emitted and consumed by SDK 1.0.x. - public const string Current = "1.0.0"; + public const string Current = "2.4.0"; /// Inclusive lower bound of the schema compatibility range. - public const string CompatibilityMinInclusive = "1.0.0"; + public const string CompatibilityMinInclusive = "2.0.0"; /// Exclusive upper bound of the schema compatibility range. - public const string CompatibilityMaxExclusive = "2.0.0"; + public const string CompatibilityMaxExclusive = "3.0.0"; /// /// Returns true when falls within the SDK's supported range. diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Contracts/Ums.Sdk.Contracts.csproj b/src/libs/sdk/dotnet/Ums.Sdk.Contracts/Ums.Sdk.Contracts.csproj index e0fe96a1..a3ac056e 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Contracts/Ums.Sdk.Contracts.csproj +++ b/src/libs/sdk/dotnet/Ums.Sdk.Contracts/Ums.Sdk.Contracts.csproj @@ -6,14 +6,7 @@ enable true Ums.Sdk.Contracts - 1.0.1 - - - https://github.com/beyondnetcode/ums - git - true + 1.0.0 UMS SDK — Typed contracts (DTOs) for the AuthorizationGraph payload, error code constants and schema version metadata. See ADR-0073 and ADR-0074. BeyondNetCode https://github.com/beyondnetcode/ums diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Tests/AuthorizationValidatorTests.cs b/src/libs/sdk/dotnet/Ums.Sdk.Tests/AuthorizationValidatorTests.cs index 0a816b6c..d7deaf2e 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Tests/AuthorizationValidatorTests.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Tests/AuthorizationValidatorTests.cs @@ -36,9 +36,9 @@ public void IdpAuthSuccess_HasBranchScopedProfile_AndIdpProvider() graph = MakeValid(graph); graph.Context.Branch.Should().NotBeNull(); - graph.Context.Profile.Scope.Should().Be("BranchScoped"); + graph.Context.Profile!.Scope.Should().Be("BranchScoped"); graph.Authentication.Method.Should().Be("IDP"); - graph.Authentication.Provider!.Strategy.Should().Be("AZURE_AD"); + graph.Authentication.Provider!.Value.Should().Be("AZURE_AD"); Validator.RequireDomainAccess(graph, "BRANCH_INVENTORY", "APPROVE").IsGranted.Should().BeTrue(); Validator.RequireScope(graph, "BRANCH_INVENTORY.VIEW").IsGranted.Should().BeTrue(); diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Tests/SchemaVersionTests.cs b/src/libs/sdk/dotnet/Ums.Sdk.Tests/SchemaVersionTests.cs index 978baf26..d5b434d0 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Tests/SchemaVersionTests.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Tests/SchemaVersionTests.cs @@ -7,11 +7,12 @@ namespace Ums.Sdk.Tests; public sealed class SchemaVersionTests { [Theory] - [InlineData("1.0.0", true)] - [InlineData("1.0.5", true)] - [InlineData("1.99.0", true)] - [InlineData("2.0.0", false)] - [InlineData("2.5.3", false)] + // Soportado = mismo MAJOR que SchemaVersion.Current (hoy 2.x). + [InlineData("2.0.0", true)] + [InlineData("2.0.5", true)] + [InlineData("2.99.0", true)] + [InlineData("3.0.0", false)] + [InlineData("1.5.3", false)] [InlineData("0.9.0", false)] [InlineData("", false)] [InlineData(null, false)] @@ -21,13 +22,33 @@ public void IsSupported_ReturnsExpected(string? input, bool expected) SchemaVersion.IsSupported(input).Should().Be(expected); } - [Theory] - [InlineData("1.1.0", true)] - [InlineData("1.0.0", false)] - [InlineData("0.9.0", false)] - public void IsMinorAhead_DetectsServerNewer(string version, bool expected) + /// + /// Los casos se DERIVAN de en vez de repetirlo. Cuando la + /// tabla copiaba la constante —«2.4.0 va por delante de 2.3.0»— cada versión nueva rompía la + /// prueba, y arreglarla consistía en volver a copiar la constante: el pin acababa afirmando la + /// versión de ayer. Lo que aquí se verifica es la RELACIÓN —un MINOR superior va por delante, + /// el mismo y los anteriores no, y un MAJOR distinto no es «por delante» sino incompatible—, + /// que es lo que no debe cambiar al publicar una versión. + /// + [Fact] + public void IsMinorAhead_DetectsServerNewer() + { + var (major, minor) = ActualDesglosada(); + + SchemaVersion.IsMinorAhead($"{major}.{minor + 1}.0").Should().BeTrue(); + SchemaVersion.IsMinorAhead($"{major}.{minor}.0").Should().BeFalse(); + + for (var anterior = minor - 1; anterior >= 0; anterior--) + SchemaVersion.IsMinorAhead($"{major}.{anterior}.0").Should().BeFalse(); + + SchemaVersion.IsMinorAhead($"{major + 1}.0.0").Should().BeFalse(); + if (major > 0) SchemaVersion.IsMinorAhead($"{major - 1}.99.0").Should().BeFalse(); + } + + private static (int Major, int Minor) ActualDesglosada() { - SchemaVersion.IsMinorAhead(version).Should().Be(expected); + var partes = SchemaVersion.Current.Split('.'); + return (int.Parse(partes[0]), int.Parse(partes[1])); } [Fact] diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthClientTests.cs b/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthClientTests.cs index 884cf135..016911e5 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthClientTests.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthClientTests.cs @@ -12,6 +12,68 @@ namespace Ums.Sdk.Tests; public sealed class UmsAuthClientTests { + private static readonly JsonSerializerOptions JsonOpcionesDelSobre = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// El sobre REAL de POST /api/v1/client/authenticate, capturado de la API viva por + /// ContratoDeClientAuthenticateTests y regenerable desde ahí. + /// + /// No está escrito a mano a propósito: las pruebas de este cliente sí lo estaban, con la + /// forma que el SDK creía correcta, y por eso pasaban mientras cualquier login real fallaba + /// (G-207). Una prueba escrita contra lo que uno supone no comprueba nada. + /// + private static string SobreReal() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + dir = dir.Parent; + + if (dir is null) throw new InvalidOperationException("No se encontró la raíz del repositorio."); + + return File.ReadAllText(Path.Combine(dir.FullName, "src", "libs", "sdk", "contracts", + "fixtures", "client-authenticate.envelope.json")); + } + + [Fact] + public async Task AuthenticateAsync_Entiende_El_Sobre_Real_Capturado_De_La_Api() + { + var client = CreateClient(new StubHandler(HttpStatusCode.OK, SobreReal())); + + var result = await client.AuthenticateAsync( + new ClientAuthRequest("COMEX_ANDINA", "usuario.impo@comexandina.com.pe", "p")); + + result.IsSuccess.Should().BeTrue(because: result.ErrorMessage); + result.Value.Graph.SchemaVersion.Should().Be("2.4.0"); + result.Value.Graph.Context.Tenant.Code.Should().Be("COMEX_ANDINA"); + } + + [Fact] + public async Task AuthenticateAsync_Formato_No_Json_Explica_El_Motivo_Real() + { + // Antes contestaba «falta schemaVersion», que manda a buscar el problema al sitio + // equivocado: el grafo está completo, lo que pasa es que viene en otro formato. + var sobre = JsonSerializer.Serialize(new + { + token = "T", + tokenType = "Bearer", + expiresIn = 60, + issuedAt = DateTimeOffset.UtcNow, + format = "XML", + graph = "", + requestId = "0HN7QMV8KJLDT:00000001" + }); + + var result = await CreateClient(new StubHandler(HttpStatusCode.OK, sobre)) + .AuthenticateAsync(new ClientAuthRequest("X", "u", "p")); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(UmsErrorCodes.AuthGraphMalformed); + result.ErrorMessage.Should().Contain("XML"); + } + private static UmsAuthClient CreateClient(HttpMessageHandler handler, UmsSdkClientOptions? options = null) { options ??= new UmsSdkClientOptions { BaseAddress = new Uri("https://ums.example.com") }; @@ -34,8 +96,11 @@ public async Task AuthenticateAsync_HappyPath_ReturnsTypedResult() expiresIn = 3600, issuedAt = DateTimeOffset.UtcNow, format = "JSON", - graph, - requestId = Guid.NewGuid() + // Cadena, no objeto; y `requestId` es un identificador de traza, no un GUID. Escritas + // con la forma que el SDK suponía, estas pruebas pasaban mientras cualquier login real + // fallaba (G-207). + graph = JsonSerializer.Serialize(graph, JsonOpcionesDelSobre), + requestId = "0HN7QMV8KJLDT:00000001" }; var handler = new StubHandler(HttpStatusCode.OK, JsonSerializer.Serialize(payload)); @@ -53,7 +118,7 @@ public async Task AuthenticateAsync_HappyPath_ReturnsTypedResult() public async Task AuthenticateAsync_UnsupportedMajor_ReturnsAuth205() { var graph = AuthGraphBuilder.ForTenant("LOGISTICS_CORE") - .WithSchemaVersion("2.0.0") + .WithSchemaVersion("3.0.0") .Build(); var payload = new @@ -63,8 +128,11 @@ public async Task AuthenticateAsync_UnsupportedMajor_ReturnsAuth205() expiresIn = 60, issuedAt = DateTimeOffset.UtcNow, format = "JSON", - graph, - requestId = Guid.NewGuid() + // Cadena, no objeto; y `requestId` es un identificador de traza, no un GUID. Escritas + // con la forma que el SDK suponía, estas pruebas pasaban mientras cualquier login real + // fallaba (G-207). + graph = JsonSerializer.Serialize(graph, JsonOpcionesDelSobre), + requestId = "0HN7QMV8KJLDT:00000001" }; var handler = new StubHandler(HttpStatusCode.OK, JsonSerializer.Serialize(payload)); diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthGraphMiddlewareRs256Tests.cs b/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthGraphMiddlewareRs256Tests.cs new file mode 100644 index 00000000..59d14ee4 --- /dev/null +++ b/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthGraphMiddlewareRs256Tests.cs @@ -0,0 +1,365 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Ums.Sdk.Authorization; +using Ums.Sdk.Authorization.AspNetCore; +using Ums.Sdk.Authorization.Testing; +using Ums.Sdk.Contracts; +using Xunit; + +namespace Ums.Sdk.Tests; + +/// +/// ADR-0157: UMS firma en RS256 y publica su clave publica en el JWKS. Este middleware exigia HS256 +/// de forma literal —correcto contra la confusion de algoritmo (G-217), pero rechazaba todos los +/// portadores buenos—. +/// +public sealed class UmsAuthGraphMiddlewareRs256Tests +{ + private const string SecretoDePrueba = "secreto-de-prueba-de-al-menos-32-bytes-para-hs256"; + private const string Kid = "kid-de-prueba"; + private const string UriDelJwks = "https://ums.local/.well-known/jwks.json"; + + [Fact] + public async Task Acepta_El_Portador_Que_Ums_Firma_Con_Su_Clave_Privada() + { + using var rsa = RSA.Create(2048); + var jwks = ServidorDeJwks(JwkDe(rsa, Kid)); + var jwt = TokenRs256(rsa, Kid); + + AuthorizationGraph? observado = null; + using var host = await ConstruirHost(g => observado = g, SoloRs256(jwks)); + var respuesta = await Pedir(host, jwt); + + respuesta.IsSuccessStatusCode.Should().BeTrue(); + observado.Should().NotBeNull(); + observado!.Context.Tenant.Code.Should().Be("LOGISTICS_CORE"); + } + + [Fact] + public async Task Rechaza_Uno_Firmado_Con_Otra_Clave_Aunque_Declare_El_Kid_Bueno() + { + using var buena = RSA.Create(2048); + using var ajena = RSA.Create(2048); + var jwks = ServidorDeJwks(JwkDe(buena, Kid)); + var jwt = TokenRs256(ajena, Kid); + + AuthorizationGraph? observado = null; + using var host = await ConstruirHost(g => observado = g, SoloRs256(jwks)); + _ = await Pedir(host, jwt); + + observado.Should().BeNull("la firma no la hizo la clave que publica el emisor"); + } + + [Fact] + public async Task Rechaza_Un_Kid_Que_No_Esta_En_El_Jwks() + { + using var rsa = RSA.Create(2048); + var jwks = ServidorDeJwks(JwkDe(rsa, Kid)); + var jwt = TokenRs256(rsa, "kid-inventado"); + + AuthorizationGraph? observado = null; + using var host = await ConstruirHost(g => observado = g, SoloRs256(jwks)); + _ = await Pedir(host, jwt); + + observado.Should().BeNull("no se prueba con «alguna» clave: sin `kid` que case, se rechaza"); + } + + [Fact] + public async Task Rechaza_Alg_None_Tambien_En_La_Via_Rs256() + { + using var rsa = RSA.Create(2048); + var jwks = ServidorDeJwks(JwkDe(rsa, Kid)); + var graph = AuthGraphBuilder.ForTenant("LOGISTICS_CORE").Build(); + var cabecera = Base64Url(Encoding.UTF8.GetBytes($"{{\"alg\":\"none\",\"typ\":\"JWT\",\"kid\":\"{Kid}\"}}")); + var cuerpo = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { graph }))); + + AuthorizationGraph? observado = null; + using var host = await ConstruirHost(g => observado = g, SoloRs256(jwks)); + _ = await Pedir(host, $"{cabecera}.{cuerpo}."); + + observado.Should().BeNull("`alg: none` no es una firma valida"); + } + + [Fact] + public async Task Configurado_Para_Rs256_Un_Hs256_No_Se_Cuela() + { + // La degradacion que no puede ocurrir: el satelite espera RS256 y le presentan un HS256. + using var rsa = RSA.Create(2048); + var jwks = ServidorDeJwks(JwkDe(rsa, Kid)); + + AuthorizationGraph? observado = null; + using var host = await ConstruirHost(g => observado = g, SoloRs256(jwks)); + _ = await Pedir(host, TokenHs256(SecretoDePrueba)); + + observado.Should().BeNull("configurado para RS256, un HS256 no debe aceptarse"); + } + + [Fact] + public async Task Hs256_Sigue_Funcionando_Donde_Se_Configuro_Asi() + { + AuthorizationGraph? observado = null; + using var host = await ConstruirHost(g => observado = g, o => o.SigningSecret = SecretoDePrueba); + _ = await Pedir(host, TokenHs256(SecretoDePrueba)); + + observado.Should().NotBeNull("el corte no puede ser brusco: hay despliegues que aun usan HS256"); + } + + [Fact] + public async Task Durante_La_Transicion_Admite_Las_Dos_Vias_Y_Ninguna_Se_Relaja() + { + using var rsa = RSA.Create(2048); + var jwks = ServidorDeJwks(JwkDe(rsa, Kid)); + + AuthorizationGraph? observado = null; + using var host = await ConstruirHost(g => observado = g, o => + { + o.SigningSecret = SecretoDePrueba; + o.JwksUri = UriDelJwks; + o.Jwks.HttpClient = jwks; + }); + + _ = await Pedir(host, TokenRs256(rsa, Kid)); + observado.Should().NotBeNull("el RS256 legitimo pasa"); + + observado = null; + _ = await Pedir(host, TokenHs256(SecretoDePrueba)); + observado.Should().NotBeNull("el HS256 legitimo tambien, durante la transicion"); + + observado = null; + _ = await Pedir(host, TokenHs256("otro-secreto-igual-de-largo-pero-ajeno")); + observado.Should().BeNull("y los rechazos siguen siendo rechazos"); + } + + [Fact] + public async Task Sin_Material_De_Verificacion_No_Se_Acepta_Nada() + { + // La omision no es una via de escape: es exactamente como llego G-217. + AuthorizationGraph? observado = null; + using var host = await ConstruirHost(g => observado = g, _ => { }); + _ = await Pedir(host, TokenHs256(SecretoDePrueba)); + + observado.Should().BeNull(); + } + + // ── La caceria del `kid` desconocido ──────────────────────────────────────────────────────── + + [Fact] + public async Task Mil_Kid_Inventados_No_Producen_Mil_Peticiones_Al_Jwks() + { + // Sin acotar, el refresco por `kid` desconocido es un amplificador de denegacion de + // servicio: lo dispara un desconocido, porque el `kid` viaja en un token sin verificar. + using var rsa = RSA.Create(2048); + var contador = new Contador(); + using var http = new HttpClient(new ManejadorDeJwks(JwkDe(rsa, Kid), contador)); + using var cache = new CacheDeJwks(new OpcionesDeJwks + { + Uri = UriDelJwks, + HttpClient = http, + EsperaEntreRefrescos = TimeSpan.FromSeconds(60), + }); + + for (var i = 0; i < 1000; i++) + { + var resultado = await cache.ClaveParaAsync($"inventado-{i}"); + resultado.Estado.Should().Be(EstadoDeClave.Desconocida); + } + + // Dos: la de arranque —la cache estaba vacia— y una unica sonda por si fuera una rotacion. + // Lo que importa no es el numero exacto sino que este acotado por la ventana de tiempo y + // no por el numero de tokens que mande el atacante. + contador.Peticiones.Should().Be(2); + } + + [Fact] + public async Task Una_Rotacion_Se_Recoge_Sola() + { + using var vieja = RSA.Create(2048); + using var nueva = RSA.Create(2048); + var contador = new Contador(); + var manejador = new ManejadorDeJwks(JwkDe(vieja, "kid-v1"), contador); + using var http = new HttpClient(manejador); + using var cache = new CacheDeJwks(new OpcionesDeJwks { Uri = UriDelJwks, HttpClient = http }); + + (await cache.ClaveParaAsync("kid-v1")).Estado.Should().Be(EstadoDeClave.Ok); + + // UMS rota: publica una clave nueva con otro `kid`. + manejador.Claves = [JwkDe(vieja, "kid-v1"), JwkDe(nueva, "kid-v2")]; + + (await cache.ClaveParaAsync("kid-v2")).Estado.Should().Be(EstadoDeClave.Ok); + contador.Peticiones.Should().Be(2); + } + + [Fact] + public async Task La_Cache_Evita_Una_Peticion_Por_Verificacion() + { + using var rsa = RSA.Create(2048); + var contador = new Contador(); + using var http = new HttpClient(new ManejadorDeJwks(JwkDe(rsa, Kid), contador)); + using var cache = new CacheDeJwks(new OpcionesDeJwks { Uri = UriDelJwks, HttpClient = http }); + + for (var i = 0; i < 25; i++) + { + (await cache.ClaveParaAsync(Kid)).Estado.Should().Be(EstadoDeClave.Ok); + } + + contador.Peticiones.Should().Be(1); + } + + [Fact] + public async Task Si_En_La_Uri_Del_Jwks_Hay_Html_Se_Rechaza_En_Vez_De_Interpretarlo() + { + // Ocurre de verdad: si el JWKS queda detras del comodin del frontal, la respuesta es la + // pagina del SPA con un 200 alegre. Un 200 no es un JWKS. + using var http = new HttpClient(new ManejadorDeHtml()); + using var cache = new CacheDeJwks(new OpcionesDeJwks { Uri = UriDelJwks, HttpClient = http }); + + (await cache.ClaveParaAsync(Kid)).Estado.Should().Be(EstadoDeClave.Inaccesible); + } + + [Fact] + public async Task Descarta_Una_Clave_Rsa_Por_Debajo_De_2048_Bits() + { + // Una clave corta verifica sin error y sin aviso: si no se descarta, deja el sistema debil + // sin que nada falle. + using var corta = RSA.Create(1024); + using var buena = RSA.Create(2048); + var contador = new Contador(); + using var http = new HttpClient(new ManejadorDeJwks(JwkDe(corta, "corta"), contador) + { + Claves = [JwkDe(corta, "corta"), JwkDe(buena, Kid)], + }); + using var cache = new CacheDeJwks(new OpcionesDeJwks { Uri = UriDelJwks, HttpClient = http }); + + (await cache.ClaveParaAsync("corta")).Estado.Should().Be(EstadoDeClave.Desconocida); + (await cache.ClaveParaAsync(Kid)).Estado.Should().Be(EstadoDeClave.Ok, "la buena sigue sirviendo"); + } + + // ── Arnes ─────────────────────────────────────────────────────────────────────────────────── + + private static Action SoloRs256(HttpClient jwks) => o => + { + // Satelite tal como debe quedar tras ADR-0157: verifica sin custodiar material de firma. + o.JwksUri = UriDelJwks; + o.Jwks.HttpClient = jwks; + }; + + private static HttpClient ServidorDeJwks(string jwk) => + new(new ManejadorDeJwks(jwk, new Contador())); + + private static async Task Pedir(IHost host, string jwt) + { + using var client = host.GetTestServer().CreateClient(); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt); + return await client.GetAsync(new Uri("/probe", UriKind.Relative)); + } + + private static string JwkDe(RSA rsa, string kid) + { + var p = rsa.ExportParameters(includePrivateParameters: false); + return JsonSerializer.Serialize(new + { + kty = "RSA", + use = "sig", + alg = "RS256", + kid, + n = Base64Url(p.Modulus!), + e = Base64Url(p.Exponent!), + }); + } + + private static string TokenRs256(RSA rsa, string kid) + { + var graph = AuthGraphBuilder.ForTenant("LOGISTICS_CORE").WithScope("PURCHASE_ORDER.VIEW").Build(); + var cabecera = Base64Url(Encoding.UTF8.GetBytes($"{{\"alg\":\"RS256\",\"typ\":\"JWT\",\"kid\":\"{kid}\"}}")); + var cuerpo = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { graph }))); + var firma = rsa.SignData( + Encoding.ASCII.GetBytes($"{cabecera}.{cuerpo}"), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + return $"{cabecera}.{cuerpo}.{Base64Url(firma)}"; + } + + private static string TokenHs256(string secreto) + { + var graph = AuthGraphBuilder.ForTenant("LOGISTICS_CORE").WithScope("PURCHASE_ORDER.VIEW").Build(); + var cabecera = Base64Url(Encoding.UTF8.GetBytes("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")); + var cuerpo = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { graph }))); + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secreto)); + return $"{cabecera}.{cuerpo}.{Base64Url(hmac.ComputeHash(Encoding.ASCII.GetBytes($"{cabecera}.{cuerpo}")))}"; + } + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static async Task ConstruirHost( + Action observar, + Action configurar) + { + var builder = Host.CreateDefaultBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + _ = services.AddUmsAuthGraphMiddleware(); + _ = services.Configure(configurar); + _ = services.AddRouting(); + }) + .Configure(app => + { + _ = app.UseUmsAuthGraph(); + _ = app.UseRouting(); + _ = app.UseEndpoints(e => e.MapGet("/probe", ctx => + { + observar(ctx.Items.TryGetValue("UmsAuthGraph", out var raw) + ? raw as AuthorizationGraph + : null); + ctx.Response.StatusCode = 200; + return Task.CompletedTask; + })); + })); + + return await builder.StartAsync(); + } + + private sealed class Contador + { + public int Peticiones { get; set; } + } + + /// JWKS servido en memoria, contando las peticiones para poder afirmar cuantas hubo. + private sealed class ManejadorDeJwks(string jwk, Contador contador) : HttpMessageHandler + { + public IReadOnlyList Claves { get; set; } = [jwk]; + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + contador.Peticiones++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + $"{{\"keys\":[{string.Join(',', Claves)}]}}", Encoding.UTF8, "application/json"), + }); + } + } + + private sealed class ManejadorDeHtml : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("SPA", Encoding.UTF8, "text/html"), + }); + } +} diff --git a/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthGraphMiddlewareTests.cs b/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthGraphMiddlewareTests.cs index 5180b51b..3ac83d4b 100644 --- a/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthGraphMiddlewareTests.cs +++ b/src/libs/sdk/dotnet/Ums.Sdk.Tests/UmsAuthGraphMiddlewareTests.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using System.Text.Json; using FluentAssertions; @@ -21,7 +22,7 @@ public sealed class UmsAuthGraphMiddlewareTests public async Task Middleware_Decodes_Graph_From_Bearer_And_Stores_It_On_HttpContext() { var graph = AuthGraphBuilder.ForTenant("LOGISTICS_CORE").WithScope("PURCHASE_ORDER.VIEW").Build(); - var jwt = BuildFakeJwt(graph); + var jwt = BuildSignedJwt(graph); AuthorizationGraph? observed = null; using var host = await BuildHost(observe => observed = observe); @@ -52,7 +53,7 @@ public async Task Middleware_Without_Bearer_Leaves_Context_Empty() public async Task Middleware_RejectsExpiredGraphs_With_401_AUTH_201() { var graph = AuthGraphBuilder.ForTenant("LOGISTICS_CORE").BuildExpired(); - var jwt = BuildFakeJwt(graph); + var jwt = BuildSignedJwt(graph); using var host = await BuildHost(_ => { }, opt => opt.RejectExpiredGraphs = true); using var client = host.GetTestServer().CreateClient(); @@ -68,9 +69,9 @@ public async Task Middleware_RejectsExpiredGraphs_With_401_AUTH_201() public async Task Middleware_RejectsIncompatibleSchemaVersion_With_401_AUTH_205() { var graph = AuthGraphBuilder.ForTenant("LOGISTICS_CORE") - .WithSchemaVersion("2.0.0") + .WithSchemaVersion("3.0.0") .Build(); - var jwt = BuildFakeJwt(graph); + var jwt = BuildSignedJwt(graph); using var host = await BuildHost(_ => { }); using var client = host.GetTestServer().CreateClient(); @@ -82,6 +83,46 @@ public async Task Middleware_RejectsIncompatibleSchemaVersion_With_401_AUTH_205( body.Should().Contain("\"AUTH_205\""); } + [Fact] + public async Task Middleware_Descarta_Un_Portador_Fabricado_A_Mano() + { + // El ataque que G-217 dejaba pasar: nadie necesita el secreto para escribir un grafo de + // administrador; le basta con codificarlo en base64 y firmar con lo que sea. + var falsificado = BuildSignedJwt( + AuthGraphBuilder.ForTenant("LOGISTICS_CORE").WithScope("PURCHASE_ORDER.DELETE").Build(), + "no-es-el-secreto-de-ums"); + + AuthorizationGraph? observed = null; + using var host = await BuildHost(observe => observed = observe); + using var client = host.GetTestServer().CreateClient(); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", falsificado); + + var response = await client.GetAsync("/probe"); + response.IsSuccessStatusCode.Should().BeTrue(); + observed.Should().BeNull("un portador que no firmo UMS no aporta grafo"); + } + + [Fact] + public async Task Middleware_Descarta_Un_Portador_Con_Alg_None() + { + // Confusion de algoritmo: si se acepta el `alg` que declara el propio token, declarar `none` + // desactiva la comprobacion de firma desde fuera. + var graph = AuthGraphBuilder.ForTenant("LOGISTICS_CORE").WithScope("PURCHASE_ORDER.DELETE").Build(); + var header = Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")); + var payload = Base64UrlEncode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { graph }))); + + AuthorizationGraph? observed = null; + using var host = await BuildHost(observe => observed = observe); + using var client = host.GetTestServer().CreateClient(); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $"{header}.{payload}."); + + var response = await client.GetAsync("/probe"); + response.IsSuccessStatusCode.Should().BeTrue(); + observed.Should().BeNull("`alg: none` no es una firma valida"); + } + private static async Task BuildHost( Action observe, Action? configure = null) @@ -92,6 +133,7 @@ private static async Task BuildHost( .ConfigureServices(services => { services.AddUmsAuthGraphMiddleware(); + services.Configure(o => o.SigningSecret = SecretoDePrueba); if (configure is not null) services.Configure(configure); services.AddRouting(); }) @@ -114,15 +156,24 @@ private static async Task BuildHost( return host; } - private static string BuildFakeJwt(AuthorizationGraph graph) + private const string SecretoDePrueba = "secreto-de-prueba-de-al-menos-32-bytes-para-hs256"; + + /// + /// Portador firmado de verdad, como el que emite UMS. + /// + /// Antes de G-217 esto producia un token con alg: none y firma vacia, y las pruebas + /// pasaban: el middleware no miraba la firma. La prueba reflejaba el agujero en vez de + /// detectarlo. + /// + private static string BuildSignedJwt(AuthorizationGraph graph, string secreto = SecretoDePrueba) { - // Header.Payload.Signature — JWT-style three-segment compact serialization. Signature is - // irrelevant for the middleware since it does NOT validate the signature here (verification - // is expected to happen in an upstream authentication step). - var header = Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")); - var payloadJson = JsonSerializer.Serialize(new { graph }); - var payload = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); - return $"{header}.{payload}."; + var header = Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")); + var payload = Base64UrlEncode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { graph }))); + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secreto)); + var firma = Base64UrlEncode(hmac.ComputeHash(Encoding.ASCII.GetBytes($"{header}.{payload}"))); + + return $"{header}.{payload}.{firma}"; } private static string Base64UrlEncode(byte[] bytes) => diff --git a/src/libs/sdk/nestjs/package-lock.json b/src/libs/sdk/nestjs/package-lock.json index 5a4a2af9..1fad5fc5 100644 --- a/src/libs/sdk/nestjs/package-lock.json +++ b/src/libs/sdk/nestjs/package-lock.json @@ -11,10 +11,10 @@ "sdk-nestjs" ], "devDependencies": { - "@nestjs/common": "11.1.24", - "@nestjs/core": "11.1.24", - "@nestjs/platform-express": "11.1.24", - "@nestjs/testing": "11.1.24", + "@nestjs/common": "11.1.28", + "@nestjs/core": "11.1.28", + "@nestjs/platform-express": "11.1.28", + "@nestjs/testing": "11.1.28", "@swc/core": "1.15.40", "@types/node": "22.19.20", "@types/supertest": "6.0.3", @@ -63,21 +63,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -86,9 +86,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -156,14 +156,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -175,9 +175,9 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.24", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.24.tgz", - "integrity": "sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz", + "integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==", "license": "MIT", "dependencies": { "file-type": "21.3.4", @@ -206,13 +206,11 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.24", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.24.tgz", - "integrity": "sha512-K4bzT+lEdd0Hhcsw3jtk56QAW6s6skK3ViN7hIROSN0kUf4ROwWEAKopJID6yhPQxB45kDtP2wEcjzE8171J3g==", - "hasInstallScript": true, + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz", + "integrity": "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==", "license": "MIT", "dependencies": { - "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", @@ -247,15 +245,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.24", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.24.tgz", - "integrity": "sha512-CeMKbRBm05aOBiWhIHWO2xDeHbxynBF9ySQv3gRjObz2N5+uJnYriAYkHvVqvC4JIydmMPmT5VdICFNlNz3qyA==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz", + "integrity": "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==", "devOptional": true, "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.1.1", + "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, @@ -269,9 +267,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.1.24", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.24.tgz", - "integrity": "sha512-+4M4UAnhtprBQN0J2uI6IP0wDqhy9aH8XCMu5SO8oCi0oB04YXA4a4PAEkxmsPn7gHW4dj1u4GFteNQOWgvTJw==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.28.tgz", + "integrity": "sha512-B+VgRxeLaH7jkOMgAyUP3N3rpFlisQ7JRxixRbgHvG6a0VgKbbkNSofKExexCgKmQQak80undb3+2kE1lUBmRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -309,26 +307,10 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nuxt/opencollective": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", - "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - }, - "bin": { - "opencollective": "bin/opencollective.js" - }, - "engines": { - "node": "^14.18.0 || >=16.10.0", - "npm": ">=5.10.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -346,9 +328,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -363,9 +345,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -380,9 +362,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -397,9 +379,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -414,9 +396,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -431,9 +413,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -448,9 +430,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -465,9 +447,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -482,9 +464,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -499,9 +481,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -516,9 +498,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -533,9 +515,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -550,9 +532,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -560,18 +542,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -586,9 +568,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -632,13 +614,6 @@ } } }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -899,9 +874,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.26", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", - "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", + "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -932,9 +907,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -992,9 +967,9 @@ } }, "node_modules/@types/superagent": { - "version": "8.1.10", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", - "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", "dev": true, "license": "MIT", "dependencies": { @@ -1076,6 +1051,16 @@ } } }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/@vitest/pretty-format": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", @@ -1158,37 +1143,10 @@ "node": ">= 0.6" } }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -1230,21 +1188,21 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "devOptional": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1254,6 +1212,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1363,15 +1335,6 @@ "typedarray": "^0.0.6" } }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1559,9 +1522,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -1602,14 +1565,11 @@ "license": "MIT" }, "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } + "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", @@ -1622,9 +1582,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1675,33 +1635,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1767,22 +1700,45 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -1962,9 +1918,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2032,9 +1988,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2048,23 +2004,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -2083,9 +2039,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -2104,9 +2060,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -2125,9 +2081,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -2146,9 +2102,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -2167,9 +2123,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -2188,9 +2144,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -2209,9 +2165,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -2230,9 +2186,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -2251,9 +2207,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -2272,9 +2228,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -2374,10 +2330,23 @@ "node": ">= 0.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "devOptional": true, "license": "MIT", "engines": { @@ -2385,16 +2354,20 @@ } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "devOptional": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ms": { @@ -2404,9 +2377,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", - "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2433,6 +2406,29 @@ "node": ">= 0.6" } }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/multer/node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2448,9 +2444,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2500,9 +2496,9 @@ } }, "node_modules/obug": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", - "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2571,9 +2567,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2584,9 +2580,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", "dev": true, "funding": [ { @@ -2604,7 +2600,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2627,13 +2623,14 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -2643,13 +2640,17 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { @@ -2690,13 +2691,13 @@ "license": "Apache-2.0" }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2706,21 +2707,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/router": { @@ -2804,33 +2805,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/send/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -2859,15 +2833,15 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -2969,9 +2943,9 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -3031,19 +3005,6 @@ "node": ">=14.18.0" } }, - "node_modules/superagent/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/supertest": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", @@ -3170,33 +3131,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -3308,16 +3242,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -3334,7 +3268,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/src/libs/sdk/nestjs/package.json b/src/libs/sdk/nestjs/package.json index f8a8a2d8..fcfe9357 100644 --- a/src/libs/sdk/nestjs/package.json +++ b/src/libs/sdk/nestjs/package.json @@ -18,10 +18,10 @@ "unplugin-swc": "1.5.9", "@swc/core": "1.15.40", "@types/node": "22.19.20", - "@nestjs/common": "11.1.24", - "@nestjs/core": "11.1.24", - "@nestjs/testing": "11.1.24", - "@nestjs/platform-express": "11.1.24", + "@nestjs/common": "11.1.28", + "@nestjs/core": "11.1.28", + "@nestjs/testing": "11.1.28", + "@nestjs/platform-express": "11.1.28", "reflect-metadata": "0.2.2", "rxjs": "7.8.2", "supertest": "7.2.2", diff --git a/src/libs/sdk/nestjs/sdk-nestjs/README.md b/src/libs/sdk/nestjs/sdk-nestjs/README.md index 8bb1d1be..0ebf53cd 100644 --- a/src/libs/sdk/nestjs/sdk-nestjs/README.md +++ b/src/libs/sdk/nestjs/sdk-nestjs/README.md @@ -19,7 +19,7 @@ import { UmsSdkModule, UmsAuthGuard, AuthGraphMiddleware, AuthorizationDeniedFilter } from '@ums/sdk-nestjs'; @Module({ - imports: [UmsSdkModule.forRoot({ mode: 'enforce' })], + imports: [UmsSdkModule.forRoot({ mode: 'enforce', signingSecret: process.env.UMS_JWT_SECRET! })], providers: [ { provide: APP_GUARD, useClass: UmsAuthGuard }, { provide: APP_FILTER, useClass: AuthorizationDeniedFilter } diff --git a/src/libs/sdk/nestjs/sdk-nestjs/src/middleware.ts b/src/libs/sdk/nestjs/sdk-nestjs/src/middleware.ts index 0c3e9c88..e572e35e 100644 --- a/src/libs/sdk/nestjs/sdk-nestjs/src/middleware.ts +++ b/src/libs/sdk/nestjs/sdk-nestjs/src/middleware.ts @@ -1,32 +1,77 @@ import { Inject, Injectable, type NestMiddleware } from '@nestjs/common'; import type { AuthorizationGraph } from '@ums/sdk-contracts'; -import { AsyncLocalAuthGraphAccessor, MemoryAuthGraphAccessor, type AuthGraphAccessor } from '@ums/sdk-authorization'; -import { UMS_AUTH_GRAPH_ACCESSOR } from './tokens.js'; +import { + AsyncLocalAuthGraphAccessor, + MemoryAuthGraphAccessor, + opcionesDeVerificacion, + verificarFirma, + type AuthGraphAccessor, + type OpcionesDeVerificacion +} from '@ums/sdk-authorization'; +import { UMS_AUTH_GRAPH_ACCESSOR, UMS_SDK_OPTIONS } from './tokens.js'; +import type { UmsSdkModuleOptions } from './options.js'; type ExpressRequest = { readonly headers: Record; - readonly umsAuthGraph?: AuthorizationGraph; }; type ExpressResponse = unknown; type NextFn = (err?: unknown) => void; /** - * Express-style middleware that: - * 1. Reads a fake or pre-parsed AuthorizationGraph from `req.umsAuthGraph` (set by an upstream - * authenticator — production deployments will replace this with a JWT-decoding step). - * 2. Binds the graph to the configured accessor for the duration of the request, so the - * `UmsAuthGuard` reads it via `AuthGraphAccessor.current()`. + * Toma el grafo de autorización del portador de la petición y lo deja disponible durante toda su + * ejecución, para que `UmsAuthGuard` lo lea con `AuthGraphAccessor.current()`. * - * For Phase 2 we'll add JWT body parsing + a refresh call to `POST /api/v1/client/authenticate`. + * Antes de G-217 este middleware leía `req.umsAuthGraph` —un campo que ponía «alguien más arriba»— + * y lo daba por bueno. Ese campo lo puede escribir cualquier middleware anterior, y nadie + * comprobaba de dónde salía: bastaba con que un middleware mal hecho copiase ahí algo del cuerpo de + * la petición para que el guardia autorizara con un grafo escrito por quien llamaba. Delegar la + * verificación en un tercero que no existe no es delegar: es no verificar. + * + * Ahora el grafo solo puede venir de un sitio: un portador que UMS firmó. */ @Injectable() export class AuthGraphMiddleware implements NestMiddleware { - constructor(@Inject(UMS_AUTH_GRAPH_ACCESSOR) private readonly accessor: AuthGraphAccessor) {} + /** + * Se arma UNA vez, en el constructor, porque el middleware es un singleton del módulo. Si la + * caché del JWKS se creara por petición no cachearía nada: habría un GET al emisor por token. + */ + private readonly verificacion: OpcionesDeVerificacion; + + constructor( + @Inject(UMS_AUTH_GRAPH_ACCESSOR) private readonly accessor: AuthGraphAccessor, + @Inject(UMS_SDK_OPTIONS) private readonly options: UmsSdkModuleOptions + ) { + this.verificacion = opcionesDeVerificacion(options); + } use(req: ExpressRequest, _res: ExpressResponse, next: NextFn): void { - const graph = req.umsAuthGraph ?? null; + // Asíncrono porque RS256 puede tener que consultar el JWKS del emisor. El grafo se enlaza + // dentro de la promesa, siempre antes de llamar a `next()`, para que el guardia lo vea. + void this.extraerGrafoVerificado(req) + .then((grafo) => this.runWith(grafo, next)) + .catch(() => this.runWith(null, next)); + } + + private async extraerGrafoVerificado(req: ExpressRequest): Promise { + const cabecera = req.headers?.['authorization'] ?? req.headers?.['Authorization']; + const valor = Array.isArray(cabecera) ? cabecera[0] : cabecera; + if (!valor?.startsWith('Bearer ')) return null; + + const verificacion = await verificarFirma(valor.slice(7).trim(), this.verificacion); + if (!verificacion.valido) return null; + + const claim = verificacion.cuerpo[this.options.jwtBodyClaim ?? 'graph']; + try { + if (claim && typeof claim === 'object') return claim as AuthorizationGraph; + if (typeof claim === 'string') return JSON.parse(claim) as AuthorizationGraph; + } catch { + return null; + } + return null; + } + private runWith(graph: AuthorizationGraph | null, next: NextFn): void { if (this.accessor instanceof AsyncLocalAuthGraphAccessor) { this.accessor.run(graph, () => next()); return; @@ -36,7 +81,7 @@ export class AuthGraphMiddleware implements NestMiddleware=1.0.0 <2.0.0', mode: input?.mode ?? 'enforce', accessor: input?.accessor, @@ -66,7 +81,7 @@ const EXPORTS = [ * Example: * ```ts * @Module({ - * imports: [UmsSdkModule.forRoot({ mode: 'enforce' })], + * imports: [UmsSdkModule.forRoot({ mode: 'enforce', signingSecret: process.env.UMS_JWT_SECRET! })], * providers: [ * { provide: APP_GUARD, useClass: UmsAuthGuard }, * { provide: APP_FILTER, useClass: AuthorizationDeniedFilter } @@ -82,7 +97,7 @@ const EXPORTS = [ @Global() @Module({}) export class UmsSdkModule { - static forRoot(options?: UmsSdkModuleOptions): DynamicModule { + static forRoot(options: UmsSdkModuleOptions): DynamicModule { const resolved = defaultOptions(options); return { module: UmsSdkModule, diff --git a/src/libs/sdk/nestjs/sdk-nestjs/src/options.ts b/src/libs/sdk/nestjs/sdk-nestjs/src/options.ts index b0251066..9b1a8235 100644 --- a/src/libs/sdk/nestjs/sdk-nestjs/src/options.ts +++ b/src/libs/sdk/nestjs/sdk-nestjs/src/options.ts @@ -1,14 +1,40 @@ -import type { AuthGraphAccessor, AuthorizationValidator, AuthorizationLogger } from '@ums/sdk-authorization'; +import type { + AuthGraphAccessor, + AuthorizationValidator, + AuthorizationLogger, + OpcionesDeJwks +} from '@ums/sdk-authorization'; import type { ModuleMetadata, Type } from '@nestjs/common'; export type UmsAuthorizationMode = 'enforce' | 'audit-only'; /** - * Configuration passed to `UmsSdkModule.forRoot()`. All fields are optional — sensible defaults - * keep small consumers concise: a fresh AuthorizationValidator, no accessor (you provide one), - * enforce mode, no audit logger. + * Configuración de `UmsSdkModule.forRoot()`. Hay que declarar material de verificación —`umsBaseUrl` + * o `signingSecret`—; el resto tiene un valor por defecto razonable: un AuthorizationValidator + * nuevo, sin accessor (lo pones tú), modo `enforce` y sin registro de auditoría. */ export interface UmsSdkModuleOptions { + /** + * Secreto con el que UMS firma sus portadores en HS256. + * + * Ya no es obligatorio: desde ADR-0157 UMS firma en RS256 y publica su clave pública, así que lo + * normal es declarar `umsBaseUrl` y no custodiar secreto ninguno. Sigue existiendo para los + * despliegues que todavía no han cruzado. + * + * **Hay que declarar al menos uno**: este o `umsBaseUrl`/`jwksUri`. Sin ninguno, el módulo falla + * al construirse, porque sin material de verificación se cree el grafo que le llegue (G-217). + */ + readonly signingSecret?: string; + + /** URI del JWKS, si no cuelga de `umsBaseUrl` en la ruta estándar. */ + readonly jwksUri?: string; + + /** Ajustes finos de la caché del JWKS (TTL, cadencia de refresco, tiempo de espera). */ + readonly jwks?: Partial; + + /** Claim del cuerpo del portador que transporta el grafo. Por defecto `'graph'`. */ + readonly jwtBodyClaim?: string; + /** Compatibility range advertised by the SDK to consumers. Defaults to `">=1.0.0 <2.0.0"`. */ readonly schemaCompatibility?: string; @@ -25,8 +51,11 @@ export interface UmsSdkModuleOptions { readonly logger?: AuthorizationLogger; /** - * Optional UMS base URL — reserved for the Phase 2 HTTP client. Currently unused by the - * guard/decorator surface but accepted so app code can declare it once. + * Base de UMS. De ella sale su JWKS (`{base}/.well-known/jwks.json`), y declararla habilita la + * verificación RS256. + * + * Declarar solo esto es lo deseable: el satélite verifica sin custodiar material de firma, y por + * tanto no puede emitir portadores aunque quisiera. */ readonly umsBaseUrl?: string; } diff --git a/src/libs/sdk/nestjs/sdk-nestjs/tests/firma-rs256.test.ts b/src/libs/sdk/nestjs/sdk-nestjs/tests/firma-rs256.test.ts new file mode 100644 index 00000000..2ce50da7 --- /dev/null +++ b/src/libs/sdk/nestjs/sdk-nestjs/tests/firma-rs256.test.ts @@ -0,0 +1,178 @@ +import 'reflect-metadata'; +import { constants, createHmac, generateKeyPairSync, sign } from 'node:crypto'; +import { describe, it, expect, afterEach } from 'vitest'; +import { Test } from '@nestjs/testing'; +import { + Controller, + Get, + type INestApplication, + type MiddlewareConsumer, + Module, + type NestModule +} from '@nestjs/common'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; +import request from 'supertest'; +import type { AuthorizationGraph } from '@ums/sdk-contracts'; +import { MemoryAuthGraphAccessor } from '@ums/sdk-authorization'; +import { AuthGraphBuilder } from '@ums/sdk-testing'; +import { + AuthGraphMiddleware, + AuthorizationDeniedFilter, + RequiresScope, + UmsAuthGuard, + UmsSdkModule +} from '../src/index.js'; + +/** + * ADR-0157: UMS firma en RS256 y publica su clave pública en el JWKS. El middleware de este SDK + * exigía HS256, así que rechazaba todos los portadores buenos. + * + * Se comprueba de extremo a extremo —petición HTTP real contra un módulo Nest montado— porque el + * fallo que se corrige no estaba en la función de verificar sino en cómo la usaba el middleware. + */ + +@Controller('pedidos') +class PedidosController { + @Get('aprobar') + @RequiresScope('PURCHASE_ORDER.APPROVE') + aprobar(): { ok: true } { + return { ok: true }; + } +} + +const SECRETO = 'secreto-de-prueba-de-al-menos-32-bytes-para-hs256'; +const KID = 'kid-de-prueba'; +const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url'); + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const jwkPublica = publicKey.export({ format: 'jwk' }) as Record; + +/** JWKS servido en memoria: la prueba no debe depender de que haya un UMS levantado. */ +const jwksEnMemoria = (async () => ({ + ok: true, + text: async () => JSON.stringify({ + keys: [{ kty: 'RSA', use: 'sig', alg: 'RS256', kid: KID, n: jwkPublica['n'], e: jwkPublica['e'] }], + }), +} as unknown as Response)) as unknown as typeof fetch; + +function tokenRs256(graph: AuthorizationGraph, clave = privateKey, kid = KID): string { + const cabecera = b64({ alg: 'RS256', typ: 'JWT', kid }); + const cuerpo = b64({ graph }); + const firma = sign('sha256', Buffer.from(`${cabecera}.${cuerpo}`, 'ascii'), + { key: clave, padding: constants.RSA_PKCS1_PADDING }).toString('base64url'); + return `${cabecera}.${cuerpo}.${firma}`; +} + +function tokenHs256(graph: AuthorizationGraph, secreto = SECRETO): string { + const cabecera = b64({ alg: 'HS256', typ: 'JWT' }); + const cuerpo = b64({ graph }); + const firma = createHmac('sha256', secreto).update(`${cabecera}.${cuerpo}`).digest('base64url'); + return `${cabecera}.${cuerpo}.${firma}`; +} + +const GRAFO = AuthGraphBuilder.forTenant('LOGISTICS_CORE').withScope('PURCHASE_ORDER.APPROVE').build(); + +async function montar(opciones: Record): Promise { + @Module({ + imports: [ + UmsSdkModule.forRoot({ + mode: 'enforce', + accessor: new MemoryAuthGraphAccessor(), + ...opciones + } as never) + ], + controllers: [PedidosController], + providers: [ + { provide: APP_GUARD, useClass: UmsAuthGuard }, + { provide: APP_FILTER, useClass: AuthorizationDeniedFilter } + ] + }) + class AppDePrueba implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(AuthGraphMiddleware).forRoutes('*'); + } + } + + const modulo = await Test.createTestingModule({ imports: [AppDePrueba] }).compile(); + const app = modulo.createNestApplication(); + await app.init(); + return app; +} + +let app: INestApplication | null = null; +afterEach(async () => { + await app?.close(); + app = null; +}); + +/** Satélite tal como debe quedar tras ADR-0157: verifica sin custodiar material de firma. */ +const SOLO_RS256 = { umsBaseUrl: 'https://ums', jwks: { fetchImpl: jwksEnMemoria } }; + +describe('ADR-0157 · el middleware de NestJS verifica los portadores RS256', () => { + it('acepta el portador que UMS firma con su clave privada', async () => { + app = await montar(SOLO_RS256); + await request(app.getHttpServer()) + .get('/pedidos/aprobar') + .set('authorization', `Bearer ${tokenRs256(GRAFO)}`) + .expect(200); + }); + + it('RECHAZA uno firmado con otra clave, aunque declare el `kid` bueno', async () => { + const ajena = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + app = await montar(SOLO_RS256); + await request(app.getHttpServer()) + .get('/pedidos/aprobar') + .set('authorization', `Bearer ${tokenRs256(GRAFO, ajena)}`) + .expect(403); + }); + + it('RECHAZA un `kid` que no está en el JWKS', async () => { + app = await montar(SOLO_RS256); + await request(app.getHttpServer()) + .get('/pedidos/aprobar') + .set('authorization', `Bearer ${tokenRs256(GRAFO, privateKey, 'kid-inventado')}`) + .expect(403); + }); + + it('RECHAZA `alg: none`', async () => { + app = await montar(SOLO_RS256); + const token = `${b64({ alg: 'none', typ: 'JWT', kid: KID })}.${b64({ graph: GRAFO })}.`; + await request(app.getHttpServer()) + .get('/pedidos/aprobar') + .set('authorization', `Bearer ${token}`) + .expect(403); + }); + + it('configurado para RS256, un HS256 NO se cuela', async () => { + // La degradación que no puede ocurrir: el satélite espera RS256 y le presentan un HS256. + app = await montar(SOLO_RS256); + await request(app.getHttpServer()) + .get('/pedidos/aprobar') + .set('authorization', `Bearer ${tokenHs256(GRAFO)}`) + .expect(403); + }); + + it('HS256 sigue funcionando donde se configuró así', async () => { + app = await montar({ signingSecret: SECRETO }); + await request(app.getHttpServer()) + .get('/pedidos/aprobar') + .set('authorization', `Bearer ${tokenHs256(GRAFO)}`) + .expect(200); + }); + + it('durante la transición admite las dos vías, y ninguna se relaja', async () => { + app = await montar({ signingSecret: SECRETO, ...SOLO_RS256 }); + const servidor = app.getHttpServer(); + + await request(servidor).get('/pedidos/aprobar') + .set('authorization', `Bearer ${tokenRs256(GRAFO)}`).expect(200); + await request(servidor).get('/pedidos/aprobar') + .set('authorization', `Bearer ${tokenHs256(GRAFO)}`).expect(200); + await request(servidor).get('/pedidos/aprobar') + .set('authorization', `Bearer ${tokenHs256(GRAFO, 'otro-secreto-igual-de-largo-pero-ajeno')}`).expect(403); + }); + + it('el módulo falla al construirse si no se le da con qué verificar', () => { + expect(() => UmsSdkModule.forRoot({} as never)).toThrow(/umsBaseUrl|signingSecret/); + }); +}); diff --git a/src/libs/sdk/nestjs/sdk-nestjs/tests/guard.test.ts b/src/libs/sdk/nestjs/sdk-nestjs/tests/guard.test.ts index be5ed26c..6700c9ea 100644 --- a/src/libs/sdk/nestjs/sdk-nestjs/tests/guard.test.ts +++ b/src/libs/sdk/nestjs/sdk-nestjs/tests/guard.test.ts @@ -1,4 +1,5 @@ import 'reflect-metadata'; +import { createHmac } from 'node:crypto'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { Test, type TestingModule } from '@nestjs/testing'; import { @@ -48,13 +49,33 @@ class OrdersController { } } -/** Test-only middleware: places a pre-built graph onto req.umsAuthGraph based on the `x-graph` header. */ +const SECRETO_DE_PRUEBA = 'secreto-de-prueba-de-al-menos-32-bytes-para-hs256'; + +const b64url = (b: Buffer): string => b.toString('base64url'); + +/** + * Emite un portador firmado como el de UMS. + * + * Antes estas pruebas escribían el grafo directamente en `req.umsAuthGraph` y pasaban: el + * middleware se creía lo que hubiera ahí. Ahora el grafo entra por donde entra en producción —un + * portador firmado— y la prueba ejercita la verificación en vez de saltársela (G-217). + */ +function firmar(graph: AuthorizationGraph | null, secreto = SECRETO_DE_PRUEBA, alg = 'HS256'): string { + const cabecera = b64url(Buffer.from(JSON.stringify({ alg, typ: 'JWT' }))); + const cuerpo = b64url(Buffer.from(JSON.stringify({ graph }))); + if (alg === 'none') return `${cabecera}.${cuerpo}.`; + const firma = createHmac('sha256', secreto).update(`${cabecera}.${cuerpo}`).digest('base64url'); + return `${cabecera}.${cuerpo}.${firma}`; +} + +/** Middleware de prueba: traduce la cabecera `x-graph` en un portador firmado de verdad. */ function buildTestModule(accessor: MemoryAuthGraphAccessor, graphs: Map): Promise { @Module({ imports: [ UmsSdkModule.forRoot({ mode: 'enforce', - accessor + accessor, + signingSecret: SECRETO_DE_PRUEBA }) ], controllers: [OrdersController], @@ -66,9 +87,10 @@ function buildTestModule(accessor: MemoryAuthGraphAccessor, graphs: Map; umsAuthGraph?: AuthorizationGraph | null }, _res: unknown, next: (err?: unknown) => void) => { + .apply((req: { headers: Record }, _res: unknown, next: (err?: unknown) => void) => { const key = (req.headers['x-graph'] as string | undefined) ?? ''; - req.umsAuthGraph = graphs.get(key) ?? null; + const graph = graphs.get(key); + if (graph) req.headers['authorization'] = `Bearer ${firmar(graph)}`; next(); }, AuthGraphMiddleware) .forRoutes('*'); @@ -144,6 +166,32 @@ describe('UmsAuthGuard (e2e with NestJS Testing + supertest)', () => { expect(res.status).toBe(200); expect(res.body).toEqual({ id: 'abc' }); }); + + it('un portador fabricado a mano no otorga nada (G-217)', async () => { + // El ataque: nadie necesita el secreto para escribir un grafo con el permiso que quiera. + const falsificado = firmar( + AuthGraphBuilder.forTenant('LOGISTICS_CORE').withScope('PURCHASE_ORDER.APPROVE').build(), + 'no-es-el-secreto-de-ums' + ); + const res = await request(server).post('/orders/abc/approve').set('authorization', `Bearer ${falsificado}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('AUTH_202'); + }); + + it('un portador con `alg: none` no otorga nada (confusión de algoritmo)', async () => { + const sinFirma = firmar( + AuthGraphBuilder.forTenant('LOGISTICS_CORE').withScope('PURCHASE_ORDER.APPROVE').build(), + SECRETO_DE_PRUEBA, + 'none' + ); + const res = await request(server).post('/orders/abc/approve').set('authorization', `Bearer ${sinFirma}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('AUTH_202'); + }); + + it('el módulo no arranca sin `signingSecret`', () => { + expect(() => UmsSdkModule.forRoot({ mode: 'enforce' } as never)).toThrow(/signingSecret/); + }); }); describe('UmsAuthGuard — global guard via UseGuards on a single controller', () => { @@ -165,16 +213,17 @@ describe('UmsAuthGuard — global guard via UseGuards on a single controller', ( graphs.set('admin', AuthGraphBuilder.forTenant('LOGISTICS_CORE').withScope('ADMIN.RESET').build()); @Module({ - imports: [UmsSdkModule.forRoot({ mode: 'enforce', accessor })], + imports: [UmsSdkModule.forRoot({ mode: 'enforce', accessor, signingSecret: SECRETO_DE_PRUEBA })], controllers: [AdminController], providers: [{ provide: APP_FILTER, useClass: AuthorizationDeniedFilter }] }) class TestAppModule implements NestModule { configure(consumer: MiddlewareConsumer): void { consumer - .apply((req: { headers: Record; umsAuthGraph?: AuthorizationGraph | null }, _res: unknown, next: (err?: unknown) => void) => { + .apply((req: { headers: Record }, _res: unknown, next: (err?: unknown) => void) => { const key = (req.headers['x-graph'] as string | undefined) ?? ''; - req.umsAuthGraph = graphs.get(key) ?? null; + const graph = graphs.get(key); + if (graph) req.headers['authorization'] = `Bearer ${firmar(graph)}`; next(); }, AuthGraphMiddleware) .forRoutes('*'); diff --git a/src/libs/sdk/typescript/sdk-authorization/README.md b/src/libs/sdk/typescript/sdk-authorization/README.md index c030995b..8c035ac3 100644 --- a/src/libs/sdk/typescript/sdk-authorization/README.md +++ b/src/libs/sdk/typescript/sdk-authorization/README.md @@ -20,14 +20,16 @@ import { configureAuthorization, requireScope } from '@ums/sdk-authorization'; +import { umsAuthGraph } from '@ums/sdk-express'; const accessor = new AsyncLocalAuthGraphAccessor(); configureAuthorization({ accessor, validator: new AuthorizationValidator() }); const app = express(); -// upstream middleware parses JWT body → req.umsAuthGraph -app.use((req, _res, next) => accessor.run((req as any).umsAuthGraph ?? null, () => next())); +// El grafo se toma del portador FIRMADO por UMS, nunca de un campo que ponga otro middleware: +// `req.umsAuthGraph` lo puede escribir cualquiera de la cadena, y eso era el agujero de G-217. +app.use(umsAuthGraph({ accessor, signingSecret: process.env.UMS_JWT_SECRET! })); app.post( '/orders/:id/approve', diff --git a/src/libs/sdk/typescript/sdk-authorization/src/authorization-validator.ts b/src/libs/sdk/typescript/sdk-authorization/src/authorization-validator.ts index cb8139ef..67e06281 100644 --- a/src/libs/sdk/typescript/sdk-authorization/src/authorization-validator.ts +++ b/src/libs/sdk/typescript/sdk-authorization/src/authorization-validator.ts @@ -2,6 +2,7 @@ import type { AuthorizationGraph } from '@ums/sdk-contracts'; import { isSchemaVersionSupported } from '@ums/sdk-contracts'; import { UmsErrorCodes } from '@ums/sdk-contracts'; import { Decisions, type AuthorizationDecision } from './authorization-decision.js'; +import { GraphIndex } from './graph-index.js'; const PrimScope = 'RequiresScope'; const PrimMenu = 'RequiresMenuOption'; @@ -42,7 +43,10 @@ export class AuthorizationValidator { ); } - return graph!.scopes.includes(scope) + // Los scopes viajan en minúsculas (UMS los deriva con ToLowerInvariant), pero el + // llamador los escribe con el código tal como lo conoce: 'STOCK_VIEW.VIEW'. Comparar + // sensible a mayúsculas convertía esa diferencia de forma en un 403 (G-167). + return GraphIndex.for(graph!).hasScope(scope) ? Decisions.granted(PrimScope, scope, graph!.validUntil) : Decisions.notGranted( PrimScope, @@ -57,33 +61,27 @@ export class AuthorizationValidator { const pre = this.preCheck(graph, PrimMenu, optionCode); if (pre !== null) return pre; - for (const module of graph!.menuAccess) { - for (const menu of module.menus) { - for (const sub of menu.subMenus) { - for (const opt of sub.options) { - if (opt.code !== optionCode) continue; - switch (opt.effect) { - case 'Allow': - return Decisions.granted(PrimMenu, optionCode, graph!.validUntil); - case 'Deny': - return Decisions.deny( - PrimMenu, - optionCode, - UmsErrorCodes.MenuOptionDenied, - `Menu option '${optionCode}' is explicitly denied (source: ${opt.source}).`, - graph!.validUntil - ); - default: - return Decisions.notGranted( - PrimMenu, - optionCode, - UmsErrorCodes.MenuOptionNotGranted, - `Menu option '${optionCode}' resolves to NotGranted.`, - graph!.validUntil - ); - } - } - } + const opt = GraphIndex.for(graph!).option(optionCode); + if (opt !== undefined) { + switch (opt.effect) { + case 'Allow': + return Decisions.granted(PrimMenu, optionCode, graph!.validUntil); + case 'Deny': + return Decisions.deny( + PrimMenu, + optionCode, + UmsErrorCodes.MenuOptionDenied, + `Menu option '${optionCode}' is explicitly denied (source: ${opt.source}).`, + graph!.validUntil + ); + default: + return Decisions.notGranted( + PrimMenu, + optionCode, + UmsErrorCodes.MenuOptionNotGranted, + `Menu option '${optionCode}' resolves to NotGranted.`, + graph!.validUntil + ); } } @@ -105,30 +103,27 @@ export class AuthorizationValidator { const pre = this.preCheck(graph, PrimDomain, target); if (pre !== null) return pre; - for (const dr of graph!.domainPermissions) { - if (dr.resource.code !== resourceCode) continue; - for (const act of dr.actions) { - if (act.actionCode !== actionCode) continue; - switch (act.effect) { - case 'Allow': - return Decisions.granted(PrimDomain, target, graph!.validUntil); - case 'Deny': - return Decisions.deny( - PrimDomain, - target, - UmsErrorCodes.DomainAccessDenied, - `Domain access '${target}' is explicitly denied (source: ${act.source}).`, - graph!.validUntil - ); - default: - return Decisions.notGranted( - PrimDomain, - target, - UmsErrorCodes.DomainAccessNotGranted, - `Domain access '${target}' resolves to NotGranted.`, - graph!.validUntil - ); - } + const act = GraphIndex.for(graph!).domain(resourceCode, actionCode); + if (act !== undefined) { + switch (act.effect) { + case 'Allow': + return Decisions.granted(PrimDomain, target, graph!.validUntil); + case 'Deny': + return Decisions.deny( + PrimDomain, + target, + UmsErrorCodes.DomainAccessDenied, + `Domain access '${target}' is explicitly denied (source: ${act.source}).`, + graph!.validUntil + ); + default: + return Decisions.notGranted( + PrimDomain, + target, + UmsErrorCodes.DomainAccessNotGranted, + `Domain access '${target}' resolves to NotGranted.`, + graph!.validUntil + ); } } @@ -196,23 +191,10 @@ function parseScope(scope: string): { resource: string; action: string } | null return { resource: scope.substring(0, idx), action: scope.substring(idx + 1) }; } +/** + * Denegación explícita en cualquiera de las dos superficies del grafo. Delega en el índice: + * antes recorría el árbol de menús completo y todos los recursos en cada comprobación. + */ function isDeniedInGraph(graph: AuthorizationGraph, resourceCode: string, actionCode: string): boolean { - for (const module of graph.menuAccess) { - for (const menu of module.menus) { - for (const sub of menu.subMenus) { - for (const opt of sub.options) { - if (opt.effect === 'Deny' && opt.code === resourceCode && opt.actionCode === actionCode) { - return true; - } - } - } - } - } - for (const dr of graph.domainPermissions) { - if (dr.resource.code !== resourceCode) continue; - for (const act of dr.actions) { - if (act.effect === 'Deny' && act.actionCode === actionCode) return true; - } - } - return false; + return GraphIndex.for(graph).isDenied(resourceCode, actionCode); } diff --git a/src/libs/sdk/typescript/sdk-authorization/src/graph-index.ts b/src/libs/sdk/typescript/sdk-authorization/src/graph-index.ts new file mode 100644 index 0000000000000000000000000000000000000000..94bb6ad73a13e07cea0a69932c2bbf2874933982 GIT binary patch literal 4482 zcmbtXU2hvj6y-U;;vyl{iJVPAf=X#q2u)g5lu|VaUOnC&+e2q(%*?KfY4a2K5q(6c zPkk$I_>Xb!o!NLDI}|}49D8==ew=&mxwEF8`_NFcoNIcuM|9dYGat;asxjU@3Ti$Z zV)e||%DB^#7oY3sZOh;I$wRH0Ht2^|O)nbNXkL4&7G^32p86`S;D0(j+`FVn@HO4~ zs&%GT;c%`)d(_tPFjg-POYfSX$|l~rySI0AaIi-QwEE3erqonv!sFQ5M$vbJPxmSM zanRH{8f`~CDsYrfpIe#+HSwe)8CSQB8e7mlCG}vlsp{%J? z-t=;NZk}N|Lpz zR+SpkQoJlo1X|9AT3MPYptw-MsMrge?XnL(9ki=-DCnFwMBu}VbKMw;bqGVUlF_2U z^TRs`!vfd|Tu6yj__;NWF2M@5-xcA4ldL^xCFhMpRIW6NbI8?^+($oZ_2OGKA04x+ zQq^PZL3vJ}J*N5U{n(lkso~K~E$Qc0!*ME9;0zoRCq=b#p7TdstG9_dQA-Ui!ct5k zif!`S1^5i8pxm5{8SB7?cpFTNqzN7amtRJb>&4kOR6ITZ{+MbLYe1=E6I#hP!mBKT zPxb1J18xYHE5zY(yr^$PeSf!rh`2kXPF@UhUZ6;;?Ls*wB21++Gq}JCwKT3#9P`nh z{zV$Mv?@kg$0$ua4`zX0B%q8CU=&1lb_qbsnWhtR`Xyzg?`^lvuxIZ6U>}W0B&|qD z;#n{?@S%x8S(qK=>Rn|LSu>@F+#Wtu_(XoGcTD9A0t>i-by#qj&^7bbyJ#Ap8n6Ql z-rxr(|M~-Yh_ligumuJ|nZN#&Dy;hxYBe=Z+2f72;_ChI`25F{Q)~T8pnP`z$wP)Zah#LM&bUkpHPfX=PYMMCG=RD4mWMCFT~t@ZKv{@^O6^?f(ujm$Te2VZ zwCzX*QImF-_6Dit2RECkrI`x?0XYPmL50zCK$cZPl}RO#P8l@F3$m@=BnKluXBAGm z%6bRfj_}!4|Elb1-`C3r%(qm1^w>Sn*0||VXqb#hwLf4W?fg$>Dw!f*FL{Erq0AFu zhpFA;=BDT-m_0WpcYQwHWF(e;4t-7NT<9jF)A7Hy$9}u?oax@-FnRXgsVLR?j3CgRB?Bj@mpU@V5hjwWwWXCV>{1m+fg; z>KNq#xIC!o>*5KCN%iCnVDGhJEoYaD0MDMlm@o9 zcSPN+cW`FxDFe^RrOLk0OYp0g6&ALS<-oXCdlq(wmU>YFgsoknltjN6B zz>04w+@XwC!aVivGZ{(|+4X?yl`dt@r#cI}aQ+84u8wtI7>6$G4?(kAU#`P*!>)e? z5&GZ>MsgO5g;76YQe)251~<4^wiw2DnecomgrH;C8w^32+tbzrTuqkUJ{mka{Pc@E z+w>RXYv7Zw%)0cWP*pXMuSMgZ_?J37P*D%|Ghcf3U};>Nnyl~Yz4EQ8wH8vrjDK>2 zO@)g&g5DlRY-7)m_~&)O$&!hv{y}FTk*q;-&B~f*b(4f+vuWfGQ%vDoh?%`{5~4D6 za&iLksr1A+U9E*6Ka{J_+SP^)>cvnbH=Lrl8KzKrxe+LiJFZI)SY$G%6zp5OdGxqr0WwX9#?PWikNS=rMI|H@a4SN9P1fR77qo*%|m?L zeZ{m%YZ=BI<>-@F>hhycUX3qDSFt9Q!`!KKcL=BXR+?mkUayhdw6-mQTo5)S2niDX z+sTHHuAP}y|JQHUH}<}+YQflWFnv#{J0!BC0E527baC`gbZfxmZ0Gy$2k^N;Xm-Tf zkE9<#FJ|s6>9%XAUBBOaAf{gXavc1V4X&$(VRyk-p`pUH(Q$b@Bjb+ literal 0 HcmV?d00001 diff --git a/src/libs/sdk/typescript/sdk-authorization/src/index.ts b/src/libs/sdk/typescript/sdk-authorization/src/index.ts index 0bfb5ef7..d38dd9d8 100644 --- a/src/libs/sdk/typescript/sdk-authorization/src/index.ts +++ b/src/libs/sdk/typescript/sdk-authorization/src/index.ts @@ -8,3 +8,5 @@ export * from './primitives.js'; export * from './decorators.js'; export * from './result.js'; export * from './configure.js'; +export * from './verificacion-de-firma.js'; +export * from './jwks.js'; diff --git a/src/libs/sdk/typescript/sdk-authorization/src/jwks.ts b/src/libs/sdk/typescript/sdk-authorization/src/jwks.ts new file mode 100644 index 00000000..5cd7af3a --- /dev/null +++ b/src/libs/sdk/typescript/sdk-authorization/src/jwks.ts @@ -0,0 +1,309 @@ +import { createPublicKey, type KeyObject } from 'node:crypto'; + +/** + * Caché del JWKS del emisor: las claves públicas con las que se verifica un portador RS256. + * + * Existe por dos razones que tiran en direcciones opuestas y hay que conciliar: + * + * 1. **No se puede ir a la red en cada verificación.** La verificación está en la ruta caliente de + * cada petición de cada satélite; un GET al JWKS por token convertiría al emisor en el cuello de + * botella de todo el ecosistema, y en su punto único de fallo. + * 2. **Las claves rotan.** Si la caché fuera eterna, una rotación dejaría a todos los satélites + * rechazando tokens legítimos hasta que alguien los reiniciara. ADR-0157 §4.4 permite el solape + * justamente para que la rotación no sea un corte, pero el solape solo sirve si el consumidor + * va a buscar la clave nueva cuando la ve. + * + * La conciliación es traer el JWKS cuando aparece un `kid` que no se conoce. Y ahí está el riesgo + * que este archivo existe para acotar: **ese refresco lo dispara un desconocido**, porque el `kid` + * viaja en la cabecera de un token sin verificar. Sin límite, basta con mandar tokens con `kid` + * inventados —uno distinto cada vez— para que el satélite bombardee al emisor. Es un amplificador + * de denegación de servicio construido con nuestras propias manos. + * + * Por eso el refresco está acotado por tres mecanismos que se complementan: + * + * - **Cadencia mínima entre refrescos** (`esperaEntreRefrescosMs`): como mucho una petición al JWKS + * por ventana, contando TODOS los `kid` desconocidos juntos. Diez mil `kid` inventados no + * producen diez mil peticiones: producen una por ventana, y el resto se rechaza sin tocar la red. + * - **Memoria de lo ya descartado** (`kidsDescartados`): un `kid` que ya se buscó y no estaba se + * recuerda, y el siguiente token que lo traiga se rechaza sin consumir siquiera la cadencia. Sin + * esto, un atacante que repitiera un único `kid` falso se comería el refresco de cada ventana y + * dejaría sin él a una rotación legítima. + * - **Petición en vuelo compartida** (`enVuelo`): N verificaciones concurrentes con el mismo `kid` + * nuevo hacen UNA petición, no N. Sin esto, la ráfaga de arranque de un satélite con tráfico + * —justo cuando la caché está vacía— sale entera a la red a la vez. + */ + +/** Una clave del JWKS, ya convertida a material utilizable. */ +interface ClavePublica { + readonly clave: KeyObject; + /** El algoritmo que declara la PROPIA clave, no el que declare el token. */ + readonly algoritmo: string; +} + +/** El resultado distingue «no está» de «no pude mirar»: llevan a rechazos distintos. */ +export type ResultadoDeClave = + | { readonly estado: 'OK'; readonly clave: KeyObject; readonly algoritmo: string } + | { readonly estado: 'DESCONOCIDA' } + | { readonly estado: 'INACCESIBLE' }; + +export interface OpcionesDeJwks { + /** URI absoluta del JWKS del emisor. Ej.: `https://ums/.well-known/jwks.json`. */ + readonly uri: string; + /** Implementación de `fetch`. Se inyecta para poder probar sin red. */ + readonly fetchImpl?: typeof fetch; + /** Cuánto se considera fresca la copia. Por defecto 10 min, el `max-age` que publica UMS. */ + readonly ttlMs?: number; + /** Cadencia mínima entre refrescos disparados por un `kid` desconocido. Por defecto 60 s. */ + readonly esperaEntreRefrescosMs?: number; + /** Cuánto se recuerda un `kid` que no estaba en el JWKS. Por defecto 10 min. */ + readonly memoriaDeDescartadosMs?: number; + /** Tiempo máximo de la petición al JWKS. Por defecto 5 s. */ + readonly tiempoDeEsperaMs?: number; + /** Tamaño máximo aceptado del documento. Por defecto 512 KiB. */ + readonly maxBytes?: number; + /** Reloj inyectable, para que las pruebas no dependan del paso real del tiempo. */ + readonly reloj?: () => number; +} + +const TTL_POR_DEFECTO = 600_000; +const ESPERA_ENTRE_REFRESCOS_POR_DEFECTO = 60_000; +const MEMORIA_DE_DESCARTADOS_POR_DEFECTO = 600_000; +const TIEMPO_DE_ESPERA_POR_DEFECTO = 5_000; +const MAX_BYTES_POR_DEFECTO = 512 * 1024; + +/** + * Tamaño mínimo de clave RSA aceptado, en bytes del módulo (2048 bits). + * + * Es el mismo suelo que UMS se impone al arrancar (ADR-0157 §4.7). Se repite aquí porque el + * consumidor no puede dar por hecho que el otro extremo lo cumple: una clave corta verifica sin + * error y sin aviso, y el sistema queda débil sin que nada falle. + */ +const BYTES_MINIMOS_DE_MODULO = 256; + +export class CacheDeJwks { + private readonly uri: string; + private readonly fetchImpl: typeof fetch; + private readonly ttlMs: number; + private readonly esperaEntreRefrescosMs: number; + private readonly memoriaDeDescartadosMs: number; + private readonly tiempoDeEsperaMs: number; + private readonly maxBytes: number; + private readonly reloj: () => number; + + private claves = new Map(); + /** Momento en que se completó el último refresco con éxito. 0 = nunca. */ + private traidoEn = 0; + /** + * Momento del último refresco disparado por un `kid` desconocido teniendo la copia fresca. + * + * Se lleva aparte del refresco por caducidad a propósito, y es la corrección de un error que + * costó entender: si un único contador gobernara los dos, el primer refresco del arranque + * —programado, nuestro, inevitable— gastaría el presupuesto del siguiente, y una rotación + * legítima se quedaría esperando la ventana entera sin motivo. El presupuesto que hay que + * racionar es solo el que puede disparar un desconocido. + */ + private ultimoRefrescoPorKid = 0; + /** Momento del último intento FALLIDO. Impide martillear un emisor caído. */ + private ultimoFallo = 0; + private readonly kidsDescartados = new Map(); + private enVuelo: Promise | null = null; + + constructor(opciones: OpcionesDeJwks) { + if (!opciones.uri) throw new Error('CacheDeJwks exige la URI del JWKS del emisor.'); + this.uri = opciones.uri; + const fetchElegido = opciones.fetchImpl ?? globalThis.fetch; + if (!fetchElegido) { + throw new Error('No hay implementación de fetch disponible — pásala en `fetchImpl`.'); + } + this.fetchImpl = fetchElegido; + this.ttlMs = opciones.ttlMs ?? TTL_POR_DEFECTO; + this.esperaEntreRefrescosMs = opciones.esperaEntreRefrescosMs ?? ESPERA_ENTRE_REFRESCOS_POR_DEFECTO; + this.memoriaDeDescartadosMs = opciones.memoriaDeDescartadosMs ?? MEMORIA_DE_DESCARTADOS_POR_DEFECTO; + this.tiempoDeEsperaMs = opciones.tiempoDeEsperaMs ?? TIEMPO_DE_ESPERA_POR_DEFECTO; + this.maxBytes = opciones.maxBytes ?? MAX_BYTES_POR_DEFECTO; + this.reloj = opciones.reloj ?? Date.now; + } + + /** + * Devuelve la clave de ese `kid`, trayendo el JWKS solo si hace falta y solo si toca. + * + * Nunca devuelve «alguna» clave: sin `kid` que case, se rechaza. Probar el token contra todas las + * claves disponibles parece inofensivo —al fin y al cabo, o firma o no firma— pero convierte cada + * rotación en una ventana en la que una clave retirada sigue siendo válida, y borra la única + * señal que dice con qué clave se emitió realmente. + */ + async clavePara(kid: string): Promise { + if (!kid) return { estado: 'DESCONOCIDA' }; + + const ahora = this.reloj(); + + // Lo ya descartado se responde sin red y sin gastar la cadencia. Es lo que impide que un + // `kid` falso repetido monopolice el refresco que necesita una rotación de verdad. + const descartadoEn = this.kidsDescartados.get(kid); + if (descartadoEn !== undefined && ahora - descartadoEn < this.memoriaDeDescartadosMs) { + return { estado: 'DESCONOCIDA' }; + } + + const enCache = this.claves.get(kid); + const copiaFresca = this.traidoEn > 0 && ahora - this.traidoEn < this.ttlMs; + if (enCache && copiaFresca) { + return { estado: 'OK', clave: enCache.clave, algoritmo: enCache.algoritmo }; + } + + // Hay que ir a la red, y el motivo cambia radicalmente quién está al mando: + // + // - Copia fría o caducada: lo dispara el reloj, no quien llama. Como mucho una vez por TTL, y + // nadie de fuera puede acelerar ese reloj. + // - Copia fresca pero `kid` sin conocer: lo dispara la cabecera de un token SIN VERIFICAR. Es + // el caso que hay que racionar, y el único. + const loDisparaUnDesconocido = copiaFresca; + const refrescado = await this.refrescar(ahora, loDisparaUnDesconocido); + + const trasRefresco = this.claves.get(kid); + if (trasRefresco) { + return { estado: 'OK', clave: trasRefresco.clave, algoritmo: trasRefresco.algoritmo }; + } + + if (refrescado) { + // Se miró de verdad y no estaba: se recuerda, para no volver a mirar por este mismo `kid`. + this.kidsDescartados.set(kid, this.reloj()); + this.podarDescartados(); + return { estado: 'DESCONOCIDA' }; + } + + // No se pudo mirar: o la cadencia lo frenó, o el emisor no respondió. + if (enCache) { + // Copia caducada y refresco frenado: se sirve lo viejo antes que rechazar un token legítimo + // por un problema de red. La clave sigue siendo del emisor; solo está sin revalidar. + return { estado: 'OK', clave: enCache.clave, algoritmo: enCache.algoritmo }; + } + return this.traidoEn > 0 ? { estado: 'DESCONOCIDA' } : { estado: 'INACCESIBLE' }; + } + + /** + * Trae el JWKS si toca. Devuelve si la copia quedó actualizada. + * + * @param loDisparaUnDesconocido cuando es cierto se aplica la cadencia, porque el refresco lo + * provocó un `kid` que venía en un token sin verificar. Cuando es falso, el refresco es + * programado —caché fría o TTL cumplido— y solo lo frena el cortafuegos de fallos. + */ + private async refrescar(ahora: number, loDisparaUnDesconocido: boolean): Promise { + // Una sola petición en vuelo: N verificaciones concurrentes con la caché fría deben producir + // UNA petición, no N. Es el caso del arranque de un satélite con tráfico encima. + if (this.enVuelo) return this.enVuelo; + + if (loDisparaUnDesconocido + && this.ultimoRefrescoPorKid > 0 + && ahora - this.ultimoRefrescoPorKid < this.esperaEntreRefrescosMs) { + return false; + } + + // Si el emisor acaba de fallar, no se insiste. Reintentar sin freno contra algo caído es + // exactamente cuando peor sienta, y convierte una caída ajena en una propia. + if (this.ultimoFallo > 0 && ahora - this.ultimoFallo < this.esperaEntreRefrescosMs) { + return false; + } + + if (loDisparaUnDesconocido) this.ultimoRefrescoPorKid = ahora; + + this.enVuelo = this.traer() + .then((ok) => { + if (!ok) this.ultimoFallo = this.reloj(); + return ok; + }) + .finally(() => { + this.enVuelo = null; + }); + return this.enVuelo; + } + + private async traer(): Promise { + const control = new AbortController(); + const temporizador = setTimeout(() => control.abort(), this.tiempoDeEsperaMs); + try { + const respuesta = await this.fetchImpl(this.uri, { + headers: { Accept: 'application/json' }, + signal: control.signal, + }); + if (!respuesta.ok) return false; + + const texto = await respuesta.text(); + // Un JWKS legítimo son unos cientos de bytes. Un documento enorme aquí es un error de + // enrutado o algo peor, y no se gana nada intentando interpretarlo. + if (texto.length > this.maxBytes) return false; + + const documento = JSON.parse(texto) as { keys?: unknown }; + if (!Array.isArray(documento.keys)) return false; + + const nuevas = new Map(); + for (const jwk of documento.keys) { + const convertida = convertir(jwk); + if (convertida) nuevas.set(convertida.kid, convertida.valor); + } + // Solo se reemplaza si se obtuvo algo utilizable: un JWKS vacío o ilegible no debe borrar + // las claves buenas que ya se tenían. + if (nuevas.size === 0) return false; + + this.claves = nuevas; + this.traidoEn = this.reloj(); + // Una rotación puede reintroducir un `kid` que antes no estaba; la memoria de descartados + // dejaría de decir la verdad, así que se limpia junto con la copia. + this.kidsDescartados.clear(); + return true; + } catch { + // Cualquier fallo —red, tiempo agotado, JSON ilegible, HTML del enrutador equivocado— es un + // refresco que no ocurrió. Nunca una excepción hacia arriba: quien verifica decide qué hacer + // con la ausencia de clave, y lo hace con un rechazo, no con un error de servidor. + return false; + } finally { + clearTimeout(temporizador); + } + } + + private podarDescartados(): void { + const ahora = this.reloj(); + for (const [kid, momento] of this.kidsDescartados) { + if (ahora - momento >= this.memoriaDeDescartadosMs) this.kidsDescartados.delete(kid); + } + } +} + +/** + * Convierte un JWK a material de verificación, descartando lo que no sirva. + * + * Solo pasan claves RSA de firma: un JWKS no debería traer nada más, y aceptar una clave simétrica + * desde un documento público sería exactamente la degradación que todo esto quiere evitar —el + * emisor publicaría, sin querer, con qué firmar—. + */ +function convertir(jwk: unknown): { kid: string; valor: ClavePublica } | null { + if (!jwk || typeof jwk !== 'object') return null; + const k = jwk as Record; + + if (k['kty'] !== 'RSA') return null; + if (k['use'] !== undefined && k['use'] !== 'sig') return null; + if (typeof k['kid'] !== 'string' || k['kid'].length === 0) return null; + if (typeof k['n'] !== 'string' || typeof k['e'] !== 'string') return null; + + // Si la clave declara algoritmo, es el que manda. Si no lo declara, RS256 es lo único que UMS + // emite (ADR-0157) y lo único que este verificador sabe comprobar. + const algoritmo = typeof k['alg'] === 'string' ? k['alg'] : 'RS256'; + if (algoritmo !== 'RS256') return null; + + const modulo = Buffer.from(k['n'].replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + if (modulo.length < BYTES_MINIMOS_DE_MODULO) return null; + + try { + const clave = createPublicKey({ + key: { kty: 'RSA', n: k['n'], e: k['e'] } as never, + format: 'jwk', + }); + return { kid: k['kid'], valor: { clave, algoritmo } }; + } catch { + return null; + } +} + +/** Construye la URI del JWKS a partir de la base de UMS. */ +export function uriDeJwks(baseUrl: string): string { + return `${baseUrl.replace(/\/$/, '')}/.well-known/jwks.json`; +} diff --git a/src/libs/sdk/typescript/sdk-authorization/src/verificacion-de-firma.ts b/src/libs/sdk/typescript/sdk-authorization/src/verificacion-de-firma.ts new file mode 100644 index 00000000..c27b98c1 --- /dev/null +++ b/src/libs/sdk/typescript/sdk-authorization/src/verificacion-de-firma.ts @@ -0,0 +1,252 @@ +import { createHmac, timingSafeEqual, verify as verificarAsimetrica, constants } from 'node:crypto'; +import { CacheDeJwks, uriDeJwks, type OpcionesDeJwks } from './jwks.js'; + +/** + * Verificación de la firma del portador emitido por UMS. + * + * Existe porque los SDK NO la hacían. `sdk-express` y el de .NET partían el token por los puntos, + * descodificaban el trozo del medio y usaban su contenido —incluido el grafo de autorización— sin + * mirar la firma. Cualquiera que supiera fabricar un base64url con la forma esperada obtenía el + * grafo que quisiera: ni siquiera hacía falta conocer el secreto (G-217). + * + * Un SDK existe precisamente para que un equipo no tenga que resolver esto por su cuenta. Cuando el + * SDK no verifica, cada satélite que lo instala hereda el agujero sin haber hecho nada mal. + * + * Vive aquí y no en cada SDK a propósito: repetida cuatro veces, la comprobación se desincroniza, y + * la copia que se quede atrás será la que nadie mire. + * + * ── RS256 ──────────────────────────────────────────────────────────────────────────────────────── + * UMS pasó a firmar con clave privada RSA y a publicar la pública en su JWKS (ADR-0157). La + * diferencia con HS256 no es de robustez del algoritmo sino de **quién puede emitir**: con un + * secreto compartido, verificar y firmar son la misma capacidad, así que todo satélite que valida + * es también un emisor y ninguna firma identifica su origen. + * + * Este verificador nació exigiendo HS256 de forma literal, y esa exigencia —correcta contra la + * confusión de algoritmo— es la que empezó a rechazar los portadores legítimos en RS256. La salida + * NO es aceptar lo que el token declare: es que el consumidor diga qué acepta y que la clave, no el + * token, determine con qué se comprueba. + */ + +/** El resultado dice POR QUÉ se rechaza. Un booleano obligaría a cada SDK a inventarse el motivo. */ +export type ResultadoDeVerificacion = + | { readonly valido: true; readonly cuerpo: Record } + | { readonly valido: false; readonly motivo: MotivoDeRechazo }; + +export type MotivoDeRechazo = + | 'FORMATO' // no son tres segmentos separados por punto + | 'ALGORITMO' // el algoritmo del token no está entre los que el consumidor acepta + | 'FIRMA' // la firma no corresponde al contenido + | 'CLAVE_DESCONOCIDA' // el `kid` no está en el JWKS del emisor + | 'JWKS_INACCESIBLE' // no se pudo consultar el JWKS y no hay copia previa + | 'CUERPO_ILEGIBLE'; // el segmento del medio no es JSON + +const decodificar = (segmento: string): Buffer => { + const normalizado = segmento.replace(/-/g, '+').replace(/_/g, '/'); + const relleno = normalizado.length % 4; + if (relleno === 1) throw new Error('longitud base64url inválida'); + return Buffer.from(normalizado + '='.repeat(relleno ? 4 - relleno : 0), 'base64'); +}; + +interface CabeceraJwt { + readonly alg?: unknown; + readonly kid?: unknown; +} + +/** + * Qué acepta el consumidor. Es configuración suya, nunca del token. + * + * Declarar solo `secretoHs256` acepta solo HS256; declarar solo `jwks` acepta solo RS256; declarar + * ambos acepta ambos, y existe únicamente para que un despliegue pueda cruzar de uno a otro sin + * corte. Lo que no puede pasar es que aceptar los dos permita DEGRADAR: ver el apunte sobre + * `algoritmosAceptados` más abajo. + */ +export interface OpcionesDeVerificacion { + /** Secreto compartido para HS256. Su sola presencia habilita ese algoritmo. */ + readonly secretoHs256?: string; + /** Caché del JWKS del emisor. Su sola presencia habilita RS256. */ + readonly jwks?: CacheDeJwks; +} + +/** + * Comprueba que el token lo firmó quien dice, y devuelve su cuerpo solo si es así. + * + * Es asíncrona porque RS256 puede tener que consultar el JWKS del emisor. No se ha intentado + * disimularlo tras una fachada síncrona: la única manera de hacerlo sería bloquear el bucle de + * eventos o verificar contra una caché que todavía no tiene la clave, y las dos son peores que + * propagar el `await` a los middlewares, que ya viven en un contexto asíncrono. + */ +export async function verificarFirma( + token: string, + opciones: OpcionesDeVerificacion +): Promise { + const aceptaHs256 = typeof opciones.secretoHs256 === 'string' && opciones.secretoHs256.length > 0; + const aceptaRs256 = opciones.jwks !== undefined; + if (!aceptaHs256 && !aceptaRs256) { + // Sin nada configurado no se puede verificar nada. Se rechaza en vez de dejar pasar: es + // justamente la omisión que produjo G-217. + return { valido: false, motivo: 'FIRMA' }; + } + + const partes = token.split('.'); + if (partes.length !== 3) return { valido: false, motivo: 'FORMATO' }; + const [cabeceraB64, cuerpoB64, firmaB64] = partes as [string, string, string]; + + let cabecera: CabeceraJwt; + try { + cabecera = JSON.parse(decodificar(cabeceraB64).toString('utf-8')) as CabeceraJwt; + } catch { + return { valido: false, motivo: 'FORMATO' }; + } + + const alg = typeof cabecera.alg === 'string' ? cabecera.alg : ''; + + // `none` se rechaza aquí, explícitamente y antes que nada. Caería igual por no estar entre los + // algoritmos aceptados, pero se deja escrito: es LA manera de desactivar la firma sin tocarla, y + // debe verse en el código que está contemplada, no deducirse de que otra rama la cubre. + if (alg === 'none' || alg === '') return { valido: false, motivo: 'ALGORITMO' }; + + // El algoritmo del token NO decide cómo se comprueba: solo se usa para elegir entre las vías que + // el consumidor habilitó. Un satélite configurado únicamente para RS256 rechaza un token HS256 + // aunque el token insista en que es HS256 —esa es la degradación que no puede ocurrir, y por eso + // la condición mira la configuración y no la cabecera—. + const contenidoFirmado = `${cabeceraB64}.${cuerpoB64}`; + + if (alg === 'RS256') { + if (!aceptaRs256) return { valido: false, motivo: 'ALGORITMO' }; + const resultado = await comprobarRs256(cabecera, contenidoFirmado, firmaB64, opciones.jwks!); + if (resultado !== null) return resultado; + } else if (alg === 'HS256') { + if (!aceptaHs256) return { valido: false, motivo: 'ALGORITMO' }; + const resultado = comprobarHs256(contenidoFirmado, firmaB64, opciones.secretoHs256!); + if (resultado !== null) return resultado; + } else { + return { valido: false, motivo: 'ALGORITMO' }; + } + + try { + return { + valido: true, + cuerpo: JSON.parse(decodificar(cuerpoB64).toString('utf-8')) as Record, + }; + } catch { + return { valido: false, motivo: 'CUERPO_ILEGIBLE' }; + } +} + +/** Devuelve el rechazo, o `null` si la firma es buena. */ +async function comprobarRs256( + cabecera: CabeceraJwt, + contenidoFirmado: string, + firmaB64: string, + jwks: CacheDeJwks +): Promise { + // Sin `kid` no se verifica. La alternativa sería probar contra todas las claves publicadas, y eso + // convierte cada rotación en una ventana donde la clave retirada sigue valiendo. + const kid = typeof cabecera.kid === 'string' ? cabecera.kid : ''; + if (!kid) return { valido: false, motivo: 'CLAVE_DESCONOCIDA' }; + + const buscada = await jwks.clavePara(kid); + if (buscada.estado === 'DESCONOCIDA') return { valido: false, motivo: 'CLAVE_DESCONOCIDA' }; + if (buscada.estado === 'INACCESIBLE') return { valido: false, motivo: 'JWKS_INACCESIBLE' }; + + // Lo que manda es el algoritmo que declara LA CLAVE. Si el JWKS dijera que es una clave para otra + // cosa, no se verifica «igualmente» con RS256 porque el token lo pida. + if (buscada.algoritmo !== 'RS256') return { valido: false, motivo: 'ALGORITMO' }; + + let firma: Buffer; + try { + firma = decodificar(firmaB64); + } catch { + return { valido: false, motivo: 'FIRMA' }; + } + + let correcta: boolean; + try { + correcta = verificarAsimetrica( + 'sha256', + Buffer.from(contenidoFirmado, 'ascii'), + { key: buscada.clave, padding: constants.RSA_PKCS1_PADDING }, + firma + ); + } catch { + return { valido: false, motivo: 'FIRMA' }; + } + return correcta ? null : { valido: false, motivo: 'FIRMA' }; +} + +/** Devuelve el rechazo, o `null` si la firma es buena. */ +function comprobarHs256( + contenidoFirmado: string, + firmaB64: string, + secreto: string +): ResultadoDeVerificacion | null { + const esperada = createHmac('sha256', secreto).update(contenidoFirmado).digest(); + let recibida: Buffer; + try { + recibida = decodificar(firmaB64); + } catch { + return { valido: false, motivo: 'FIRMA' }; + } + // La comparación es en tiempo constante: hacerla con `===` filtra, por el tiempo que tarda en + // fallar, cuántos bytes iniciales acertó quien lo intenta. + if (recibida.length !== esperada.length) return { valido: false, motivo: 'FIRMA' }; + if (!timingSafeEqual(recibida, esperada)) return { valido: false, motivo: 'FIRMA' }; + return null; +} + +/** + * Verificación HS256 síncrona. + * + * Se mantiene porque hay despliegues y arneses de prueba que todavía firman con secreto compartido + * y no pueden cruzar a RS256 el mismo día. No admite RS256 —no puede: consultar el JWKS es + * asíncrono— así que un consumidor que la use seguirá rechazando los portadores nuevos. Para + * verificar los dos, `verificarFirma`. + * + * @deprecated Usa `verificarFirma`, que cubre RS256 (ADR-0157) además de HS256. + */ +export function verificarFirmaHs256(token: string, secreto: string): ResultadoDeVerificacion { + if (!secreto) return { valido: false, motivo: 'FIRMA' }; + + const partes = token.split('.'); + if (partes.length !== 3) return { valido: false, motivo: 'FORMATO' }; + const [cabeceraB64, cuerpoB64, firmaB64] = partes as [string, string, string]; + + let cabecera: CabeceraJwt; + try { + cabecera = JSON.parse(decodificar(cabeceraB64).toString('utf-8')) as CabeceraJwt; + } catch { + return { valido: false, motivo: 'FORMATO' }; + } + if (cabecera.alg !== 'HS256') return { valido: false, motivo: 'ALGORITMO' }; + + const rechazo = comprobarHs256(`${cabeceraB64}.${cuerpoB64}`, firmaB64, secreto); + if (rechazo !== null) return rechazo; + + try { + return { + valido: true, + cuerpo: JSON.parse(decodificar(cuerpoB64).toString('utf-8')) as Record, + }; + } catch { + return { valido: false, motivo: 'CUERPO_ILEGIBLE' }; + } +} + +/** + * Arma las opciones de verificación a partir de lo que declara un consumidor. + * + * Concentra aquí la regla de convivencia para que los tres SDK no la reimplementen cada uno a su + * manera —que es como una acaba siendo más laxa que las otras—. + */ +export function opcionesDeVerificacion(config: { + readonly signingSecret?: string; + readonly umsBaseUrl?: string; + readonly jwksUri?: string; + readonly jwks?: Partial; +}): OpcionesDeVerificacion { + const uri = config.jwksUri ?? (config.umsBaseUrl ? uriDeJwks(config.umsBaseUrl) : undefined); + return { + secretoHs256: config.signingSecret, + jwks: uri ? new CacheDeJwks({ ...config.jwks, uri }) : undefined, + }; +} diff --git a/src/libs/sdk/typescript/sdk-authorization/tests/validator.test.ts b/src/libs/sdk/typescript/sdk-authorization/tests/validator.test.ts index 29884c3d..5cf3b958 100644 --- a/src/libs/sdk/typescript/sdk-authorization/tests/validator.test.ts +++ b/src/libs/sdk/typescript/sdk-authorization/tests/validator.test.ts @@ -39,7 +39,7 @@ describe('AuthorizationValidator — golden fixture contract', () => { expect(graph.context.branch).not.toBeNull(); expect(graph.context.profile.scope).toBe('BranchScoped'); expect(graph.authentication.method).toBe('IDP'); - expect(graph.authentication.provider?.strategy).toBe('AZURE_AD'); + expect(graph.authentication.provider?.value).toBe('AZURE_AD'); expect(isGranted(validator.requireDomainAccess(graph, 'BRANCH_INVENTORY', 'APPROVE'))).toBe(true); expect(isGranted(validator.requireScope(graph, 'BRANCH_INVENTORY.VIEW'))).toBe(true); }); diff --git a/src/libs/sdk/typescript/sdk-authorization/tests/verificacion-de-firma.test.ts b/src/libs/sdk/typescript/sdk-authorization/tests/verificacion-de-firma.test.ts new file mode 100644 index 00000000..eb19ae42 --- /dev/null +++ b/src/libs/sdk/typescript/sdk-authorization/tests/verificacion-de-firma.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest'; +import { createHmac, generateKeyPairSync, sign, constants, type KeyObject } from 'node:crypto'; +import { CacheDeJwks } from '../src/jwks.js'; +import { verificarFirma, verificarFirmaHs256 } from '../src/verificacion-de-firma.js'; + +/** + * ADR-0157: UMS firma en RS256 y publica su clave pública. El verificador exigía HS256 de forma + * literal —correcto contra la confusión de algoritmo (G-217), pero rechazaba los portadores buenos—. + * + * Estas pruebas fijan las dos mitades a la vez: que RS256 se acepte, y que aceptarlo no haya + * abierto ninguna de las puertas que la exigencia original cerraba. + */ + +const SECRETO = 'secreto-de-prueba-de-al-menos-32-caracteres-1234'; +const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url'); +const CUERPO = { sub: 'admin@beyondnet.com.pe', graph: { schemaVersion: '2.4.0' } }; + +function parDeClaves(kid: string) { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = publicKey.export({ format: 'jwk' }) as Record; + return { + privada: privateKey, + jwk: { kty: 'RSA', use: 'sig', alg: 'RS256', kid, n: jwk['n'], e: jwk['e'] }, + }; +} + +function tokenRs256(privada: KeyObject, kid: string, cuerpo: unknown = CUERPO): string { + const cabecera = b64({ alg: 'RS256', typ: 'JWT', kid }); + const c = b64(cuerpo); + const firma = sign( + 'sha256', + Buffer.from(`${cabecera}.${c}`, 'ascii'), + { key: privada, padding: constants.RSA_PKCS1_PADDING } + ).toString('base64url'); + return `${cabecera}.${c}.${firma}`; +} + +function tokenHs256(secreto = SECRETO, cuerpo: unknown = CUERPO): string { + const cabecera = b64({ alg: 'HS256', typ: 'JWT' }); + const c = b64(cuerpo); + const firma = createHmac('sha256', secreto).update(`${cabecera}.${c}`).digest('base64url'); + return `${cabecera}.${c}.${firma}`; +} + +/** JWKS servido en memoria, contando las peticiones para poder afirmar cuántas hubo. */ +function servidorDeJwks(claves: unknown[]) { + const estado = { peticiones: 0, cuerpo: { keys: claves } as unknown }; + const fetchImpl = (async () => { + estado.peticiones += 1; + return { + ok: true, + text: async () => JSON.stringify(estado.cuerpo), + } as unknown as Response; + }) as unknown as typeof fetch; + return { estado, fetchImpl }; +} + +describe('RS256 por JWKS (ADR-0157)', () => { + it('(a) acepta un token firmado con la clave que publica el JWKS', async () => { + const { privada, jwk } = parDeClaves('kid-bueno'); + const { fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const r = await verificarFirma(tokenRs256(privada, 'kid-bueno'), { jwks }); + + expect(r.valido).toBe(true); + if (r.valido) expect(r.cuerpo['sub']).toBe('admin@beyondnet.com.pe'); + }); + + it('(b) RECHAZA un token firmado con OTRA clave, aunque declare un `kid` que sí existe', async () => { + // El ataque realista: el `kid` es válido y el token está bien formado; lo único que falla es + // que quien firmó no tenía la clave privada del emisor. + const { jwk } = parDeClaves('kid-bueno'); + const ajena = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + const { fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const r = await verificarFirma(tokenRs256(ajena, 'kid-bueno'), { jwks }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('FIRMA'); + }); + + it('(c) RECHAZA `alg: none`, que es como se desactiva la firma sin tocarla', async () => { + const { jwk } = parDeClaves('kid-bueno'); + const { fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const token = `${b64({ alg: 'none', typ: 'JWT' })}.${b64(CUERPO)}.`; + const r = await verificarFirma(token, { jwks, secretoHs256: SECRETO }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('ALGORITMO'); + }); + + it('RECHAZA un token sin `kid`: no se prueba con «alguna» clave', async () => { + const { privada, jwk } = parDeClaves('kid-bueno'); + const { fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const cabecera = b64({ alg: 'RS256', typ: 'JWT' }); // sin kid + const c = b64(CUERPO); + const firma = sign('sha256', Buffer.from(`${cabecera}.${c}`, 'ascii'), + { key: privada, padding: constants.RSA_PKCS1_PADDING }).toString('base64url'); + + const r = await verificarFirma(`${cabecera}.${c}.${firma}`, { jwks }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('CLAVE_DESCONOCIDA'); + }); + + it('trae el JWKS UNA vez para muchas verificaciones: la caché evita el GET por token', async () => { + const { privada, jwk } = parDeClaves('kid-bueno'); + const { estado, fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + for (let i = 0; i < 25; i += 1) { + const r = await verificarFirma(tokenRs256(privada, 'kid-bueno'), { jwks }); + expect(r.valido).toBe(true); + } + expect(estado.peticiones).toBe(1); + }); + + it('verificaciones concurrentes con la caché fría hacen UNA petición, no una por token', async () => { + const { privada, jwk } = parDeClaves('kid-bueno'); + const { estado, fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const resultados = await Promise.all( + Array.from({ length: 20 }, () => verificarFirma(tokenRs256(privada, 'kid-bueno'), { jwks })) + ); + + expect(resultados.every((r) => r.valido)).toBe(true); + expect(estado.peticiones).toBe(1); + }); + + it('una rotación se recoge sola: un `kid` nuevo dispara un refresco y el token pasa', async () => { + const vieja = parDeClaves('kid-v1'); + const { estado, fetchImpl } = servidorDeJwks([vieja.jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + expect((await verificarFirma(tokenRs256(vieja.privada, 'kid-v1'), { jwks })).valido).toBe(true); + + // UMS rota: publica una clave nueva con otro `kid`. + const nueva = parDeClaves('kid-v2'); + estado.cuerpo = { keys: [vieja.jwk, nueva.jwk] }; + + const r = await verificarFirma(tokenRs256(nueva.privada, 'kid-v2'), { jwks }); + expect(r.valido).toBe(true); + expect(estado.peticiones).toBe(2); + }); +}); + +describe('(d) un `kid` desconocido no cuelga ni abre una tormenta contra el JWKS', () => { + it('mil `kid` inventados producen UNA petición, no mil', async () => { + // Sin acotar, el refresco por `kid` desconocido es un amplificador de denegación de servicio: + // lo dispara un desconocido, porque el `kid` viaja en un token todavía sin verificar. + const { privada, jwk } = parDeClaves('kid-bueno'); + const { estado, fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ + uri: 'https://ums/.well-known/jwks.json', + fetchImpl, + esperaEntreRefrescosMs: 60_000, + }); + + for (let i = 0; i < 1000; i += 1) { + const r = await verificarFirma(tokenRs256(privada, `inventado-${i}`), { jwks }); + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('CLAVE_DESCONOCIDA'); + } + + // Dos: la de arranque —la caché estaba vacía— y una única sonda por si fuera una rotación. + // Las otras 998 no tocan la red. Lo que importa no es el número exacto sino que esté acotado + // por la ventana de tiempo y no por el número de tokens que mande el atacante. + expect(estado.peticiones).toBe(2); + }); + + it('repetir el MISMO `kid` falso no consume el refresco que necesita una rotación', async () => { + // Sin memoria de descartados, un único `kid` falso repetido se comería el refresco de cada + // ventana y una rotación legítima no llegaría a verse nunca. + const reloj = { t: 1_000_000 }; + const vieja = parDeClaves('kid-v1'); + const { estado, fetchImpl } = servidorDeJwks([vieja.jwk]); + const jwks = new CacheDeJwks({ + uri: 'https://ums/.well-known/jwks.json', + fetchImpl, + esperaEntreRefrescosMs: 60_000, + memoriaDeDescartadosMs: 3_600_000, + reloj: () => reloj.t, + }); + + await verificarFirma(tokenRs256(vieja.privada, 'kid-v1'), { jwks }); // 1ª petición: arranque + expect(estado.peticiones).toBe(1); + + // El atacante machaca con el mismo `kid` falso. El reloj avanza poco, para que el TTL no + // caduque y quede aislado lo que se mide: el coste de repetir un `kid` que ya se descartó. + for (let i = 0; i < 10; i += 1) { + reloj.t += 10_000; + await verificarFirma(tokenRs256(vieja.privada, 'kid-falso'), { jwks }); + } + // Solo la primera vez se miró de verdad; a partir de ahí se recuerda que no estaba. + expect(estado.peticiones).toBe(2); + + // Y cuando llega la rotación de verdad, el refresco sigue disponible. + const nueva = parDeClaves('kid-v2'); + estado.cuerpo = { keys: [nueva.jwk] }; + reloj.t += 120_000; + + const r = await verificarFirma(tokenRs256(nueva.privada, 'kid-v2'), { jwks }); + expect(r.valido).toBe(true); + }); + + it('si el JWKS no responde, se rechaza — no se cuelga ni se deja pasar', async () => { + const fetchImpl = (async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + const { privada } = parDeClaves('kid-bueno'); + + const r = await verificarFirma(tokenRs256(privada, 'kid-bueno'), { jwks }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('JWKS_INACCESIBLE'); + }); + + it('si en la URI del JWKS hay HTML —enrutado equivocado— se rechaza, no se interpreta', async () => { + // Ocurre de verdad: si el JWKS queda detrás del comodín del frontal, la respuesta es la página + // del SPA con un 200 alegre. Un 200 no es un JWKS. + const fetchImpl = (async () => ({ + ok: true, + text: async () => 'SPA', + } as unknown as Response)) as unknown as typeof fetch; + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + const { privada } = parDeClaves('kid-bueno'); + + const r = await verificarFirma(tokenRs256(privada, 'kid-bueno'), { jwks }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('JWKS_INACCESIBLE'); + }); +}); + +describe('(e) HS256 y RS256 conviven sin que aceptar los dos permita degradar', () => { + it('con solo `secretoHs256`, un token HS256 legítimo sigue pasando', async () => { + const r = await verificarFirma(tokenHs256(), { secretoHs256: SECRETO }); + expect(r.valido).toBe(true); + }); + + it('con solo JWKS configurado, un token HS256 NO se cuela', async () => { + // La degradación clásica: el consumidor espera RS256 y alguien le presenta un HS256. Se + // rechaza por configuración, sin llegar siquiera a mirar la firma. + const { jwk } = parDeClaves('kid-bueno'); + const { estado, fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const r = await verificarFirma(tokenHs256(), { jwks }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('ALGORITMO'); + expect(estado.peticiones).toBe(0); + }); + + it('con solo JWKS, un HS256 firmado con la clave PÚBLICA como secreto tampoco se cuela', async () => { + // La confusión de algoritmo canónica: la clave pública es conocida, así que si el verificador + // dejara que el token eligiera el algoritmo, cualquiera podría usarla como secreto HMAC. + const { jwk } = parDeClaves('kid-bueno'); + const { fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const r = await verificarFirma(tokenHs256(jwk.n as string), { jwks }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('ALGORITMO'); + }); + + it('con solo `secretoHs256`, un RS256 no se acepta aunque su firma sea buena', async () => { + const { privada } = parDeClaves('kid-bueno'); + const r = await verificarFirma(tokenRs256(privada, 'kid-bueno'), { secretoHs256: SECRETO }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('ALGORITMO'); + }); + + it('durante la transición, con ambos configurados, cada token va por su vía y ninguna se relaja', async () => { + const { privada, jwk } = parDeClaves('kid-bueno'); + const { fetchImpl } = servidorDeJwks([jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + const ambos = { jwks, secretoHs256: SECRETO }; + + expect((await verificarFirma(tokenRs256(privada, 'kid-bueno'), ambos)).valido).toBe(true); + expect((await verificarFirma(tokenHs256(), ambos)).valido).toBe(true); + + // Y los rechazos siguen siendo rechazos: ni el HS256 con otro secreto, ni el RS256 con otra clave. + expect((await verificarFirma(tokenHs256('otro-secreto-igual-de-largo-pero-ajeno'), ambos)).valido).toBe(false); + const ajena = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + expect((await verificarFirma(tokenRs256(ajena, 'kid-bueno'), ambos)).valido).toBe(false); + }); + + it('sin material de verificación no se acepta nada: la omisión no es una vía de escape', async () => { + const r = await verificarFirma(tokenHs256(), {}); + expect(r.valido).toBe(false); + }); + + it('RECHAZA un algoritmo que nadie habilitó (ES256), aunque el token insista', async () => { + const { fetchImpl } = servidorDeJwks([parDeClaves('kid-bueno').jwk]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + const token = `${b64({ alg: 'ES256', typ: 'JWT', kid: 'kid-bueno' })}.${b64(CUERPO)}.firma`; + + const r = await verificarFirma(token, { jwks, secretoHs256: SECRETO }); + + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('ALGORITMO'); + }); +}); + +describe('la vía HS256 síncrona se mantiene para quien aún no ha cruzado', () => { + it('sigue aceptando lo bueno y rechazando lo fabricado', () => { + expect(verificarFirmaHs256(tokenHs256(), SECRETO).valido).toBe(true); + expect(verificarFirmaHs256(`${b64({ alg: 'HS256' })}.${b64(CUERPO)}.inventada`, SECRETO).valido).toBe(false); + expect(verificarFirmaHs256(`${b64({ alg: 'none' })}.${b64(CUERPO)}.`, SECRETO).valido).toBe(false); + }); + + it('no acepta RS256: quien la use seguirá rechazando los portadores nuevos', () => { + const { privada } = parDeClaves('kid-bueno'); + const r = verificarFirmaHs256(tokenRs256(privada, 'kid-bueno'), SECRETO); + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('ALGORITMO'); + }); +}); + +describe('el JWKS no se cree cualquier cosa', () => { + it('descarta una clave simétrica colada en el JWKS', async () => { + // Un JWKS con una clave simétrica dentro sería el emisor publicando con qué firmar. + const { fetchImpl } = servidorDeJwks([{ kty: 'oct', kid: 'kid-bueno', k: 'AAAA', alg: 'HS256' }]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const r = await verificarFirma(tokenHs256(), { jwks }); + expect(r.valido).toBe(false); + }); + + it('descarta una clave RSA por debajo de 2048 bits y conserva las demás', async () => { + // Una clave corta verifica sin error y sin aviso: si no se descarta aquí, deja el sistema + // débil sin que nada falle. Se publica junto a una buena para comprobar que se descarta ella + // sola, no el JWKS entero. + const buena = parDeClaves('kid-bueno'); + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 1024 }); + const jwk = publicKey.export({ format: 'jwk' }) as Record; + const { fetchImpl } = servidorDeJwks([ + { kty: 'RSA', use: 'sig', alg: 'RS256', kid: 'corta', n: jwk['n'], e: jwk['e'] }, + buena.jwk, + ]); + const jwks = new CacheDeJwks({ uri: 'https://ums/.well-known/jwks.json', fetchImpl }); + + const r = await verificarFirma(tokenRs256(privateKey, 'corta'), { jwks }); + expect(r.valido).toBe(false); + if (!r.valido) expect(r.motivo).toBe('CLAVE_DESCONOCIDA'); + + // La buena sigue sirviendo. + expect((await verificarFirma(tokenRs256(buena.privada, 'kid-bueno'), { jwks })).valido).toBe(true); + }); +}); diff --git a/src/libs/sdk/typescript/sdk-client/src/client.ts b/src/libs/sdk/typescript/sdk-client/src/client.ts index a539528d..5f34ac2c 100644 --- a/src/libs/sdk/typescript/sdk-client/src/client.ts +++ b/src/libs/sdk/typescript/sdk-client/src/client.ts @@ -1,13 +1,22 @@ import { UmsErrorCodes, isSchemaVersionSupported, SchemaVersion } from '@ums/sdk-contracts'; -import type { ClientAuthOutcome, ClientAuthRequest, ClientAuthResult, UmsAuthClientOptions } from './types.js'; +import type { AuthorizationGraph } from '@ums/sdk-contracts'; +import type { + ClientAuthEnvelope, + ClientAuthOutcome, + ClientAuthRequest, + ClientAuthResult, + UmsAuthClientOptions +} from './types.js'; const DEFAULT_PATH = '/api/v1/client/authenticate'; const DEFAULT_TIMEOUT_MS = 30_000; /** - * Typed fetch-based client for the UMS authentication endpoint. - * Validates the server's `schemaVersion` against the SDK's compatibility window before returning - * the deserialized graph — incompatible majors yield `AUTH_205` without exposing the payload. + * Cliente tipado del endpoint de autenticación de UMS. + * + * Deserializa el grafo —que viaja como cadena, en el formato que eligió el inquilino— y comprueba + * su `schemaVersion` contra la ventana de compatibilidad del SDK antes de devolverlo; un MAJOR + * incompatible da `AUTH_205` sin exponer el contenido. */ export class UmsAuthClient { private readonly baseUrl: string; @@ -52,28 +61,50 @@ export class UmsAuthClient { return failure(code, body || response.statusText); } - let parsed: ClientAuthResult; + let sobre: ClientAuthEnvelope; try { - parsed = (await response.json()) as ClientAuthResult; + sobre = (await response.json()) as ClientAuthEnvelope; } catch (err) { return failure(UmsErrorCodes.AuthGraphMalformed, (err as Error).message); } - if (!parsed.graph?.schemaVersion) { + // El grafo viene serializado en el formato que eligió el inquilino. Hay que deserializarlo + // ANTES de mirar nada dentro: sobre una cadena, `.schemaVersion` es `undefined`, y esa era la + // causa de que el SDK contestara «falta schemaVersion» a una respuesta perfectamente válida. + const formato = (sobre.format ?? 'JSON').toUpperCase(); + if (formato !== 'JSON') { + return failure( + UmsErrorCodes.AuthGraphMalformed, + `El inquilino sirve el grafo en '${formato}' y este SDK solo interpreta JSON. ` + + `Pide 'JSON' en la petición (campo 'format') o configura el inquilino para emitirlo así.` + ); + } + + let graph: AuthorizationGraph; + try { + graph = JSON.parse(sobre.graph) as AuthorizationGraph; + } catch (err) { + return failure( + UmsErrorCodes.AuthGraphMalformed, + `El campo 'graph' no es JSON deserializable: ${(err as Error).message}` + ); + } + + if (!graph?.schemaVersion) { return failure( UmsErrorCodes.AuthGraphSchemaMissing, 'Server response does not carry a schemaVersion field.' ); } - if (!isSchemaVersionSupported(parsed.graph.schemaVersion)) { + if (!isSchemaVersionSupported(graph.schemaVersion)) { return failure( UmsErrorCodes.AuthGraphSchemaUnsupported, - `Server emitted schemaVersion '${parsed.graph.schemaVersion}' outside SDK range ` + + `Server emitted schemaVersion '${graph.schemaVersion}' outside SDK range ` + `(${SchemaVersion.CompatibilityMinInclusive} ≤ x < ${SchemaVersion.CompatibilityMaxExclusive}).` ); } - return { ok: true, value: parsed }; + return { ok: true, value: { ...sobre, graph } }; } } diff --git a/src/libs/sdk/typescript/sdk-client/src/types.ts b/src/libs/sdk/typescript/sdk-client/src/types.ts index e9803174..5a188a2d 100644 --- a/src/libs/sdk/typescript/sdk-client/src/types.ts +++ b/src/libs/sdk/typescript/sdk-client/src/types.ts @@ -7,6 +7,31 @@ export interface ClientAuthRequest { readonly format?: 'JSON' | 'XML' | 'YAML' | 'CSV'; } +/** + * El sobre TAL COMO VIAJA por el cable. + * + * `graph` es una **cadena serializada**, no un objeto: el formato lo elige el inquilino (JSON, XML, + * YAML o CSV) y un objeto JSON no puede transportar XML. El SDK lo tipaba como objeto y nunca había + * ejercido el endpoint real, así que `parsed.graph?.schemaVersion` daba `undefined` y **cualquier + * login a través del SDK fallaba** con `AUTH_204` (G-207). + * + * `requestId` es el identificador de traza de ASP.NET Core (`0HN...:00000001`), no un GUID. + * + * Se distingue del resultado que recibe quien llama: este es el cable, aquel es el grafo ya + * interpretado. Mezclarlos fue lo que permitió que la forma real y la esperada divergieran sin que + * nada fallara. + */ +export interface ClientAuthEnvelope { + readonly token: string; + readonly tokenType: string; + readonly expiresIn: number; + readonly issuedAt: string; + readonly format: string; + readonly graph: string; + readonly requestId: string; +} + +/** Lo que recibe quien llama: el mismo sobre, con el grafo ya interpretado. */ export interface ClientAuthResult { readonly token: string; readonly tokenType: string; diff --git a/src/libs/sdk/typescript/sdk-client/tests/client.test.ts b/src/libs/sdk/typescript/sdk-client/tests/client.test.ts index fae53d6b..9492fc3a 100644 --- a/src/libs/sdk/typescript/sdk-client/tests/client.test.ts +++ b/src/libs/sdk/typescript/sdk-client/tests/client.test.ts @@ -1,7 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; import { AuthGraphBuilder } from '@ums/sdk-testing'; import { UmsAuthClient, type ClientAuthOutcome } from '../src/index.js'; +/** + * El sobre REAL de `POST /api/v1/client/authenticate`, capturado de la API viva por + * `ContratoDeClientAuthenticateTests` y regenerable desde ahí. + * + * No está escrito a mano a propósito. Las pruebas de este cliente sí lo estaban, con la forma que + * el SDK creía correcta —`graph` como objeto—, y por eso pasaban mientras cualquier login real + * fallaba con `AUTH_204` (G-207). Una prueba escrita contra lo que uno supone no comprueba nada. + */ +const SOBRE_REAL = JSON.parse( + readFileSync( + fileURLToPath(new URL('../../../contracts/fixtures/client-authenticate.envelope.json', import.meta.url)), + 'utf-8' + ) +) as Record; + function makeFetch(status: number, body: unknown): typeof fetch { return async () => new Response(typeof body === 'string' ? body : JSON.stringify(body), { @@ -24,8 +41,10 @@ describe('UmsAuthClient', () => { expiresIn: 3600, issuedAt: new Date().toISOString(), format: 'JSON', - graph, - requestId: 'rid' + // Cadena, no objeto: es como viaja por el cable. Escrito como objeto, esta prueba pasaba + // mientras cualquier login real fallaba (G-207). + graph: JSON.stringify(graph), + requestId: '0HN7QMV8KJLDT:00000001' }) }); @@ -42,13 +61,14 @@ describe('UmsAuthClient', () => { }); it('returns AUTH_205 when server emits an unsupported MAJOR', async () => { - const graph = AuthGraphBuilder.forTenant('LOGISTICS_CORE').withSchemaVersion('2.0.0').build(); + const graph = AuthGraphBuilder.forTenant('LOGISTICS_CORE').withSchemaVersion('3.0.0').build(); const client = new UmsAuthClient({ baseUrl: 'https://ums.example.com', fetchImpl: makeFetch(200, { token: 'T', tokenType: 'Bearer', expiresIn: 60, - issuedAt: new Date().toISOString(), format: 'JSON', graph, requestId: 'rid' + issuedAt: new Date().toISOString(), format: 'JSON', + graph: JSON.stringify(graph), requestId: '0HN7QMV8KJLDT:00000001' }) }); @@ -57,6 +77,40 @@ describe('UmsAuthClient', () => { if (!outcome.ok) expect(outcome.error.code).toBe('AUTH_205'); }); + it('entiende el sobre real capturado de la API (G-207)', async () => { + const client = new UmsAuthClient({ + baseUrl: 'https://ums.example.com', + fetchImpl: makeFetch(200, SOBRE_REAL) + }); + + const outcome = await client.authenticate({ + tenantCode: 'COMEX_ANDINA', + username: 'usuario.impo@comexandina.com.pe', + password: 'p' + }); + + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value.graph.schemaVersion).toBe('2.4.0'); + expect(outcome.value.graph.context.tenant.code).toBe('COMEX_ANDINA'); + } + }); + + it('rechaza con un mensaje útil si el inquilino sirve el grafo en XML', async () => { + // Antes daba «falta schemaVersion», que manda a buscar el problema al sitio equivocado. + const client = new UmsAuthClient({ + baseUrl: 'https://ums.example.com', + fetchImpl: makeFetch(200, { ...SOBRE_REAL, format: 'XML', graph: '' }) + }); + + const outcome = await client.authenticate({ tenantCode: 'X', username: 'u', password: 'p' }); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error.code).toBe('AUTH_203'); + expect(outcome.error.message).toContain('XML'); + } + }); + it('maps HTTP 401 to AUTH_006 (InvalidCredentials)', async () => { const client = new UmsAuthClient({ baseUrl: 'https://ums.example.com', diff --git a/src/libs/sdk/typescript/sdk-contracts/src/auth-graph.ts b/src/libs/sdk/typescript/sdk-contracts/src/auth-graph.ts index ae4ad0c2..3eb298c5 100644 --- a/src/libs/sdk/typescript/sdk-contracts/src/auth-graph.ts +++ b/src/libs/sdk/typescript/sdk-contracts/src/auth-graph.ts @@ -1,73 +1,114 @@ import type { AccessEffect, PermissionSource } from './access-effect.js'; /** - * Self-contained, immutable snapshot of a user's authorization universe at authentication time. - * Mirrors `src/libs/sdk/contracts/auth-graph.schema.json` (v1.0.0). - * See ADR-0071 for the model and ADR-0074 for the versioning policy. + * Snapshot inmutable y autocontenido del universo de autorización de un usuario + * en el momento de autenticar. + * + * Refleja `src/libs/sdk/contracts/auth-graph.schema.json` (v1.0.0), que a su vez + * describe lo que emite `AuthGraphPayload` — el único mapeador del grafo, común + * al endpoint de sistemas satélite (`POST /api/v1/client/authenticate`) y al + * login web (`POST /api/v1/auth/login`, bajo `authorizationGraph`). + * + * Dos convenciones que conviene tener presentes al leer estas interfaces: + * 1. Todo nombre o etiqueta se expone como `value`. + * 2. Los identificadores técnicos (`id`, `moduleId`, `systemSuiteId`…) son + * OPCIONALES: solo llegan si el inquilino activó + * `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA` (por defecto `false`). La clave + * de negocio es `code`; no construyas nada que dependa de un id. + * + * Ver ADR-UMS-088 (modelo) y ADR-0074 (versionado). */ export interface AuthorizationGraph { readonly schemaVersion: string; + readonly onboardingPending: boolean; + /** + * v2.4.0 — estado de acceso, enumeración cerrada. `onboardingPending` no bastaba desde que la + * autenticación admite filtrar por sistema: «no tiene ningún perfil» y «tiene perfiles pero + * ninguno en el sistema pedido» son estados distintos, y tratarlos igual le mostraría un flujo + * de alta a quien ya está dado de alta. `onboardingPending` se conserva y equivale a + * `'OnboardingPending'`; el productor los deriva de la misma entrada, así que no divergen. + */ + readonly accessState: 'Granted' | 'NoProfileInSystem' | 'OnboardingPending'; readonly context: PrincipalContext; readonly authentication: AuthenticationMetadata; readonly actions: ReadonlyArray; + /** Perfiles activos del usuario; vacío en el grafo lobby. Ver {@link ProfileOption}. */ + readonly profiles: ReadonlyArray; readonly menuAccess: ReadonlyArray; readonly domainPermissions: ReadonlyArray; readonly featureFlags: ReadonlyArray; readonly effectiveConfig: EffectiveConfig; + /** + * Ajustes del sistema visibles para el cliente, por espacio de nombres: + * `settings.brand.logo_url`, `settings.ui.home_route`, `settings.locale.language`… + * Es el punto de extensión del contrato: un espacio nuevo no cambia la forma. + */ + readonly settings: Readonly>>>; readonly scopes: ReadonlyArray; readonly generatedAt: string; readonly validUntil: string; } +/** `systemSuite`, `role` y `profile` son null en el grafo lobby (onboardingPending). */ export interface PrincipalContext { readonly user: UserSummary; readonly tenant: TenantSummary; - readonly systemSuite: SystemSuiteSummary; - readonly role: RoleSummary; - readonly profile: ProfileSummary; + readonly systemSuite: SystemSuiteSummary | null; + readonly role: RoleSummary | null; + readonly profile: ProfileSummary | null; readonly branch: BranchSummary | null; + /** + * v2.4.0 — eco literal del sistema que se pidió al autenticarse, o `null` si no se pidió + * ninguno. No es una lectura del catálogo: devuelve la entrada del propio llamante. Permite + * rotular «no tiene acceso a X» sin llevar la configuración del cliente al navegador. + */ + readonly requestedSystem: RequestedSystemRef | null; +} + +export interface RequestedSystemRef { + readonly code: string; } export interface UserSummary { - readonly id: string; + readonly id?: string; readonly email: string; readonly username: string; - readonly displayName: string; - readonly status: 'ACTIVE' | 'PENDING' | 'BLOCKED' | string; + readonly value: string; + readonly status: 'Pending' | 'Active' | 'Blocked' | 'Deleted' | 'Denied' | string; } export interface TenantSummary { - readonly id: string; + readonly id?: string; readonly code: string; - readonly name: string; - readonly status: 'ACTIVE' | 'SUSPENDED' | 'ARCHIVED' | string; + readonly value: string; + readonly status: 'Active' | 'Suspended' | 'Archived' | string; + readonly isManagementOwner: boolean; } export interface SystemSuiteSummary { - readonly id: string; + readonly id?: string; readonly code: string; - readonly name: string; - readonly status: 'DRAFT' | 'PUBLISHED' | 'RETIRED' | string; + readonly value: string; + readonly status: 'Active' | 'Maintenance' | 'Deprecated' | string; } export interface RoleSummary { - readonly id: string; + readonly id?: string; readonly code: string; - readonly name: string; + readonly value: string; readonly hierarchyLevel: number; - readonly parentRoleId: string | null; } export interface ProfileSummary { - readonly id: string; + readonly id?: string; readonly scope: 'OrgWide' | 'BranchScoped'; readonly isActive: boolean; } export interface BranchSummary { - readonly id: string; + readonly id?: string; readonly code: string; - readonly name: string; + readonly value: string; } export interface AuthenticationMetadata { @@ -79,80 +120,114 @@ export interface AuthenticationMetadata { } export interface IdpProviderRef { - readonly name: string; - readonly code: string; - readonly strategy: string; -} - -export interface ActionRef { - readonly id: string; + readonly id?: string; readonly code: string; readonly name: string; + /** Estrategia del proveedor: AzureAd, Okta, GenericOidc… */ + readonly value: string; } -export interface MenuModule { - readonly module: ModuleSummary; - readonly menus: ReadonlyArray; +/** + * Un perfil al que el usuario tiene acceso: un rol dentro de un sistema, opcionalmente acotado a + * una sucursal. Exactamente uno lleva `isCurrent: true`. + * + * El sistema no sale del perfil —que no lo guarda— sino de su rol, que pertenece a exactamente uno. + * La lista llega ordenada por nivel de jerarquía, sistema y rol: el primero es el que el servidor + * habría elegido por defecto. + */ +export interface ProfileOption { + /** + * v2.4.0 — SIEMPRE presente, a diferencia de los demás `id` del grafo, que siguen sujetos a + * `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA`. Aquí no es un metadato decorativo: es la clave de + * `POST /client/switch-profile`, la operación que esta misma lista invita a ejecutar. + */ + readonly id: string; + readonly system: { readonly code: string; readonly value: string }; + readonly role: { readonly code: string; readonly value: string; readonly hierarchyLevel: number }; + readonly branch: { readonly code: string; readonly value: string } | null; + readonly scope: 'OrgWide' | 'BranchScoped'; + readonly isCurrent: boolean; } -export interface ModuleSummary { - readonly id: string; +export interface ActionRef { readonly code: string; - readonly name: string; - readonly sortOrder: number; - readonly status: 'DRAFT' | 'PUBLISHED' | 'RETIRED' | string; + readonly value: string; } -export interface Menu { - readonly id: string; +/** + * Módulo con su navegación alcanzable. `menuAccess` es una lista plana de estos. + * + * v2.0.0: el árbol es RECURSIVO. La forma anterior tenía tres niveles fijos + * (menus → subMenus → options) y perdía en silencio los nodos que no encajaban en ella. + * Recorre `children` hasta agotarlo; no presupongas una profundidad. + */ +export interface MenuModule { + readonly id?: string; readonly code: string; - readonly label: string; + readonly value: string; readonly sortOrder: number; - readonly subMenus: ReadonlyArray; + readonly status: 'Active' | 'Inactive' | string; + /** v2.3.0: identificador de icono del módulo, con el mismo criterio que el del nodo. */ + readonly icon: string | null; + readonly nodes: ReadonlyArray; } -export interface SubMenu { - readonly id: string; +/** + * Nodo del árbol. `kind` clasifica su papel sin fijar su profundidad. + * + * Solo viaja lo ALCANZABLE: una hoja sin acciones resueltas se omite y una rama sin hojas se omite + * con ella. La ausencia significa «no concedido». + */ +export interface NavigationNode { + readonly id?: string; readonly code: string; - readonly label: string; + readonly value: string; + readonly kind: 'Menu' | 'SubMenu' | 'Option' | string; readonly sortOrder: number; - readonly options: ReadonlyArray; + /** Identificador de icono, no un recurso: el catálogo gráfico lo elige el cliente. */ + readonly icon: string | null; + /** Ruta relativa destino. `null` en los nodos que solo agrupan. */ + readonly route: string | null; + /** Vacía en los nodos de agrupación. */ + readonly actions: ReadonlyArray; + /** Siempre presente, aunque venga vacía. */ + readonly children: ReadonlyArray; } -export interface MenuOption { - readonly id: string; - readonly code: string; - readonly label: string; +/** + * Acción con su efecto resuelto. `NotGranted` no viaja: es lo que significa la ausencia. `Deny` sí, + * porque gana sobre cualquier concesión heredada y el cliente debe pintarlo como bloqueado. + */ +export interface NodeAction { readonly actionCode: string; readonly effect: AccessEffect; readonly source: PermissionSource; } +/** + * Recurso de dominio con su resolución por acción. `domainPermissions` es una + * lista plana de estos, sin envoltorio `resource`. + */ export interface DomainResourcePermissions { - readonly resource: DomainResource; + readonly resourceId?: string; + readonly resourceType: 'Aggregate' | 'Entity' | 'DomainMethod' | string; + readonly resourceCode: string; + readonly value: string; + readonly moduleId?: string | null; + readonly parentResourceId?: string | null; readonly actions: ReadonlyArray; } -export interface DomainResource { - readonly id: string; - readonly type: 'Aggregate' | 'Entity' | 'DomainMethod' | string; - readonly code: string; - readonly name: string; - readonly moduleId: string | null; - readonly parentResourceId: string | null; -} - export interface DomainActionResolution { - readonly actionId: string; readonly actionCode: string; - readonly actionName: string; + readonly value: string; readonly effect: AccessEffect; readonly source: PermissionSource; } export interface FeatureFlagState { + readonly systemSuiteId?: string; readonly flagCode: string; - readonly systemSuiteId: string; readonly isEnabled: boolean; readonly matchedCriteriaType: | 'TenantId' @@ -171,6 +246,7 @@ export interface EffectiveConfig { readonly maxLoginAttempts: number; readonly minPasswordLength: number; readonly mfaRequiredForAdmin: boolean; + readonly mfaAllowedMethods: ReadonlyArray; readonly accessTokenDurationMs: number; readonly authUseExternalIdp: boolean; } diff --git a/src/libs/sdk/typescript/sdk-contracts/src/error-codes.ts b/src/libs/sdk/typescript/sdk-contracts/src/error-codes.ts index 006dfb3c..d32cd231 100644 --- a/src/libs/sdk/typescript/sdk-contracts/src/error-codes.ts +++ b/src/libs/sdk/typescript/sdk-contracts/src/error-codes.ts @@ -37,7 +37,12 @@ export const UmsErrorCodes = { AuthGraphMissing: 'AUTH_202', AuthGraphMalformed: 'AUTH_203', AuthGraphSchemaMissing: 'AUTH_204', - AuthGraphSchemaUnsupported: 'AUTH_205' + AuthGraphSchemaUnsupported: 'AUTH_205', + /** + * El portador no supera la verificacion de firma. Antes no existia porque los SDK no la + * hacian: descodificaban el token y se creian su contenido (G-217). + */ + AuthGraphSignatureInvalid: 'AUTH_206' } as const; export type UmsErrorCode = (typeof UmsErrorCodes)[keyof typeof UmsErrorCodes]; diff --git a/src/libs/sdk/typescript/sdk-contracts/src/schema-version.ts b/src/libs/sdk/typescript/sdk-contracts/src/schema-version.ts index 3c3dd44c..644c064d 100644 --- a/src/libs/sdk/typescript/sdk-contracts/src/schema-version.ts +++ b/src/libs/sdk/typescript/sdk-contracts/src/schema-version.ts @@ -3,9 +3,9 @@ * See ADR-0074 for the versioning policy. */ export const SchemaVersion = { - Current: '1.0.0', - CompatibilityMinInclusive: '1.0.0', - CompatibilityMaxExclusive: '2.0.0' + Current: '2.4.0', + CompatibilityMinInclusive: '2.0.0', + CompatibilityMaxExclusive: '3.0.0' } as const; interface Semver { diff --git a/src/libs/sdk/typescript/sdk-contracts/tests/schema-version.test.ts b/src/libs/sdk/typescript/sdk-contracts/tests/schema-version.test.ts index f4723b64..69b6ef81 100644 --- a/src/libs/sdk/typescript/sdk-contracts/tests/schema-version.test.ts +++ b/src/libs/sdk/typescript/sdk-contracts/tests/schema-version.test.ts @@ -9,11 +9,11 @@ import { describe('SchemaVersion compatibility', () => { it.each([ - ['1.0.0', true], - ['1.0.5', true], - ['1.99.0', true], - ['2.0.0', false], - ['2.5.3', false], + ['2.0.0', true], + ['2.0.5', true], + ['2.99.0', true], + ['3.0.0', false], + ['3.5.3', false], ['0.9.0', false], ['', false], [null, false], @@ -30,19 +30,33 @@ describe('SchemaVersion compatibility', () => { }); it('isMinorAhead detects server newer than SDK', () => { - expect(isMinorAhead('1.1.0')).toBe(true); - expect(isMinorAhead('1.0.0')).toBe(false); - expect(isMinorAhead('0.9.0')).toBe(false); + // Los casos se DERIVAN de SchemaVersion.Current en vez de repetirlo. Cuando la lista copiaba + // la constante, cada versión nueva rompía la prueba y arreglarla consistía en volver a + // copiarla: el pin acababa afirmando la versión de ayer. Lo que se verifica es la RELACIÓN. + const [major, minor] = SchemaVersion.Current.split('.').map(Number); + + expect(isMinorAhead(`${major}.${minor + 1}.0`)).toBe(true); + expect(isMinorAhead(`${major}.${minor}.0`)).toBe(false); + for (let anterior = minor - 1; anterior >= 0; anterior--) { + expect(isMinorAhead(`${major}.${anterior}.0`)).toBe(false); + } + // Un MAJOR distinto no es «por delante»: es incompatible. + expect(isMinorAhead(`${major + 1}.0.0`)).toBe(false); + if (major > 0) expect(isMinorAhead(`${major - 1}.99.0`)).toBe(false); }); it('isMinorBehind detects SDK newer than server', () => { - expect(isMinorBehind('1.0.0')).toBe(false); - // After bumping SchemaVersion.Current the older 0.x is MAJOR-different, so behind returns false. + const [major, minor] = SchemaVersion.Current.split('.').map(Number); + + // Un MAJOR distinto no es «por detrás»: es incompatible. + expect(isMinorBehind(`${major - 1}.0.0`)).toBe(false); expect(isMinorBehind('0.9.0')).toBe(false); + // El SDK va por delante de un servidor con MINOR anterior. + if (minor > 0) expect(isMinorBehind(`${major}.${minor - 1}.0`)).toBe(true); }); it('isMajorMatch checks major equality', () => { - expect(isMajorMatch('1.999.999')).toBe(true); - expect(isMajorMatch('2.0.0')).toBe(false); + expect(isMajorMatch('2.999.999')).toBe(true); + expect(isMajorMatch('3.0.0')).toBe(false); }); }); diff --git a/src/libs/sdk/typescript/sdk-express/README.md b/src/libs/sdk/typescript/sdk-express/README.md index 9ef4374b..5ef4098f 100644 --- a/src/libs/sdk/typescript/sdk-express/README.md +++ b/src/libs/sdk/typescript/sdk-express/README.md @@ -23,7 +23,9 @@ const accessor = new AsyncLocalAuthGraphAccessor(); configureAuthorization({ accessor, validator: new AuthorizationValidator() }); const app = express(); -app.use(umsAuthGraph({ accessor, rejectExpired: true })); +// `signingSecret` es obligatorio: es el secreto con el que UMS firma sus portadores. Sin él, el +// middleware no puede distinguir un grafo emitido por UMS de uno escrito por quien llama (G-217). +app.use(umsAuthGraph({ accessor, rejectExpired: true, signingSecret: process.env.UMS_JWT_SECRET! })); ``` After the middleware runs, any handler that calls `requireScope(...)` or any of the diff --git a/src/libs/sdk/typescript/sdk-express/src/middleware.ts b/src/libs/sdk/typescript/sdk-express/src/middleware.ts index ce94e93a..a90f70fd 100644 --- a/src/libs/sdk/typescript/sdk-express/src/middleware.ts +++ b/src/libs/sdk/typescript/sdk-express/src/middleware.ts @@ -2,12 +2,39 @@ import type { AuthorizationGraph } from '@ums/sdk-contracts'; import { isSchemaVersionSupported } from '@ums/sdk-contracts'; import { type AuthGraphAccessor, + type OpcionesDeVerificacion, + type OpcionesDeJwks, AsyncLocalAuthGraphAccessor, - MemoryAuthGraphAccessor + MemoryAuthGraphAccessor, + opcionesDeVerificacion, + verificarFirma } from '@ums/sdk-authorization'; export interface UmsAuthGraphOptions { readonly accessor: AuthGraphAccessor; + /** + * Secreto con el que UMS firma sus portadores en HS256. + * + * Ya no es obligatorio: desde ADR-0157 UMS firma en RS256 y publica su clave pública, así que lo + * normal es configurar `umsBaseUrl` y no tener secreto ninguno. Sigue existiendo para los + * despliegues que todavía no han cruzado. + * + * **Hay que declarar al menos uno de los dos** (este o `umsBaseUrl`/`jwksUri`). Si no se declara + * ninguno, el middleware falla al construirse: sin material de verificación no puede distinguir + * un portador legítimo de uno fabricado, que es el agujero de G-217. + */ + readonly signingSecret?: string; + /** + * Base de UMS, de la que sale su JWKS (`{base}/.well-known/jwks.json`). Habilita RS256. + * + * Declarar solo esto es lo deseable: el satélite verifica sin custodiar material de firma, y por + * tanto no puede emitir portadores aunque quisiera. + */ + readonly umsBaseUrl?: string; + /** URI del JWKS, si no cuelga de `umsBaseUrl` en la ruta estándar. */ + readonly jwksUri?: string; + /** Ajustes finos de la caché del JWKS (TTL, cadencia de refresco, tiempo de espera). */ + readonly jwks?: Partial; /** JWT body claim that carries the serialized graph. Default: 'graph'. */ readonly jwtBodyClaim?: string; /** When true, reject with 401 + AUTH_201 if validUntil is in the past. */ @@ -24,6 +51,18 @@ export interface UmsAuthGraphOptions { */ export function umsAuthGraph(options: UmsAuthGraphOptions) { const accessor = options.accessor; + if (!options.signingSecret && !options.umsBaseUrl && !options.jwksUri) { + // Al construir, no al recibir la primera petición: un satélite mal configurado debe romperse + // al arrancar, no servir peticiones sin verificar hasta que alguien lo note. + throw new Error( + 'umsAuthGraph exige material con el que verificar la firma: `umsBaseUrl` (o `jwksUri`) para ' + + 'los portadores RS256 que emite UMS desde ADR-0157, o `signingSecret` para los HS256 que ' + + 'queden. Sin ninguno de los dos no puede distinguir un token legítimo de uno fabricado (G-217).' + ); + } + // Se construye UNA vez, no por petición: la caché del JWKS solo sirve de algo si sobrevive entre + // peticiones. Crearla dentro del middleware haría un GET al emisor por cada token. + const verificacion: OpcionesDeVerificacion = opcionesDeVerificacion(options); const claim = options.jwtBodyClaim ?? 'graph'; const rejectExpired = options.rejectExpired === true; const rejectIncompatible = options.rejectIncompatible !== false; @@ -36,28 +75,39 @@ export function umsAuthGraph(options: UmsAuthGraphOptions) { const token = extractBearer(req); if (token === null) return runWith(null, next); - let graph: AuthorizationGraph | null = null; - try { - graph = decodeGraph(token, claim); - } catch { - return runWith(null, next); - } - if (graph === null) return runWith(null, next); + // Asíncrono porque RS256 puede tener que consultar el JWKS. El `void` es deliberado: Express no + // espera la promesa, y el flujo continúa por `next()` o por `reject()` dentro de ella. + void (async () => { + // La firma PRIMERO. Sin esto, todo lo que viene después —el grafo, la versión de esquema, la + // caducidad— se comprueba sobre un contenido que cualquiera pudo escribir. + const resultado = await verificarFirma(token, verificacion); + if (!resultado.valido) { + return reject(res, 'AUTH_206', `El portador no supera la verificación de firma (${resultado.motivo}).`); + } - if (!graph.schemaVersion) { - if (rejectIncompatible) return reject(res, 'AUTH_204', 'AuthorizationGraph payload does not declare a schemaVersion.'); - return runWith(null, next); - } - if (!isSchemaVersionSupported(graph.schemaVersion)) { - if (rejectIncompatible) - return reject(res, 'AUTH_205', `schemaVersion '${graph.schemaVersion}' is outside SDK compatibility range.`); - return runWith(null, next); - } - if (rejectExpired && new Date(graph.validUntil).getTime() <= Date.now()) { - return reject(res, 'AUTH_201', 'AuthorizationGraph has expired.'); - } + let graph: AuthorizationGraph | null = null; + try { + graph = extraerGrafo(resultado.cuerpo, claim); + } catch { + return runWith(null, next); + } + if (graph === null) return runWith(null, next); - return runWith(graph, next); + if (!graph.schemaVersion) { + if (rejectIncompatible) return reject(res, 'AUTH_204', 'AuthorizationGraph payload does not declare a schemaVersion.'); + return runWith(null, next); + } + if (!isSchemaVersionSupported(graph.schemaVersion)) { + if (rejectIncompatible) + return reject(res, 'AUTH_205', `schemaVersion '${graph.schemaVersion}' is outside SDK compatibility range.`); + return runWith(null, next); + } + if (rejectExpired && new Date(graph.validUntil).getTime() <= Date.now()) { + return reject(res, 'AUTH_201', 'AuthorizationGraph has expired.'); + } + + return runWith(graph, next); + })().catch((err: unknown) => next(err)); }; function runWith(graph: AuthorizationGraph | null, next: (err?: unknown) => void): void { @@ -87,31 +137,18 @@ function extractBearer(req: { headers: Record, claim: string): AuthorizationGraph | null { const value = payload?.[claim]; if (value && typeof value === 'object') return value as AuthorizationGraph; if (typeof value === 'string') return JSON.parse(value) as AuthorizationGraph; return null; } -function base64UrlDecode(input: string): string { - let normalized = input.replace(/-/g, '+').replace(/_/g, '/'); - const pad = normalized.length % 4; - if (pad === 2) normalized += '=='; - else if (pad === 3) normalized += '='; - else if (pad === 1) throw new Error('Invalid base64url length'); - if (typeof Buffer !== 'undefined') return Buffer.from(normalized, 'base64').toString('utf-8'); - // Browser fallback: - return decodeURIComponent( - atob(normalized) - .split('') - .map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`) - .join('') - ); -} function reject(res: ExpressResponse, code: string, message: string): void { res.status(401).json({ code, message }); diff --git a/src/libs/sdk/typescript/sdk-express/tests/firma.test.ts b/src/libs/sdk/typescript/sdk-express/tests/firma.test.ts new file mode 100644 index 00000000..923d7981 --- /dev/null +++ b/src/libs/sdk/typescript/sdk-express/tests/firma.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { constants, createHmac, generateKeyPairSync, sign } from 'node:crypto'; +import { umsAuthGraph } from '../src/middleware.js'; +import { MemoryAuthGraphAccessor } from '@ums/sdk-authorization'; + +/** + * G-217: el middleware descodificaba el token y se creía su contenido sin mirar la firma. + * Cualquiera podía fabricarse un portador con el grafo que quisiera —incluido uno de + * administrador— sin conocer el secreto. Estas pruebas existen para que eso no pueda volver. + */ + +const SECRETO = 'secreto-de-prueba-de-al-menos-32-caracteres-1234'; +const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)).toString('base64url'); + +/** Fabrica un token firmado de verdad. */ +const tokenFirmado = (cuerpo: unknown, secreto = SECRETO): string => { + const cabecera = b64({ alg: 'HS256', typ: 'JWT' }); + const c = b64(cuerpo); + const firma = createHmac('sha256', secreto).update(`${cabecera}.${c}`).digest('base64url'); + return `${cabecera}.${c}.${firma}`; +}; + +const GRAFO = { schemaVersion: '2.4.0', scopes: ['todo.borrar'], profiles: [] }; + +/** + * Invoca el middleware y espera a que termine. + * + * El `await` no es adorno: desde que la verificación admite RS256 (ADR-0157) el middleware es + * asíncrono, porque resolver la clave puede exigir consultar el JWKS del emisor. Leer el resultado + * sin esperar leería el estado anterior a la verificación, y la prueba pasaría sin comprobar nada. + */ +const invocar = async (token: string | null, opciones: Record = {}) => { + const accessor = new MemoryAuthGraphAccessor(); + const mw = umsAuthGraph({ accessor, signingSecret: SECRETO, ...opciones } as never); + let estado = 0; let cuerpo: unknown = null; let siguiente = false; + + await new Promise((resolve) => { + const res = { + status(c: number) { estado = c; return this; }, + json(b: unknown) { cuerpo = b; resolve(); return this; }, + } as never; + mw( + { headers: token ? { authorization: `Bearer ${token}` } : {} } as never, + res, + () => { siguiente = true; resolve(); } + ); + }); + + return { estado, cuerpo, siguiente, grafo: accessor.current() }; +}; + +describe('G-217 · el portador se verifica antes de creerse su contenido', () => { + it('acepta un token firmado con el secreto correcto', async () => { + const r = await invocar(tokenFirmado({ graph: GRAFO })); + expect(r.siguiente).toBe(true); + expect(r.grafo).not.toBeNull(); + }); + + it('RECHAZA un token fabricado a mano, sin firma válida', async () => { + // El ataque exacto que G-217 describe: se escribe el grafo que se quiera y se le pega + // cualquier cosa como firma. Antes, esto pasaba y el satélite concedía esos permisos. + const cabecera = b64({ alg: 'HS256', typ: 'JWT' }); + const cuerpo = b64({ graph: { ...GRAFO, scopes: ['todo.borrar', 'admin.total'] } }); + const falsificado = `${cabecera}.${cuerpo}.firma-inventada`; + + const r = await invocar(falsificado); + expect(r.estado).toBe(401); + expect(r.siguiente).toBe(false); + expect(r.grafo).toBeNull(); + }); + + it('RECHAZA un token firmado con OTRO secreto', async () => { + const r = await invocar(tokenFirmado({ graph: GRAFO }, 'otro-secreto-igual-de-largo-pero-ajeno-12')); + expect(r.estado).toBe(401); + expect(r.grafo).toBeNull(); + }); + + it('RECHAZA `alg: none`, que es como se desactiva la firma sin tocarla', async () => { + const cabecera = b64({ alg: 'none', typ: 'JWT' }); + const cuerpo = b64({ graph: GRAFO }); + const r = await invocar(`${cabecera}.${cuerpo}.`); + expect(r.estado).toBe(401); + expect(r.grafo).toBeNull(); + }); + + it('exige material de verificación al construirse, no al recibir la primera petición', () => { + expect(() => umsAuthGraph({ accessor: new MemoryAuthGraphAccessor() })) + .toThrow(/umsBaseUrl|signingSecret/); + }); +}); + +describe('ADR-0157 · el mismo middleware verifica los portadores RS256 de UMS', () => { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = publicKey.export({ format: 'jwk' }) as Record; + const KID = 'kid-de-prueba'; + + const jwksEnMemoria = (async () => ({ + ok: true, + text: async () => JSON.stringify({ + keys: [{ kty: 'RSA', use: 'sig', alg: 'RS256', kid: KID, n: jwk['n'], e: jwk['e'] }], + }), + } as unknown as Response)) as unknown as typeof fetch; + + const tokenRs256 = (cuerpo: unknown, clave = privateKey) => { + const cabecera = b64({ alg: 'RS256', typ: 'JWT', kid: KID }); + const c = b64(cuerpo); + const firma = sign('sha256', Buffer.from(`${cabecera}.${c}`, 'ascii'), + { key: clave, padding: constants.RSA_PKCS1_PADDING }).toString('base64url'); + return `${cabecera}.${c}.${firma}`; + }; + + /** Configurado SOLO para RS256: sin secreto, como debe quedar un satélite tras ADR-0157. */ + const soloRs256 = { signingSecret: undefined, umsBaseUrl: 'https://ums', jwks: { fetchImpl: jwksEnMemoria } }; + + it('acepta el portador que UMS firma con su clave privada', async () => { + const r = await invocar(tokenRs256({ graph: GRAFO }), soloRs256); + expect(r.siguiente).toBe(true); + expect(r.grafo).not.toBeNull(); + }); + + it('RECHAZA uno firmado con otra clave, aunque declare el `kid` bueno', async () => { + const ajena = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + const r = await invocar(tokenRs256({ graph: GRAFO }, ajena), soloRs256); + expect(r.estado).toBe(401); + expect(r.grafo).toBeNull(); + }); + + it('configurado para RS256, un HS256 NO se cuela aunque el secreto exista en otro sitio', async () => { + const r = await invocar(tokenFirmado({ graph: GRAFO }), soloRs256); + expect(r.estado).toBe(401); + expect(r.grafo).toBeNull(); + }); +}); diff --git a/src/libs/sdk/typescript/sdk-express/tests/middleware.test.ts b/src/libs/sdk/typescript/sdk-express/tests/middleware.test.ts index 65817fdf..0d9b313f 100644 --- a/src/libs/sdk/typescript/sdk-express/tests/middleware.test.ts +++ b/src/libs/sdk/typescript/sdk-express/tests/middleware.test.ts @@ -1,13 +1,25 @@ import { describe, it, expect } from 'vitest'; +import { createHmac } from 'node:crypto'; import { AsyncLocalAuthGraphAccessor, MemoryAuthGraphAccessor } from '@ums/sdk-authorization'; import { AuthGraphBuilder } from '@ums/sdk-testing'; import type { AuthorizationGraph } from '@ums/sdk-contracts'; import { umsAuthGraph } from '../src/index.js'; +/** + * Secreto de la corrida. Desde G-217 el middleware verifica la firma, así que estas pruebas ya no + * pueden fabricar un token `alg: none` y esperar que se acepte — eso es exactamente lo que ahora + * se rechaza, y lo cubre `firma.test.ts`. + */ +const SECRETO_DE_PRUEBA = 'secreto-de-prueba-de-al-menos-32-caracteres-1234'; + +/** Un portador FIRMADO de verdad, que es lo que emite UMS. */ function fakeJwt(graph: AuthorizationGraph): string { - const header = base64Url(Buffer.from('{"alg":"none","typ":"JWT"}')); + const header = base64Url(Buffer.from('{"alg":"HS256","typ":"JWT"}')); const payload = base64Url(Buffer.from(JSON.stringify({ graph }))); - return `${header}.${payload}.`; + const firma = createHmac('sha256', SECRETO_DE_PRUEBA) + .update(`${header}.${payload}`) + .digest('base64url'); + return `${header}.${payload}.${firma}`; } function base64Url(buffer: Buffer): string { @@ -29,11 +41,39 @@ function makeRes() { }; } +/** + * Espera a que el middleware termine, por donde termine. + * + * Desde que la verificación admite RS256 el middleware es asíncrono —consultar el JWKS lo obliga—, + * así que leer `res.body` justo después de invocarlo lee el estado de antes de que pasara nada. + * Este arnés espera a lo que ocurra primero: la llamada a `next()` o el rechazo escrito en la + * respuesta. + */ +function ejecutar( + mw: ReturnType, + headers: Record +): Promise<{ res: ReturnType; siguiente: boolean }> { + return new Promise((resolve) => { + const res = makeRes(); + const original = res.status.bind(res); + res.status = (code: number) => { + const devuelto = original(code); + return { + json(p: unknown) { + devuelto.json(p); + resolve({ res, siguiente: false }); + } + }; + }; + mw({ headers }, res as never, () => resolve({ res, siguiente: true })); + }); +} + describe('@ums/sdk-express middleware', () => { it('binds graph to MemoryAuthGraphAccessor and calls next', async () => { const graph = AuthGraphBuilder.forTenant('LOGISTICS_CORE').withScope('PURCHASE_ORDER.VIEW').build(); const accessor = new MemoryAuthGraphAccessor(); - const mw = umsAuthGraph({ accessor }); + const mw = umsAuthGraph({ accessor, signingSecret: SECRETO_DE_PRUEBA }); await new Promise((resolve) => { mw({ headers: { authorization: `Bearer ${fakeJwt(graph)}` } }, makeRes() as never, () => resolve()); @@ -47,7 +87,7 @@ describe('@ums/sdk-express middleware', () => { it('binds graph inside AsyncLocalAuthGraphAccessor scope', async () => { const graph = AuthGraphBuilder.forTenant('LOGISTICS_CORE').build(); const accessor = new AsyncLocalAuthGraphAccessor(); - const mw = umsAuthGraph({ accessor }); + const mw = umsAuthGraph({ accessor, signingSecret: SECRETO_DE_PRUEBA }); let observed: AuthorizationGraph | null = null; await new Promise((resolve) => { @@ -61,15 +101,13 @@ describe('@ums/sdk-express middleware', () => { }); it('rejects with 401 AUTH_205 on unsupported MAJOR', async () => { - const graph = AuthGraphBuilder.forTenant('LOGISTICS_CORE').withSchemaVersion('2.0.0').build(); + const graph = AuthGraphBuilder.forTenant('LOGISTICS_CORE').withSchemaVersion('3.0.0').build(); const accessor = new MemoryAuthGraphAccessor(); - const mw = umsAuthGraph({ accessor }); - const res = makeRes(); + const mw = umsAuthGraph({ accessor, signingSecret: SECRETO_DE_PRUEBA }); - mw({ headers: { authorization: `Bearer ${fakeJwt(graph)}` } }, res as never, () => { - throw new Error('next() should not be called on rejection'); - }); + const { res, siguiente } = await ejecutar(mw, { authorization: `Bearer ${fakeJwt(graph)}` }); + expect(siguiente).toBe(false); expect(res.body.status).toBe(401); expect((res.body.payload as { code: string }).code).toBe('AUTH_205'); }); @@ -77,20 +115,18 @@ describe('@ums/sdk-express middleware', () => { it('rejects expired graph when rejectExpired is true', async () => { const graph = AuthGraphBuilder.forTenant('LOGISTICS_CORE').buildExpired(); const accessor = new MemoryAuthGraphAccessor(); - const mw = umsAuthGraph({ accessor, rejectExpired: true }); - const res = makeRes(); + const mw = umsAuthGraph({ accessor, signingSecret: SECRETO_DE_PRUEBA, rejectExpired: true }); - mw({ headers: { authorization: `Bearer ${fakeJwt(graph)}` } }, res as never, () => { - throw new Error('next() should not be called on expiry rejection'); - }); + const { res, siguiente } = await ejecutar(mw, { authorization: `Bearer ${fakeJwt(graph)}` }); + expect(siguiente).toBe(false); expect(res.body.status).toBe(401); expect((res.body.payload as { code: string }).code).toBe('AUTH_201'); }); it('passes through when no bearer is provided', async () => { const accessor = new MemoryAuthGraphAccessor(); - const mw = umsAuthGraph({ accessor }); + const mw = umsAuthGraph({ accessor, signingSecret: SECRETO_DE_PRUEBA }); let called = false; await new Promise((resolve) => { mw({ headers: {} }, makeRes() as never, () => { called = true; resolve(); }); diff --git a/src/libs/sdk/typescript/sdk-testing/src/auth-graph-builder.ts b/src/libs/sdk/typescript/sdk-testing/src/auth-graph-builder.ts index f900ccfa..5011ef89 100644 --- a/src/libs/sdk/typescript/sdk-testing/src/auth-graph-builder.ts +++ b/src/libs/sdk/typescript/sdk-testing/src/auth-graph-builder.ts @@ -105,7 +105,7 @@ export class AuthGraphBuilder { withIdpAuth(providerName: string, providerCode: string, strategy: string): this { this.authMethod = 'IDP'; - this.provider = { name: providerName, code: providerCode, strategy }; + this.provider = { code: providerCode, name: providerName, value: strategy }; return this; } @@ -169,22 +169,30 @@ export class AuthGraphBuilder { return { schemaVersion: this.schemaVersion, + onboardingPending: false, + // Convive con `onboardingPending` (ADR-0156 §5.1). El builder solo fabrica grafos con acceso + // concedido; los otros dos estados los cubren los fixtures. + accessState: 'Granted', context: { user: { - id: randomUuid(), email: this.userEmail, username: this.userEmail.split('@')[0] ?? this.userEmail, - displayName: this.userEmail, - status: 'ACTIVE' + value: this.userEmail, + status: 'Active' }, - tenant: { id: randomUuid(), code: this.tenantCode, name: this.tenantName, status: 'ACTIVE' }, - systemSuite: { id: randomUuid(), code: this.systemSuiteCode, name: this.systemSuiteCode, status: 'PUBLISHED' }, - role: { id: randomUuid(), code: this.roleCode, name: this.roleCode, hierarchyLevel: 1, parentRoleId: null }, - profile: { id: randomUuid(), scope: this.profileScope, isActive: true }, + tenant: { code: this.tenantCode, value: this.tenantName, status: 'Active', isManagementOwner: false }, + systemSuite: { code: this.systemSuiteCode, value: this.systemSuiteCode, status: 'Active' }, + role: { code: this.roleCode, value: this.roleCode, hierarchyLevel: 1 }, + profile: { scope: this.profileScope, isActive: true }, branch: this.profileScope === 'BranchScoped' && this.branchCode !== null - ? { id: randomUuid(), code: this.branchCode, name: this.branchCode } - : null + ? { code: this.branchCode, value: this.branchCode } + : null, + // La API lo emite SIEMPRE, también como null: un cliente no debería tener que distinguir + // «no se pidió sistema» de «la clave no existe en esta versión». El builder no lo emitía y + // el contrato ya lo exigía — el grafo que fabricaba para las pruebas era, según el propio + // contrato del SDK, inválido (mismo mal que G-207). + requestedSystem: null }, authentication: { method: this.authMethod, @@ -194,19 +202,21 @@ export class AuthGraphBuilder { sessionExpiresAt: validUntil.toISOString() }, actions, + profiles: [], menuAccess, domainPermissions, featureFlags: this.flags.map((f) => ({ flagCode: f.flagCode, - systemSuiteId: randomUuid(), isEnabled: f.enabled, matchedCriteriaType: f.matchedCriteriaType })), + settings: {}, effectiveConfig: { sessionTimeoutMinutes: 60, maxLoginAttempts: 5, minPasswordLength: 12, mfaRequiredForAdmin: true, + mfaAllowedMethods: ['Totp'], accessTokenDurationMs: 3600000, authUseExternalIdp: this.authMethod === 'IDP' }, @@ -230,44 +240,65 @@ export class AuthGraphBuilder { if (this.menuOptions.length === 0) return []; const options = this.menuOptions.map((m) => { const existing = actions.find((a) => a.code === m.actionCode); - const actionRef = existing ?? { id: randomUuid(), code: m.actionCode, name: m.actionCode }; + const actionRef = existing ?? { code: m.actionCode, value: m.actionCode }; if (!existing) actions.push(actionRef); return { - id: randomUuid(), code: m.optionCode, - label: m.optionCode, + value: m.optionCode, actionCode: m.actionCode, effect: m.effect, source: m.source }; }); + // Un nodo hoja por opción, agrupando sus acciones: la forma del contrato v2.0.0. + const hojas = [...new Set(this.menuOptions.map(m => m.optionCode))].map(codigo => ({ + code: codigo, + value: codigo, + kind: 'Option' as const, + sortOrder: 1, + icon: null, + route: `/${codigo.toLowerCase()}`, + actions: options.filter(o => o.code === codigo).map(o => ({ + actionCode: o.actionCode, + effect: o.effect, + source: o.source, + })), + children: [], + })); + return [ { - module: { - id: randomUuid(), - code: 'TEST_MODULE', - name: 'Test Module', - sortOrder: 1, - status: 'PUBLISHED' - }, - menus: [ + code: 'TEST_MODULE', + value: 'Test Module', + sortOrder: 1, + status: 'Active', + // Se emite siempre, también como null: sin él un satélite sabe cómo se llama el módulo + // pero no con qué pintarlo, y acaba resolviendo el icono por código. + icon: null, + nodes: [ { - id: randomUuid(), code: 'TEST_MENU', - label: 'Test Menu', + value: 'Test Menu', + kind: 'Menu' as const, sortOrder: 1, - subMenus: [ + icon: null, + route: null, + actions: [], + children: [ { - id: randomUuid(), code: 'TEST_SUB', - label: 'Test SubMenu', + value: 'Test SubMenu', + kind: 'SubMenu' as const, sortOrder: 1, - options - } - ] - } - ] - } + icon: null, + route: null, + actions: [], + children: hojas, + }, + ], + }, + ], + }, ]; } @@ -283,39 +314,22 @@ export class AuthGraphBuilder { for (const [resourceCode, perms] of byResource) { const resolutions = perms.map((p) => { const existing = actions.find((a) => a.code === p.action); - const actionRef = existing ?? { id: randomUuid(), code: p.action, name: p.action }; + const actionRef = existing ?? { code: p.action, value: p.action }; if (!existing) actions.push(actionRef); return { - actionId: actionRef.id, actionCode: p.action, - actionName: p.action, + value: p.action, effect: p.effect, source: p.source }; }); result.push({ - resource: { - id: randomUuid(), - type: 'Aggregate', - code: resourceCode, - name: resourceCode, - moduleId: null - }, + resourceType: 'Aggregate', + resourceCode, + value: resourceCode, actions: resolutions }); } return result; } } - -function randomUuid(): string { - // RFC 4122 v4-ish; cryptographically random when crypto.randomUUID is available. - const c = globalThis.crypto as { randomUUID?: () => string } | undefined; - if (c?.randomUUID) return c.randomUUID(); - // Fallback for older environments. - return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => { - const r = Math.floor(Math.random() * 16); - const v = ch === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} From 1b2e72312a62e702754d3abc1c09a7f0715b9f9c Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 9 Aug 2026 20:16:53 -0500 Subject: [PATCH 03/35] feat(web): resincroniza el web-app con la plataforma de origen, conservando Branding y GraphQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reemplaza src/ y tests/ del web-app por el árbol de origen rebrandeado y vuelve a enganchar lo que es del satélite y el origen no tiene: - BrandingPanel quedaba huérfano tras la sustitución de TenantDetailPanel. Se reintroduce la pestaña «Identidad Visual» de punta a punta: el tipo de pestaña en use-tenant-dashboard, el icono y la etiqueta en el panel, y las claves tabBranding en los dos idiomas. - Se conserva la capa GraphQL (graphqlClient y los módulos *.graphql.ts), que sigue siendo el transporte de lectura de branding.service. Se retiran dos ficheros que quedan sin consumidor porque el árbol nuevo accede a datos por REST: query-transport.service (elegía REST/GraphQL por parámetro) y branding.model (ya estaba muerto antes de este cambio). Los componentes de jerarquía MenuRow/SubMenuRow/OptionRow/ModuleCard se van con el modelo de menú de tres niveles: los sustituye SuiteNode/SuiteNodeTree. Validación: vite build OK; vitest 1697/1697. Co-Authored-By: Claude Opus 5 --- src/apps/ums.web-app/Dockerfile | 8 +- src/apps/ums.web-app/README.es.md | 8 +- src/apps/ums.web-app/README.md | 8 +- src/apps/ums.web-app/eslint.config.js | 47 +- src/apps/ums.web-app/index.html | 2 +- src/apps/ums.web-app/nginx.conf.template | 40 ++ src/apps/ums.web-app/package.json | 1 - src/apps/ums.web-app/playwright.config.ts | 60 ++- .../public/brand/logo-beyondnet-white.png | Bin 0 -> 5277 bytes .../public/brand/logo-beyondnet.png | Bin 0 -> 10144 bytes src/apps/ums.web-app/src/App.tsx | 2 +- .../audit/hooks/use-audit-records.test.tsx | 112 +++++ .../hooks/use-graph-navigation.test.ts | 149 ++++++ .../hooks/use-graph-navigation.ts | 105 +++++ .../use-permission-template-dashboard.ts | 2 + .../hooks/use-permission-template.test.tsx | 153 ++++++- .../hooks/use-permission-template.ts | 92 +++- .../hooks/use-system-settings.ts | 86 ++++ .../hooks/use-system-suite.test.tsx | 156 ++++--- .../authorization/hooks/use-system-suite.ts | 193 +++----- .../authorization/utils/permission-cascade.ts | 155 ++++--- .../hooks/use-feature-flag-dashboard.ts | 2 + .../hooks/use-parameter-catalog-dashboard.ts | 2 + .../use-parameter-catalog.ts | 53 ++- .../src/application/errors/http-error.test.ts | 83 +++- .../src/application/errors/http-error.ts | 67 ++- .../src/application/formatting/index.ts | 5 + .../formatting/use-date-format.test.ts | 79 ++++ .../application/formatting/use-date-format.ts | 29 +- .../formatting/use-number-format.test.ts | 64 +++ .../formatting/use-number-format.ts | 58 +++ .../application/hooks/use-drag-resize.test.ts | 28 ++ .../src/application/hooks/use-drag-resize.ts | 76 +++- .../application/hooks/use-focus-trap.test.ts | 44 +- .../src/application/hooks/use-focus-trap.ts | 10 +- .../hooks/use-notified-mutation.ts | 11 +- .../hooks/use-status-label.test.ts | 6 +- .../src/application/hooks/use-status-label.ts | 10 +- .../namespaces/authorization.translations.ts | 6 + .../namespaces/identity.translations.test.ts | 14 - .../i18n/namespaces/identity.translations.ts | 128 +++--- .../identity/hooks/use-branch.test.tsx | 117 ++++- .../application/identity/hooks/use-branch.ts | 116 ++++- .../hooks/use-delegation-dashboard.ts | 2 + .../identity/hooks/use-delegation.ts | 54 ++- .../hooks/use-tenant-dashboard.test.ts | 20 - .../identity/hooks/use-tenant-dashboard.ts | 39 +- .../application/identity/hooks/use-tenant.ts | 34 +- .../hooks/use-user-account-dashboard.ts | 2 + .../identity/hooks/use-user-account.ts | 8 +- .../identity/services/auth.service.ts | 14 +- .../security/securityInterceptor.ts | 3 +- .../shared/hooks/use-navigation-prefetch.ts | 3 - .../shared/hooks/use-query-state.ts | 9 + .../src/application/stores/auth.store.ts | 51 +++ .../src/application/stores/devTools.store.ts | 3 +- .../src/application/stores/i18n.store.test.ts | 48 +- .../src/application/stores/i18n.store.ts | 31 +- .../application/stores/theme.store.test.ts | 27 +- .../src/application/stores/theme.store.ts | 21 +- .../src/application/utils/error-utils.ts | 17 +- .../audit/schemas/audit-record.schema.test.ts | 144 ++++++ .../audit/schemas/audit-record.schema.ts | 8 +- .../models/permission-template.model.ts | 7 +- .../schemas/authorization-graph.schema.ts | 136 ++++-- .../permission-template.schema.test.ts | 50 +++ .../schemas/permission-template.schema.ts | 48 +- .../schemas/system-suite.commands.schema.ts | 6 +- .../schemas/system-suite.schema.test.ts | 83 +++- .../schemas/system-suite.schema.ts | 96 ++-- .../app-configuration.commands.schema.test.ts | 90 ++++ .../app-configuration.commands.schema.ts | 6 +- .../schemas/app-configuration.schema.test.ts | 124 +++++ .../schemas/app-configuration.schema.ts | 8 +- .../schemas/feature-flag.commands.schema.ts | 4 +- .../identity/constants/idp.constants.test.ts | 12 +- .../identity/constants/idp.constants.ts | 19 +- .../identity/constants/tenant.constants.ts | 80 +++- .../domain/identity/models/branch.model.ts | 15 +- .../identity/models/branding.model.test.ts | 26 -- .../domain/identity/models/branding.model.ts | 32 -- .../domain/identity/models/tenant.schema.ts | 8 +- .../identity/schemas/branch.schema.test.ts | 108 +++++ .../domain/identity/schemas/branch.schema.ts | 90 +++- .../identity/schemas/delegation.schema.ts | 10 +- .../schemas/identity-provider.schema.ts | 2 +- .../identity/schemas/inbox.schema.test.ts | 94 ++++ .../domain/identity/schemas/inbox.schema.ts | 2 +- .../tenant-signup-request.schema.test.ts | 95 ++++ .../domain/identity/schemas/tenant.schema.ts | 7 +- .../identity/schemas/user-account.schema.ts | 10 +- src/apps/ums.web-app/src/index.css | 152 ++++--- .../services/audit-record.service.test.ts | 128 ++++++ .../permission-template.service.test.ts | 63 +-- .../services/permission-template.service.ts | 71 ++- .../services/profile.service.test.ts | 79 ++-- .../authorization/services/profile.service.ts | 59 ++- .../services/role.service.test.ts | 21 +- .../authorization/services/role.service.ts | 8 +- .../services/system-suite.service.test.ts | 128 +++--- .../services/system-suite.service.ts | 135 +++--- .../services/app-configuration.service.ts | 3 +- .../services/feature-flag.service.test.ts | 7 - .../parameter-catalog.service.ts | 21 +- .../services/query-transport.service.test.ts | 123 ----- .../services/query-transport.service.ts | 54 --- .../src/infrastructure/http/httpClient.ts | 6 +- .../http/request-context.test.ts | 2 +- .../infrastructure/http/request-context.ts | 10 +- .../identity/services/auth.service.ts | 23 +- .../services/delegation.service.test.ts | 61 +-- .../identity/services/delegation.service.ts | 53 ++- .../identity/services/idp.service.test.ts | 25 +- .../identity/services/idp.service.ts | 14 +- .../identity/services/tenant.service.test.ts | 181 ++++---- .../identity/services/tenant.service.ts | 84 ++-- .../services/user-account.service.test.ts | 20 +- .../identity/services/user-account.service.ts | 43 +- src/apps/ums.web-app/src/main.tsx | 19 +- .../PermissionTemplateDetailPanel.tsx | 131 +++--- .../components/tree/DomainResourcesPanel.tsx | 8 +- .../tree/ModulePermissionsPanel.tsx | 74 ++- .../components/tree/NodeDetailPanel.tsx | 80 ++-- .../components/tree/PermissionTree.tsx | 47 +- .../tree/SystemActionsPanel.test.tsx | 98 ++++ .../components/tree/SystemActionsPanel.tsx | 81 ++-- .../components/tree/TreeNode.tsx | 8 +- .../tree/ProfileModulePermissionsPanel.tsx | 64 +-- .../screens/ProfileDashboardScreen.tsx | 2 + .../components/SystemSuiteDetailPanel.tsx | 115 +++-- .../SystemSuiteDomainResourcesPanel.tsx | 11 +- .../SystemSuiteFeatureFlagsPanel.tsx | 5 +- .../components/SystemSuiteForm.test.tsx | 82 ++++ .../components/SystemSuiteForm.tsx | 7 +- .../components/SystemSuiteListPanel.tsx | 2 +- .../components/SystemSuiteProfileCard.tsx | 11 +- .../components/SystemSuiteRolesPanel.tsx | 17 +- .../components/hierarchy/MenuRow.tsx | 304 ------------- .../components/hierarchy/ModuleCard.tsx | 240 ---------- .../components/hierarchy/NodeActionsPanel.tsx | 106 +++++ .../hierarchy/NodeMetadataDialog.tsx | 138 ++++++ .../components/hierarchy/OptionRow.tsx | 175 -------- .../components/hierarchy/SubMenuRow.tsx | 302 ------------- .../components/hierarchy/SuiteNode.test.tsx | 72 +++ .../components/hierarchy/SuiteNode.tsx | 425 ++++++++++++++++++ .../hierarchy/SuiteNodeTree.test.tsx | 69 +++ .../components/hierarchy/SuiteNodeTree.tsx | 336 ++++++++++++++ .../components/hierarchy/index.ts | 9 +- .../components/hierarchy/types.ts | 47 -- .../components/AppConfigurationListPanel.tsx | 2 +- .../ParameterDefinitionPickerDialog.tsx | 2 +- .../GlobalAppConfigurationDashboardScreen.tsx | 21 +- .../components/FeatureFlagDetailPanel.tsx | 4 +- .../components/FeatureFlagForm.tsx | 2 +- .../components/FeatureFlagListPanel.tsx | 3 +- .../components/ParameterCatalogListPanel.tsx | 2 +- .../components/ParameterDefinitionForm.tsx | 4 +- .../delegation/components/DelegationForm.tsx | 12 +- .../components/DelegationListPanel.tsx | 2 +- .../components/DelegationProfileCard.tsx | 121 +++-- .../screens/DelegationDashboardScreen.tsx | 2 +- .../profile/components/ForgotPasswordForm.tsx | 237 ++++++---- .../identity/profile/screens/LoginScreen.tsx | 106 +++-- .../profile/screens/ProfileScreen.tsx | 4 +- .../components/BranchLifecycleDialog.tsx | 125 ++++++ .../tenant/components/BranchManager.test.tsx | 134 ++++++ .../tenant/components/BranchManager.tsx | 205 +++++++-- .../identity/tenant/components/IdpPanel.tsx | 36 +- .../tenant/components/TenantAuditPanel.tsx | 124 +++++ .../components/TenantConfigurationsPanel.tsx | 46 +- .../tenant/components/TenantDetailPanel.tsx | 16 +- .../identity/tenant/components/TenantForm.tsx | 2 +- .../tenant/components/TenantListPanel.tsx | 1 + .../components/TenantProfileCard.test.tsx | 2 + .../tenant/components/TenantProfileCard.tsx | 37 +- .../components/TenantSignupRequestsPanel.tsx | 2 +- .../tenant/components/tenant-list-renders.tsx | 15 +- .../tenant/screens/TenantDashboardScreen.tsx | 34 ++ .../components/UserAccountForm.tsx | 2 +- .../components/UserAccountPasswordPanel.tsx | 4 + .../components/UserSignupsInboxPanel.tsx | 2 +- .../shared/components/AccessGuard.test.tsx | 134 ++++++ .../shared/components/ApiErrorBanner.test.tsx | 44 +- .../shared/components/ApiErrorBanner.tsx | 7 +- .../shared/components/ChildEntityToolbar.tsx | 7 +- .../shared/components/ConnectedUserDrawer.tsx | 2 +- .../shared/components/DataGrid.test.tsx | 46 +- .../shared/components/DataGrid.tsx | 144 +++++- .../shared/components/EntityCard.tsx | 2 +- .../shared/components/EntityRow.tsx | 12 +- .../shared/components/ErrorBoundary.tsx | 2 - .../shared/components/InlineAddForm.tsx | 4 +- .../shared/components/ListToolbar.tsx | 90 ++-- .../shared/components/M3DataView.tsx | 9 +- .../shared/components/M3Dialog.test.tsx | 28 ++ .../PermissionSectionToolbar.test.tsx | 8 + .../components/PermissionSectionToolbar.tsx | 7 +- .../components/ProfileSelector.test.tsx | 93 ++++ .../shared/components/ProfileSelector.tsx | 117 +++++ .../shared/components/ProtectedRoute.test.tsx | 124 +++++ .../shared/components/ProtectedRoute.tsx | 1 + .../shared/components/SectionHeader.tsx | 2 +- .../shared/components/StatusBadge.tsx | 2 + .../components/SystemThemeApplier.test.tsx | 196 ++++++++ .../shared/components/SystemThemeApplier.tsx | 182 ++++++++ .../shared/components/ToastQueue.tsx | 2 + .../shared/components/Tooltip.tsx | 3 + .../shared/components/data-view/DataTable.tsx | 7 +- .../shared/hooks/use-breakpoint.ts | 42 ++ .../shared/layouts/MainLayout.test.tsx | 43 ++ .../shared/layouts/MainLayout.tsx | 43 +- .../layouts/MasterDetailLayout.test.tsx | 29 +- .../shared/layouts/MasterDetailLayout.tsx | 79 +++- .../shared/layouts/NavRail.test.tsx | 58 ++- .../presentation/shared/layouts/NavRail.tsx | 358 ++++++++------- .../shared/layouts/TopAppBar.test.tsx | 134 +++++- .../presentation/shared/layouts/TopAppBar.tsx | 96 +++- .../shared/layouts/navigation.config.test.tsx | 63 +-- .../shared/layouts/navigation.config.tsx | 77 +++- .../shared/layouts/navigation.icons.tsx | 84 ++++ .../layouts/use-shell-navigation.test.tsx | 180 ++++++++ .../shared/layouts/use-shell-navigation.tsx | 183 ++++++++ .../shared/utils/pluralize.test.ts | 48 ++ .../presentation/shared/utils/pluralize.ts | 32 ++ .../src/test/mocks/data/system-suites.mock.ts | 311 ++++--------- .../src/test/mocks/data/tenants.mock.ts | 36 +- .../src/test/mocks/data/user-accounts.mock.ts | 12 +- .../ums.web-app/src/test/mocks/handlers.ts | 155 +------ src/apps/ums.web-app/tailwind.config.js | 37 +- .../tests/app-configuration-state.spec.ts | 214 +++++++++ src/apps/ums.web-app/tests/auth.spec.ts | 26 +- .../tests/authorization-ui.spec.ts | 28 +- .../ums.web-app/tests/business-flows.spec.ts | 245 ++++++++++ .../tests/delegation-approval-gate.spec.ts | 200 +++++++++ .../tests/feature-flag-state.spec.ts | 189 ++++++++ .../ums.web-app/tests/helpers/limpieza.ts | 61 +++ .../tests/identity-provider.spec.ts | 154 +++++++ src/apps/ums.web-app/tests/login.spec.ts | 12 +- .../tests/mvp-creation-chain.spec.ts | 233 ++++++++++ src/apps/ums.web-app/tests/mvp-flows.spec.ts | 78 ++++ src/apps/ums.web-app/tests/navigation.spec.ts | 93 +++- .../tests/parameter-catalog-state.spec.ts | 204 +++++++++ .../tests/parameter-values.spec.ts | 193 ++++++++ .../tests/permission-template-state.spec.ts | 136 ++++++ .../ums.web-app/tests/profile-panel.spec.ts | 6 +- .../ums.web-app/tests/profile-slice.spec.ts | 171 +++++++ .../ums.web-app/tests/profile-state.spec.ts | 293 ++++++++++++ src/apps/ums.web-app/tests/role-state.spec.ts | 154 +++++++ .../tests/system-suite-actions.spec.ts | 158 +++++++ .../tests/system-suite-modules.spec.ts | 223 +++++++++ .../tests/system-suite-state.spec.ts | 83 ++++ .../tests/tenant-state-toggle.spec.ts | 97 ++++ .../tests/user-account-state.spec.ts | 194 ++++++++ src/apps/ums.web-app/vite.config.ts | 10 +- src/apps/ums.web-app/vitest.config.ts | 18 +- 255 files changed, 12823 insertions(+), 4188 deletions(-) create mode 100644 src/apps/ums.web-app/public/brand/logo-beyondnet-white.png create mode 100644 src/apps/ums.web-app/public/brand/logo-beyondnet.png create mode 100644 src/apps/ums.web-app/src/application/audit/hooks/use-audit-records.test.tsx create mode 100644 src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.test.ts create mode 100644 src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.ts create mode 100644 src/apps/ums.web-app/src/application/authorization/hooks/use-system-settings.ts create mode 100644 src/apps/ums.web-app/src/application/formatting/use-date-format.test.ts create mode 100644 src/apps/ums.web-app/src/application/formatting/use-number-format.test.ts create mode 100644 src/apps/ums.web-app/src/application/formatting/use-number-format.ts create mode 100644 src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.test.ts create mode 100644 src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.commands.schema.test.ts create mode 100644 src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.schema.test.ts delete mode 100644 src/apps/ums.web-app/src/domain/identity/models/branding.model.test.ts delete mode 100644 src/apps/ums.web-app/src/domain/identity/models/branding.model.ts create mode 100644 src/apps/ums.web-app/src/domain/identity/schemas/inbox.schema.test.ts create mode 100644 src/apps/ums.web-app/src/domain/identity/schemas/tenant-signup-request.schema.test.ts create mode 100644 src/apps/ums.web-app/src/infrastructure/audit/services/audit-record.service.test.ts delete mode 100644 src/apps/ums.web-app/src/infrastructure/configuration/services/query-transport.service.test.ts delete mode 100644 src/apps/ums.web-app/src/infrastructure/configuration/services/query-transport.service.ts create mode 100644 src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/SystemActionsPanel.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.test.tsx delete mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/MenuRow.tsx delete mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/ModuleCard.tsx create mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeActionsPanel.tsx create mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeMetadataDialog.tsx delete mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/OptionRow.tsx delete mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SubMenuRow.tsx create mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.tsx create mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.tsx delete mode 100644 src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/types.ts create mode 100644 src/apps/ums.web-app/src/presentation/identity/tenant/components/BranchLifecycleDialog.tsx create mode 100644 src/apps/ums.web-app/src/presentation/identity/tenant/components/BranchManager.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantAuditPanel.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/components/AccessGuard.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/hooks/use-breakpoint.ts create mode 100644 src/apps/ums.web-app/src/presentation/shared/layouts/navigation.icons.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.test.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.tsx create mode 100644 src/apps/ums.web-app/src/presentation/shared/utils/pluralize.test.ts create mode 100644 src/apps/ums.web-app/src/presentation/shared/utils/pluralize.ts create mode 100644 src/apps/ums.web-app/tests/app-configuration-state.spec.ts create mode 100644 src/apps/ums.web-app/tests/business-flows.spec.ts create mode 100644 src/apps/ums.web-app/tests/delegation-approval-gate.spec.ts create mode 100644 src/apps/ums.web-app/tests/feature-flag-state.spec.ts create mode 100644 src/apps/ums.web-app/tests/helpers/limpieza.ts create mode 100644 src/apps/ums.web-app/tests/identity-provider.spec.ts create mode 100644 src/apps/ums.web-app/tests/mvp-creation-chain.spec.ts create mode 100644 src/apps/ums.web-app/tests/mvp-flows.spec.ts create mode 100644 src/apps/ums.web-app/tests/parameter-catalog-state.spec.ts create mode 100644 src/apps/ums.web-app/tests/parameter-values.spec.ts create mode 100644 src/apps/ums.web-app/tests/permission-template-state.spec.ts create mode 100644 src/apps/ums.web-app/tests/profile-slice.spec.ts create mode 100644 src/apps/ums.web-app/tests/profile-state.spec.ts create mode 100644 src/apps/ums.web-app/tests/role-state.spec.ts create mode 100644 src/apps/ums.web-app/tests/system-suite-actions.spec.ts create mode 100644 src/apps/ums.web-app/tests/system-suite-modules.spec.ts create mode 100644 src/apps/ums.web-app/tests/system-suite-state.spec.ts create mode 100644 src/apps/ums.web-app/tests/tenant-state-toggle.spec.ts create mode 100644 src/apps/ums.web-app/tests/user-account-state.spec.ts diff --git a/src/apps/ums.web-app/Dockerfile b/src/apps/ums.web-app/Dockerfile index 66f98c8c..90731bbe 100644 --- a/src/apps/ums.web-app/Dockerfile +++ b/src/apps/ums.web-app/Dockerfile @@ -5,7 +5,11 @@ FROM node:24-alpine AS base WORKDIR /usr/src/app COPY package*.json ./ COPY apps/ums.web-app/package*.json ./apps/ums.web-app/ -RUN npm ci +# G-123: el package-lock.json regenerado en macOS omite los binarios nativos opcionales de rollup +# (@rollup/rollup-linux-*-musl) — bug npm #4828 —, así que `npm ci`/`npm install` con ese lock dejan +# `vite build` sin rollup en alpine. Se borra el lock para forzar resolución fresca que incluya la +# optional-dep del target musl en build-time. El fix de fondo es regenerar el lock con plataformas linux. +RUN rm -f package-lock.json apps/ums.web-app/package-lock.json && npm install --no-audit --no-fund # ========================================================= # Phase 2: Production Compilation @@ -26,6 +30,8 @@ COPY --from=builder /usr/src/app/apps/ums.web-app/dist /usr/share/nginx/html # /etc/nginx/templates/*.template so API_UPSTREAM can be set per deployment. COPY apps/ums.web-app/nginx.conf.template /etc/nginx/templates/default.conf.template ENV API_UPSTREAM=http://ums-backend:80 +# Upstream de Grafana para el proxy /grafana/ (observabilidad en la barra). +ENV GRAFANA_UPSTREAM=http://grafana:3000 EXPOSE 80 diff --git a/src/apps/ums.web-app/README.es.md b/src/apps/ums.web-app/README.es.md index 1e088a67..13776308 100644 --- a/src/apps/ums.web-app/README.es.md +++ b/src/apps/ums.web-app/README.es.md @@ -9,10 +9,10 @@ UMS Web Console es el portal React 18 para la experiencia administrativa de User | Necesidad | Abrir esto | | ---------------------------- | ----------------------------------------------------------------------- | | README raiz | [Resumen del repositorio](../../../README.md) | -| Portal documental en ingles | [docs/README.md](../../../docs/README.md) | -| Portal documental en espanol | [docs/README.es.md](../../../docs/README.es.md) | -| Portal de arquitectura | [docs/architecture/index.es.md](../../../docs/architecture/index.es.md) | -| Portal de gobernanza | [docs/governance/index.es.md](../../../docs/governance/index.es.md) | +| Portal documental en ingles | [docs/README.md](../../../reference/indices/index.md) | +| Portal documental en espanol | [docs/README.es.md](../../../reference/indices/index.md) | +| Portal de arquitectura | [docs/architecture/index.es.md](../../../reference/architecture/index.md) | +| Portal de gobernanza | [docs/governance/index.es.md](../../../reference/gobernanza/index.md) | ## Vista General diff --git a/src/apps/ums.web-app/README.md b/src/apps/ums.web-app/README.md index 7132dc2c..e76570e2 100644 --- a/src/apps/ums.web-app/README.md +++ b/src/apps/ums.web-app/README.md @@ -9,10 +9,10 @@ UMS Web Console is the React 18 portal for the User Management System administra | Need | Open this | | ---------------------------- | ----------------------------------------------------------------- | | Root README | [Repository overview](../../../README.md) | -| English documentation portal | [docs/README.md](../../../docs/README.md) | -| Spanish documentation portal | [docs/README.es.md](../../../docs/README.es.md) | -| Architecture portal | [docs/architecture/index.md](../../../docs/architecture/index.md) | -| Governance portal | [docs/governance/index.md](../../../docs/governance/index.md) | +| English documentation portal | [docs/README.md](../../../reference/indices/index.md) | +| Spanish documentation portal | [docs/README.es.md](../../../reference/indices/index.md) | +| Architecture portal | [docs/architecture/index.md](../../../reference/architecture/index.md) | +| Governance portal | [docs/governance/index.md](../../../reference/gobernanza/index.md) | ## At a Glance diff --git a/src/apps/ums.web-app/eslint.config.js b/src/apps/ums.web-app/eslint.config.js index 5d86bde3..4cd98fd9 100644 --- a/src/apps/ums.web-app/eslint.config.js +++ b/src/apps/ums.web-app/eslint.config.js @@ -5,11 +5,25 @@ import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; import prettier from 'eslint-plugin-prettier'; import eslintConfigPrettier from 'eslint-config-prettier'; +// Análisis estático serverless para TS: las reglas de SonarSource como plugin de +// ESLint (sin servidor SonarQube ni token). Contraparte de SonarAnalyzer.CSharp +// en el backend .NET. Ver G-016. +import sonarjs from 'eslint-plugin-sonarjs'; +// SAST local orientado a SEGURIDAD para TS (contraparte de SonarAnalyzer.CSharp en el +// backend). Detecta patrones inseguros: `eval`, RegExp/child_process/fs con datos no +// confiables, `Math.random()` en contexto de seguridad, etc. Ejecución 100% local. +import security from 'eslint-plugin-security'; export default tseslint.config( { ignores: ['dist'] }, { - extends: [js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + sonarjs.configs.recommended, + security.configs.recommended, + eslintConfigPrettier, + ], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, @@ -26,32 +40,25 @@ export default tseslint.config( // fully flattened. TypeScript strict build covers this check reliably. '@typescript-eslint/no-unused-expressions': 'off', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], - // ── Deuda de estilo heredada (visible como warning, no bloquea CI) ────── - // Se degradan a `warn` mientras se atacan de forma incremental. Las reglas de - // CORRECTITUD (react-hooks/*, no-fallthrough, no-case-declarations) siguen como - // error. Ver la política de lint del repo. - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': 'warn', - 'no-empty': 'warn', - 'no-useless-escape': 'warn', - // Reglas OPINADAS nuevas de eslint-plugin-react-hooks v7 (perf/patrón): visibles como - // warning mientras se atacan incrementalmente. La regla CRÍTICA `rules-of-hooks` sigue - // como error. Refactorizar estos patrones en código ya probado (1476 tests) se hace por - // separado para no arriesgar regresiones. - 'react-hooks/set-state-in-effect': 'warn', - 'react-hooks/refs': 'warn', - 'react-hooks/purity': 'warn', - 'react-hooks/immutability': 'warn', - 'react-hooks/static-components': 'warn', - 'react-hooks/preserve-manual-memoization': 'warn', // Production: only allow console.error (for error boundaries) - 'no-console': ['warn', { allow: ['error'] }], + 'no-console': ['error', { allow: ['error'] }], // Require explicit return types on exported functions for API boundaries '@typescript-eslint/explicit-function-return-type': 'off', // Allow non-null assertions in test files only '@typescript-eslint/no-non-null-assertion': 'warn', // Prettier formatting rules 'prettier/prettier': 'error', + // SEGURIDAD: se desactiva `detect-object-injection` — marca cualquier acceso + // `obj[variable]`, patrón ubicuo y casi siempre falso positivo (la propia doc del + // plugin lo advierte). Se conservan las reglas de alto valor: detect-eval-with- + // expression, detect-child-process, detect-non-literal-fs-filename, detect-non- + // literal-require, detect-unsafe-regex, detect-pseudoRandomBytes, etc. + 'security/detect-object-injection': 'off', + // Debatibles en un frontend (lado cliente): se reportan como warning (visibles, + // no bloquean) mientras se revisan. Los críticos (eval, child_process, + // non-literal-require/fs) siguen como error del preset → bloquean. + 'security/detect-possible-timing-attacks': 'warn', + 'security/detect-non-literal-regexp': 'warn', }, } ); diff --git a/src/apps/ums.web-app/index.html b/src/apps/ums.web-app/index.html index 30ee93de..19fc6cc9 100644 --- a/src/apps/ums.web-app/index.html +++ b/src/apps/ums.web-app/index.html @@ -2,7 +2,7 @@ - + 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 G`u_k8WRfia literal 0 HcmV?d00001 diff --git a/src/apps/ums.web-app/src/App.tsx b/src/apps/ums.web-app/src/App.tsx index a0dcf114..8067aec2 100644 --- a/src/apps/ums.web-app/src/App.tsx +++ b/src/apps/ums.web-app/src/App.tsx @@ -69,7 +69,7 @@ export default function App() { return (
- + }> diff --git a/src/apps/ums.web-app/src/application/audit/hooks/use-audit-records.test.tsx b/src/apps/ums.web-app/src/application/audit/hooks/use-audit-records.test.tsx new file mode 100644 index 00000000..0bfe7ac2 --- /dev/null +++ b/src/apps/ums.web-app/src/application/audit/hooks/use-audit-records.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { useGetAuditRecords } from './use-audit-records'; +import { auditRecordService } from '@infra/audit/services/audit-record.service'; + +vi.mock('@infra/audit/services/audit-record.service', () => ({ + auditRecordService: { + getAll: vi.fn(), + }, + default: { + getAll: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +const mockPage = { + items: [ + { + auditRecordId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + whoActed: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + subjectType: 'UserAccount', + whenOccurred: '2024-01-01T00:00:00Z', + whatChanged: 'status changed', + eventType: 'UserSuspended', + auditResult: 'Success', + affectedEntityId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', + affectedEntityType: 'UserAccount', + rootTenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa9', + metadata: null, + }, + ], + page: 1, + pageSize: 20, + totalItems: 1, + totalPages: 1, +}; + +describe('useGetAuditRecords', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('devuelve la página de registros en estado de éxito', async () => { + vi.mocked(auditRecordService.getAll).mockResolvedValue(mockPage); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetAuditRecords({ page: 1, pageSize: 20 }), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.items[0].eventType).toBe('UserSuspended'); + expect(result.current.data?.totalItems).toBe(1); + expect(auditRecordService.getAll).toHaveBeenCalledWith({ page: 1, pageSize: 20 }); + }); + + it('propaga los parámetros de filtro al servicio', async () => { + vi.mocked(auditRecordService.getAll).mockResolvedValue(mockPage); + + const params = { + page: 2, + pageSize: 10, + eventType: 'RoleAssigned', + actorId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + }; + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetAuditRecords(params), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(auditRecordService.getAll).toHaveBeenCalledWith(params); + }); + + it('no invoca al servicio cuando enabled es false', async () => { + vi.mocked(auditRecordService.getAll).mockResolvedValue(mockPage); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetAuditRecords(undefined, false), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(result.current.data).toBeUndefined(); + expect(auditRecordService.getAll).not.toHaveBeenCalled(); + }); + + it('expone el estado de error cuando el servicio rechaza', async () => { + vi.mocked(auditRecordService.getAll).mockRejectedValue(new Error('boom')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetAuditRecords(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error?.message).toBe('boom'); + }); +}); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.test.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.test.ts new file mode 100644 index 00000000..3763655b --- /dev/null +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useGraphNavigation, itemsNavegables } from './use-graph-navigation'; + +let menuAccess: unknown; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { menuAccess } } }), +})); + +const nodo = ( + code: string, + kind: string, + extra: Partial<{ + icon: string | null; + route: string | null; + actions: Array<{ actionCode: string; effect: string; source: string }>; + children: unknown[]; + }> = {} +) => ({ + code, + value: `Etiqueta ${code}`, + kind, + sortOrder: 1, + icon: extra.icon ?? null, + route: extra.route ?? null, + actions: extra.actions ?? [], + children: extra.children ?? [], +}); + +describe('useGraphNavigation', () => { + beforeEach(() => { + menuAccess = undefined; + }); + + it('devuelve vacío sin grafo', () => { + const { result } = renderHook(() => useGraphNavigation()); + expect(result.current).toEqual([]); + }); + + it('conserva la jerarquía y el icono y la ruta de cada nodo', () => { + menuAccess = [ + { + code: 'PORT', + value: 'Portafolio', + sortOrder: 2, + status: 'Active', + icon: 'layout-dashboard', + nodes: [ + nodo('DASHBOARDS', 'Menu', { + icon: 'layout-dashboard', + children: [ + nodo('DASH_EJEC', 'SubMenu', { + children: [ + nodo('PORT_DASHBOARD', 'Option', { + route: '/portafolio', + actions: [{ actionCode: 'VIEW', effect: 'Allow', source: 'Template' }], + }), + ], + }), + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useGraphNavigation()); + const menu = result.current[0].items[0]; + + // Desde el contrato 2.3.0 el módulo trae su propio icono, no solo sus nodos. + expect(result.current[0].icon).toBe('layout-dashboard'); + expect(menu.icon).toBe('layout-dashboard'); + // Un menú agrupa: no navega. + expect(menu.route).toBeNull(); + + const hoja = menu.children[0].children[0]; + expect(hoja.route).toBe('/portafolio'); + expect(hoja.allowed.has('VIEW')).toBe(true); + }); + + it('separa lo concedido de lo denegado explícitamente', () => { + menuAccess = [ + { + code: 'PRD', + value: 'Iniciativas', + sortOrder: 1, + status: 'Active', + nodes: [ + nodo('ART_CERT', 'Option', { + route: '/prds/artefactos/certificacion', + actions: [ + { actionCode: 'VIEW', effect: 'Allow', source: 'Template' }, + { actionCode: 'CERTIFY', effect: 'Deny', source: 'Override' }, + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useGraphNavigation()); + const item = result.current[0].items[0]; + + // `Deny` no es ausencia: la interfaz debe poder bloquear en vez de ocultar. + expect(item.allowed.has('VIEW')).toBe(true); + expect(item.denied.has('CERTIFY')).toBe(true); + expect(item.allowed.has('CERTIFY')).toBe(false); + }); + + it('ordena los módulos y los nodos por su sortOrder', () => { + menuAccess = [ + { code: 'B', value: 'B', sortOrder: 2, status: 'Active', icon: null, nodes: [] }, + { code: 'A', value: 'A', sortOrder: 1, status: 'Active', icon: null, nodes: [] }, + ]; + + const { result } = renderHook(() => useGraphNavigation()); + expect(result.current.map(m => m.code)).toEqual(['A', 'B']); + // Un módulo sin icono configurado llega como null, no como un icono inventado. + expect(result.current[0].icon).toBeNull(); + }); +}); + +describe('itemsNavegables', () => { + it('devuelve solo lo que tiene ruta, a cualquier profundidad', () => { + menuAccess = [ + { + code: 'PRD', + value: 'Iniciativas', + sortOrder: 1, + status: 'Active', + nodes: [ + nodo('MENU', 'Menu', { + children: [ + nodo('SUB', 'SubMenu', { + children: [nodo('HOJA', 'Option', { route: '/prds' })], + }), + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useGraphNavigation()); + const navegables = itemsNavegables(result.current); + + // Menú y submenú agrupan; solo la hoja navega. + expect(navegables.map(i => i.code)).toEqual(['HOJA']); + }); +}); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.ts new file mode 100644 index 00000000..cd8275e3 --- /dev/null +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.ts @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '@app/stores/auth.store'; +import type { GraphNavigationNode } from '@domain/authorization/schemas/authorization-graph.schema'; + +/** + * La navegación que publica el sistema, lista para pintar. + * + * Desde el contrato v2.2.0 cada nodo trae `icon` y `route` —y desde v2.3.0 el módulo trae el + * suyo—, que era lo que faltaba para que el + * cliente construyera su menú sin conocer de antemano el sistema: con solo código y etiqueta sabía + * qué texto poner, pero no qué icono ni a dónde llevar al usuario. + * + * Este hook NO decide el aspecto: aplana el árbol conservando la jerarquía y resuelve tres cosas + * que, si cada pantalla las resolviera por su cuenta, acabarían divergiendo: + * + * 1. **Qué es navegable.** Un nodo con `route` lleva a algún sitio; uno sin ella solo agrupa. + * 2. **Qué acciones concede.** Se exponen como conjunto para que un `disabled` sea una consulta + * y no un recorrido. + * 3. **Qué está bloqueado explícitamente.** `Deny` no es lo mismo que ausencia, y la interfaz + * debería poder distinguir «no puedes» de «no existe». + * + * El árbol viaja ya podado: lo que llega es lo alcanzable. No hay que filtrar por permiso otra vez. + */ +export interface NavigationItem { + code: string; + label: string; + kind: string; + icon: string | null; + route: string | null; + /** Acciones concedidas sobre el nodo. */ + allowed: ReadonlySet; + /** Acciones denegadas explícitamente: bloquear, no ocultar. */ + denied: ReadonlySet; + children: NavigationItem[]; +} + +export interface NavigationModule { + code: string; + label: string; + sortOrder: number; + /** Icono del módulo publicado por el sistema; null si no se configuró. */ + icon: string | null; + items: NavigationItem[]; +} + +const VACIO: NavigationModule[] = []; + +function mapear(nodo: GraphNavigationNode): NavigationItem { + const allowed = new Set(); + const denied = new Set(); + + for (const accion of nodo.actions ?? []) { + if (accion.effect === 'Allow') allowed.add(accion.actionCode); + else if (accion.effect === 'Deny') denied.add(accion.actionCode); + } + + return { + code: nodo.code, + label: nodo.value, + kind: nodo.kind, + icon: nodo.icon, + route: nodo.route, + allowed, + denied, + children: (nodo.children ?? []).map(mapear), + }; +} + +export function useGraphNavigation(): NavigationModule[] { + const menuAccess = useAuthStore(state => state.user?.authorizationGraph?.menuAccess); + + return useMemo(() => { + if (!menuAccess?.length) return VACIO; + + return [...menuAccess] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(modulo => ({ + code: modulo.code, + label: modulo.value, + sortOrder: modulo.sortOrder, + icon: modulo.icon ?? null, + items: [...(modulo.nodes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder).map(mapear), + })); + }, [menuAccess]); +} + +/** + * Aplana la navegación en las entradas que REALMENTE navegan: las que tienen ruta. + * + * Útil para resolver qué elemento marcar como activo a partir de la ruta actual, sin que cada + * pantalla reimplemente el recorrido del árbol. + */ +export function itemsNavegables(modulos: NavigationModule[]): NavigationItem[] { + const salida: NavigationItem[] = []; + + const visitar = (items: NavigationItem[]) => { + for (const item of items) { + if (item.route) salida.push(item); + visitar(item.children); + } + }; + + modulos.forEach(m => visitar(m.items)); + return salida; +} diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.ts index a21a61ba..d0d09f17 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.ts @@ -17,6 +17,8 @@ export function usePermissionTemplateDashboard(tenantId?: string) { criteria: 'role', filter: 'all', sortBy: 'suite', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.test.tsx b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.test.tsx index c584aed7..23dd3add 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.test.tsx +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.test.tsx @@ -10,8 +10,10 @@ import { useDeprecatePermissionTemplate, useAddTemplateItem, useRemoveTemplateItem, + useApplyTemplateItemEffect, } from './use-permission-template'; import permissionTemplateService from '@infra/authorization/services/permission-template.service'; +import type { PermissionTemplateItem } from '@domain/authorization/models/permission-template.model'; vi.mock('@infra/authorization/services/permission-template.service', () => ({ permissionTemplateService: { @@ -22,6 +24,8 @@ vi.mock('@infra/authorization/services/permission-template.service', () => ({ deprecate: vi.fn(), addItem: vi.fn(), removeItem: vi.fn(), + setItemEffect: vi.fn(), + activateItem: vi.fn(), }, default: { getAll: vi.fn(), @@ -31,6 +35,8 @@ vi.mock('@infra/authorization/services/permission-template.service', () => ({ deprecate: vi.fn(), addItem: vi.fn(), removeItem: vi.fn(), + setItemEffect: vi.fn(), + activateItem: vi.fn(), }, })); @@ -191,7 +197,9 @@ describe('use-permission-template hooks', () => { expect(permissionTemplateService.addItem).toHaveBeenCalledWith('t1', expect.any(Object)); }); - it('useRemoveTemplateItem calls service successfully', async () => { + // El DELETE conserva ruta y contrato, pero desde ADR-0164 retira la fila en vez de borrarla. + // El hook sigue existiendo para dar de baja la concesión entera; ya no para expresar «Neutral». + it('useRemoveTemplateItem retira el ítem contra el mismo endpoint', async () => { vi.mocked(permissionTemplateService.removeItem).mockResolvedValue(); const wrapper = createWrapper(); @@ -208,3 +216,146 @@ describe('use-permission-template hooks', () => { expect(permissionTemplateService.removeItem).toHaveBeenCalledWith('t1', 'item1'); }); }); + +// ─── ADR-0164: Neutral no borra, y lo retirado se reactiva ─────────────────── + +describe('useApplyTemplateItemEffect', () => { + const target = { + targetType: 'SystemSuite' as const, + targetId: 'suite-1', + actionId: 'action-1', + }; + + const item = (overrides: Partial): PermissionTemplateItem => ({ + itemId: 'item-1', + targetType: 'SystemSuite', + targetId: 'suite-1', + targetName: 'Suite', + actionId: 'action-1', + actionName: 'Leer', + isAllowed: false, + isDenied: false, + isActive: true, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(permissionTemplateService.addItem).mockResolvedValue(); + vi.mocked(permissionTemplateService.removeItem).mockResolvedValue(); + vi.mocked(permissionTemplateService.setItemEffect).mockResolvedValue(); + vi.mocked(permissionTemplateService.activateItem).mockResolvedValue(); + }); + + it('pone Neutral con el verbo de efecto y no borra el ítem', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ + effect: 'Neutral', + item: item({ isAllowed: true }), + target, + }); + }); + + expect(permissionTemplateService.setItemEffect).toHaveBeenCalledWith('t1', 'item-1', 'Neutral'); + expect(permissionTemplateService.removeItem).not.toHaveBeenCalled(); + }); + + it('reactiva el ítem retirado en vez de volver a darlo de alta (evita el 409)', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ + effect: 'Allow', + item: item({ isActive: false, isAllowed: true }), + target, + }); + }); + + expect(permissionTemplateService.activateItem).toHaveBeenCalledWith('t1', 'item-1'); + expect(permissionTemplateService.addItem).not.toHaveBeenCalled(); + // El efecto guardado ya era Allow: reactivar basta, no hace falta reescribirlo. + expect(permissionTemplateService.setItemEffect).not.toHaveBeenCalled(); + }); + + it('al reactivar corrige el efecto si el guardado no es el pedido', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ + effect: 'Deny', + item: item({ isActive: false, isAllowed: true }), + target, + }); + }); + + expect(permissionTemplateService.activateItem).toHaveBeenCalledWith('t1', 'item-1'); + expect(permissionTemplateService.setItemEffect).toHaveBeenCalledWith('t1', 'item-1', 'Deny'); + }); + + it('no toca nada si se pide Neutral sobre un ítem ya retirado', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + let wrote = true; + await act(async () => { + wrote = await result.current.applyEffect({ + effect: 'Neutral', + item: item({ isActive: false, isAllowed: true }), + target, + }); + }); + + expect(wrote).toBe(false); + expect(permissionTemplateService.setItemEffect).not.toHaveBeenCalled(); + expect(permissionTemplateService.activateItem).not.toHaveBeenCalled(); + expect(permissionTemplateService.removeItem).not.toHaveBeenCalled(); + }); + + it('crea la fila cuando la casilla nunca tuvo ítem', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ effect: 'Allow', item: undefined, target }); + }); + + expect(permissionTemplateService.addItem).toHaveBeenCalledWith('t1', { + ...target, + isAllowed: true, + isDenied: false, + }); + }); + + it('no crea fila para pedir Neutral sobre una casilla sin ítem', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ effect: 'Neutral', item: undefined, target }); + }); + + expect(permissionTemplateService.addItem).not.toHaveBeenCalled(); + }); + + it('no reescribe el efecto que ya está vigente', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + let wrote = true; + await act(async () => { + wrote = await result.current.applyEffect({ + effect: 'Allow', + item: item({ isAllowed: true }), + target, + }); + }); + + expect(wrote).toBe(false); + expect(permissionTemplateService.setItemEffect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.ts index 15869fc4..d4ccbfb7 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.ts @@ -1,11 +1,17 @@ +import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import permissionTemplateService from '@infra/authorization/services/permission-template.service'; import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { type CreatePermissionTemplatePayload, type AddTemplateItemPayload, + type ExclusiveArcTarget, + type PermissionEffect, + type PermissionTemplateItem, type PermissionTemplatePage, type PermissionTemplateDetail, + isRetiredItem, + itemStoredEffect, } from '@domain/authorization/models/permission-template.model'; import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; @@ -142,18 +148,24 @@ export const useAddTemplateItem = (templateId: string) => }), }); +/** + * Retira el ítem: el DELETE conserva ruta y contrato pero ya es el verbo lógico (ADR-0164), así que + * la fila sobrevive con `isActive: false` y sigue ocupando su clave (objetivo + acción). No sirve + * para expresar «Neutral» —para eso está `useApplyTemplateItemEffect`— sino para dar de baja la + * concesión entera. + */ export const useRemoveTemplateItem = (templateId: string) => useNotifiedMutation({ mutationFn: (itemId: string) => permissionTemplateService.removeItem(templateId, itemId), invalidateKeys: [['permission-templates', templateId]], successNotif: () => ({ - title: 'Permiso Eliminado', - message: 'El ítem fue removido de la plantilla.', + title: 'Permiso Retirado', + message: 'El ítem quedó retirado de la plantilla y ya no concede nada.', type: 'warning' as const, }), errorNotif: () => ({ - title: 'Error al Eliminar Permiso', - message: 'No se pudo remover el ítem de permiso.', + title: 'Error al Retirar Permiso', + message: 'No se pudo retirar el ítem de permiso.', }), }); @@ -191,3 +203,75 @@ export const useDeactivateTemplateItem = (templateId: string) => }), errorNotif: () => ({ title: 'Error al Desactivar', message: 'No se pudo desactivar el ítem.' }), }); + +// ─── Aplicación de efecto sobre una casilla (ADR-0164) ──────────────────────── + +/** Clave de la concesión: a qué objeto y con qué acción se refiere la casilla que se edita. */ +export interface TemplateItemTarget { + targetType: ExclusiveArcTarget; + targetId: string; + actionId: string; +} + +export interface ApplyTemplateItemEffectArgs { + /** Efecto que pide el usuario en la pantalla. */ + effect: PermissionEffect; + /** Fila existente para esa clave, vigente o retirada; `undefined` si nunca se creó. */ + item?: PermissionTemplateItem | null; + /** Datos del alta, usados solo cuando hay que crear la fila. */ + target: TemplateItemTarget; +} + +/** + * Traduce «quiero Allow/Deny/Neutral en esta casilla» a los verbos del backend, en un único sitio. + * + * Existe porque desde ADR-0164 la aritmética dejó de ser obvia: + * - `Neutral` NO es borrar. El ítem sobrevive; se le fija el efecto Neutral (PUT .../effect). + * - Sobre una fila retirada no se puede volver a hacer alta: la clave (objetivo, acción) sigue + * ocupada y el backend responde 409. Se reactiva (POST .../activate) y, si el efecto guardado + * no coincide con el pedido, se corrige. + * - Si el efecto pedido ya es el vigente no se escribe nada, para no emitir un toast que mienta. + * + * Devuelve `true` si hubo escritura, para que el llamador sepa si tiene algo que anunciar. + */ +export const useApplyTemplateItemEffect = (templateId: string) => { + const addItem = useAddTemplateItem(templateId); + const setEffect = useSetTemplateItemEffect(templateId); + const activateItem = useActivateTemplateItem(templateId); + + const applyEffect = useCallback( + async ({ effect, item, target }: ApplyTemplateItemEffectArgs): Promise => { + if (!item) { + // No hay fila: solo se crea si el usuario pide un efecto real. «Neutral» es la ausencia + // de opinión, y para eso basta con no tener fila. + if (effect === 'Neutral') return false; + await addItem.mutateAsync({ + ...target, + isAllowed: effect === 'Allow', + isDenied: effect === 'Deny', + }); + return true; + } + + if (isRetiredItem(item)) { + // Una fila retirada ya no concede nada: pedirle Neutral no cambiaría el estado efectivo. + if (effect === 'Neutral') return false; + await activateItem.mutateAsync(item.itemId); + if (itemStoredEffect(item) !== effect) { + await setEffect.mutateAsync({ itemId: item.itemId, effect }); + } + return true; + } + + if (itemStoredEffect(item) === effect) return false; + await setEffect.mutateAsync({ itemId: item.itemId, effect }); + return true; + }, + [addItem, setEffect, activateItem] + ); + + return { + applyEffect, + isPending: addItem.isPending || setEffect.isPending || activateItem.isPending, + }; +}; diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-settings.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-settings.ts new file mode 100644 index 00000000..c344edfa --- /dev/null +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-settings.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '@app/stores/auth.store'; + +/** + * Ajustes del sistema que el grafo publica para inicializar la aplicación (G-178). + * + * El servidor los entrega agrupados por espacio de nombres —`settings.brand.logo_url`, + * `settings.theme.primary`, `settings.ui.home_route`…— y solo incluye los marcados como visibles. + * + * Este hook NO valida ni completa lo que falte con datos inventados: devuelve `undefined` y deja + * que cada consumidor decida su respaldo. Un sistema sin branding configurado debe verse como el + * producto por defecto, no como un producto a medio pintar. + */ +export interface SystemSettings { + brand: { + displayName?: string; + shortName?: string; + /** Descriptor bajo el nombre comercial; sustituye al subtítulo del producto. */ + tagline?: string; + logoUrl?: string; + iconUrl?: string; + }; + theme: { + primary?: string; + accent?: string; + mode?: 'light' | 'dark' | 'system'; + }; + ui: { + layout?: string; + homeRoute?: string; + density?: string; + }; + locale: { + language?: string; + timezone?: string; + currency?: string; + }; + /** Acceso crudo para espacios de nombres que este hook aún no tipa. */ + raw: Record>; +} + +const VACIO: Record> = {}; + +export function useSystemSettings(): SystemSettings { + const settings = useAuthStore( + state => + (state.user?.authorizationGraph?.settings as Record>) ?? VACIO + ); + + return useMemo(() => { + const grupo = (nombre: string) => settings[nombre] ?? {}; + const brand = grupo('brand'); + const theme = grupo('theme'); + const ui = grupo('ui'); + const locale = grupo('locale'); + + const modo = theme.mode; + + return { + brand: { + displayName: brand.display_name, + shortName: brand.short_name, + tagline: brand.tagline, + logoUrl: brand.logo_url, + iconUrl: brand.icon_url, + }, + theme: { + primary: theme.primary, + accent: theme.accent, + // Cualquier otro valor se ignora: un modo desconocido no debe dejar la interfaz a medias. + mode: modo === 'light' || modo === 'dark' || modo === 'system' ? modo : undefined, + }, + ui: { + layout: ui.layout, + homeRoute: ui.home_route, + density: ui.density, + }, + locale: { + language: locale.language, + timezone: locale.timezone, + currency: locale.currency, + }, + raw: settings, + }; + }, [settings]); +} diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.test.tsx b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.test.tsx index a23ef92f..4c677199 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.test.tsx +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.test.tsx @@ -11,12 +11,13 @@ import { useRemoveModule, useActivateModule, useDeactivateModule, - useAddMenu, - useRemoveMenu, - useAddSubMenu, - useRemoveSubMenu, - useAddOption, - useRemoveOption, + useAddNode, + useUpdateNode, + useRemoveNode, + useSetNodeStatus, + useLinkNodeAction, + useUnlinkNodeAction, + useSetNodeMetadata, useRegisterAction, useRemoveAction, useAddDomainResource, @@ -34,12 +35,13 @@ vi.mock('@infra/authorization/services/system-suite.service', () => ({ removeModule: vi.fn(), activateModule: vi.fn(), deactivateModule: vi.fn(), - addMenu: vi.fn(), - removeMenu: vi.fn(), - addSubMenu: vi.fn(), - removeSubMenu: vi.fn(), - addOption: vi.fn(), - removeOption: vi.fn(), + addNode: vi.fn(), + updateNode: vi.fn(), + removeNode: vi.fn(), + setNodeStatus: vi.fn(), + linkNodeAction: vi.fn(), + unlinkNodeAction: vi.fn(), + setNodeMetadata: vi.fn(), registerAction: vi.fn(), removeAction: vi.fn(), addDomainResource: vi.fn(), @@ -54,12 +56,13 @@ vi.mock('@infra/authorization/services/system-suite.service', () => ({ removeModule: vi.fn(), activateModule: vi.fn(), deactivateModule: vi.fn(), - addMenu: vi.fn(), - removeMenu: vi.fn(), - addSubMenu: vi.fn(), - removeSubMenu: vi.fn(), - addOption: vi.fn(), - removeOption: vi.fn(), + addNode: vi.fn(), + updateNode: vi.fn(), + removeNode: vi.fn(), + setNodeStatus: vi.fn(), + linkNodeAction: vi.fn(), + unlinkNodeAction: vi.fn(), + setNodeMetadata: vi.fn(), registerAction: vi.fn(), removeAction: vi.fn(), addDomainResource: vi.fn(), @@ -159,7 +162,12 @@ describe('use-system-suite hooks', () => { const { result } = renderHook(() => useCreateSystemSuite(), { wrapper }); await act(async () => { - result.current.mutate({ code: 'NEW', name: 'New Suite' }); + result.current.mutate({ + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + code: 'NEW', + name: 'New Suite', + description: 'A new suite', + }); }); await waitFor(() => { @@ -167,8 +175,10 @@ describe('use-system-suite hooks', () => { }); expect(systemSuiteService.createSystemSuite).toHaveBeenCalledWith({ + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', code: 'NEW', name: 'New Suite', + description: 'A new suite', }); }); @@ -257,124 +267,142 @@ describe('use-system-suite hooks', () => { expect(systemSuiteService.deactivateModule).toHaveBeenCalledWith('s1', 'mod1'); }); - it('useAddMenu calls service successfully', async () => { - vi.mocked(systemSuiteService.addMenu).mockResolvedValue(); + it('useAddNode calls service successfully', async () => { + vi.mocked(systemSuiteService.addNode).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useAddMenu('s1', 'mod1'), { wrapper }); + const { result } = renderHook(() => useAddNode('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate({ code: 'MENU1', label: 'Menu 1', sortOrder: 1 }); + result.current.mutate({ + parentNodeId: 'root1', + kind: 'Option', + code: 'NODE1', + label: 'Node 1', + sortOrder: 1, + }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.addMenu).toHaveBeenCalledWith('s1', 'mod1', expect.any(Object)); + expect(systemSuiteService.addNode).toHaveBeenCalledWith( + 's1', + 'mod1', + expect.objectContaining({ parentNodeId: 'root1', kind: 'Option', code: 'NODE1' }) + ); }); - it('useRemoveMenu calls service successfully', async () => { - vi.mocked(systemSuiteService.removeMenu).mockResolvedValue(); + it('useUpdateNode calls service with nodeId', async () => { + vi.mocked(systemSuiteService.updateNode).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useRemoveMenu('s1', 'mod1'), { wrapper }); + const { result } = renderHook(() => useUpdateNode('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate('menu1'); + result.current.mutate({ nodeId: 'node1', label: 'Nuevo', sortOrder: 2 }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.removeMenu).toHaveBeenCalledWith('s1', 'mod1', 'menu1'); + expect(systemSuiteService.updateNode).toHaveBeenCalledWith( + 's1', + 'mod1', + 'node1', + expect.objectContaining({ label: 'Nuevo', sortOrder: 2 }) + ); }); - it('useAddSubMenu calls service successfully', async () => { - vi.mocked(systemSuiteService.addSubMenu).mockResolvedValue(); + it('useRemoveNode calls service successfully', async () => { + vi.mocked(systemSuiteService.removeNode).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useAddSubMenu('s1', 'mod1', 'menu1'), { wrapper }); + const { result } = renderHook(() => useRemoveNode('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate({ code: 'SUB1', label: 'Sub 1', sortOrder: 1 }); + result.current.mutate('node1'); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.addSubMenu).toHaveBeenCalledWith( - 's1', - 'mod1', - 'menu1', - expect.any(Object) - ); + expect(systemSuiteService.removeNode).toHaveBeenCalledWith('s1', 'mod1', 'node1'); }); - it('useRemoveSubMenu calls service successfully', async () => { - vi.mocked(systemSuiteService.removeSubMenu).mockResolvedValue(); + it('useSetNodeStatus calls service with active flag', async () => { + vi.mocked(systemSuiteService.setNodeStatus).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useRemoveSubMenu('s1', 'mod1', 'menu1'), { wrapper }); + const { result } = renderHook(() => useSetNodeStatus('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate('sub1'); + result.current.mutate({ nodeId: 'node1', active: false }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.removeSubMenu).toHaveBeenCalledWith('s1', 'mod1', 'menu1', 'sub1'); + expect(systemSuiteService.setNodeStatus).toHaveBeenCalledWith('s1', 'mod1', 'node1', false); }); - it('useAddOption calls service successfully', async () => { - vi.mocked(systemSuiteService.addOption).mockResolvedValue(); + it('useLinkNodeAction calls service (N:M)', async () => { + vi.mocked(systemSuiteService.linkNodeAction).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useAddOption('s1', 'mod1', 'menu1', 'sub1'), { wrapper }); + const { result } = renderHook(() => useLinkNodeAction('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate({ code: 'OPT1', label: 'Opt 1', actionCode: 'ACT1', sortOrder: 1 }); + result.current.mutate({ nodeId: 'node1', actionCode: 'VIEW' }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.addOption).toHaveBeenCalledWith( - 's1', - 'mod1', - 'menu1', - 'sub1', - expect.any(Object) - ); + expect(systemSuiteService.linkNodeAction).toHaveBeenCalledWith('s1', 'mod1', 'node1', 'VIEW'); }); - it('useRemoveOption calls service successfully', async () => { - vi.mocked(systemSuiteService.removeOption).mockResolvedValue(); + it('useUnlinkNodeAction calls service (N:M)', async () => { + vi.mocked(systemSuiteService.unlinkNodeAction).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useRemoveOption('s1', 'mod1', 'menu1', 'sub1'), { - wrapper, + const { result } = renderHook(() => useUnlinkNodeAction('s1', 'mod1'), { wrapper }); + + await act(async () => { + result.current.mutate({ nodeId: 'node1', actionCode: 'VIEW' }); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); }); + expect(systemSuiteService.unlinkNodeAction).toHaveBeenCalledWith('s1', 'mod1', 'node1', 'VIEW'); + }); + + it('useSetNodeMetadata calls service with SDLC metadata', async () => { + vi.mocked(systemSuiteService.setNodeMetadata).mockResolvedValue(); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useSetNodeMetadata('s1', 'mod1'), { wrapper }); + await act(async () => { - result.current.mutate('opt1'); + result.current.mutate({ nodeId: 'node1', responsable: 'QA', criticidad: 'Alta' }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.removeOption).toHaveBeenCalledWith( + expect(systemSuiteService.setNodeMetadata).toHaveBeenCalledWith( 's1', 'mod1', - 'menu1', - 'sub1', - 'opt1' + 'node1', + expect.objectContaining({ responsable: 'QA', criticidad: 'Alta' }) ); }); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts index edc13fa0..c87fa964 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts @@ -176,200 +176,149 @@ export const useDeactivateModule = (systemSuiteId: string) => { }); }; -// ─── Menu Mutations ─────────────────────────────────────────────────────────── +// ─── Node Mutations (árbol recursivo, ADR-0090) ─────────────────────────────── -export const useAddMenu = (systemSuiteId: string, moduleId: string) => { +export const useAddNode = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ mutationFn: (payload: { + parentNodeId?: string | null; + kind: string; code: string; label: string; description?: string; sortOrder: number; - }) => systemSuiteService.addMenu(systemSuiteId, moduleId, payload), + }) => systemSuiteService.addNode(systemSuiteId, moduleId, payload), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Menú Registrado', - message: 'El menú fue agregado correctamente.', + title: 'Nodo Registrado', + message: 'El nodo fue agregado al árbol correctamente.', }), errorNotif: () => ({ - title: 'Error al Registrar Menú', - message: 'No se pudo agregar el menú.', + title: 'Error al Registrar Nodo', + message: 'No se pudo agregar el nodo.', }), }); }; -export const useUpdateMenu = (systemSuiteId: string, moduleId: string, menuId: string) => { - return useNotifiedMutation({ - mutationFn: (payload: { label: string; description?: string; sortOrder: number }) => - systemSuiteService.updateMenu(systemSuiteId, moduleId, menuId, payload), - invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], - successNotif: () => ({ - title: 'Menú Actualizado', - message: 'El menú fue actualizado correctamente.', - }), - errorNotif: () => ({ - title: 'Error al Actualizar Menú', - message: 'No se pudo actualizar el menú.', - }), - }); -}; - -export const useRemoveMenu = (systemSuiteId: string, moduleId: string) => { - return useNotifiedMutation({ - mutationFn: (menuId: string) => systemSuiteService.removeMenu(systemSuiteId, moduleId, menuId), - invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], - successNotif: () => ({ - title: 'Menú Eliminado', - message: 'El menú fue eliminado.', - type: 'warning' as const, - }), - errorNotif: () => ({ - title: 'Error al Eliminar Menú', - message: 'No se pudo eliminar el menú.', - }), - }); -}; - -// ─── SubMenu Mutations ──────────────────────────────────────────────────────── - -export const useAddSubMenu = (systemSuiteId: string, moduleId: string, menuId: string) => { +export const useUpdateNode = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ mutationFn: (payload: { - code: string; + nodeId: string; label: string; description?: string; sortOrder: number; - }) => systemSuiteService.addSubMenu(systemSuiteId, moduleId, menuId, payload), + }) => + systemSuiteService.updateNode(systemSuiteId, moduleId, payload.nodeId, { + label: payload.label, + description: payload.description, + sortOrder: payload.sortOrder, + }), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Submenú Registrado', - message: 'El submenú fue agregado correctamente.', + title: 'Nodo Actualizado', + message: 'El nodo fue actualizado correctamente.', }), errorNotif: () => ({ - title: 'Error al Registrar Submenú', - message: 'No se pudo agregar el submenú.', + title: 'Error al Actualizar Nodo', + message: 'No se pudo actualizar el nodo.', }), }); }; -export const useUpdateSubMenu = ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string -) => { +export const useRemoveNode = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (payload: { label: string; description?: string; sortOrder: number }) => - systemSuiteService.updateSubMenu(systemSuiteId, moduleId, menuId, subMenuId, payload), + mutationFn: (nodeId: string) => systemSuiteService.removeNode(systemSuiteId, moduleId, nodeId), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Submenú Actualizado', - message: 'El submenú fue actualizado correctamente.', + title: 'Nodo Eliminado', + message: 'El nodo (y su subárbol) fue eliminado.', + type: 'warning' as const, }), errorNotif: () => ({ - title: 'Error al Actualizar Submenú', - message: 'No se pudo actualizar el submenú.', + title: 'Error al Eliminar Nodo', + message: 'No se pudo eliminar el nodo.', }), }); }; -export const useRemoveSubMenu = (systemSuiteId: string, moduleId: string, menuId: string) => { +export const useSetNodeStatus = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (subMenuId: string) => - systemSuiteService.removeSubMenu(systemSuiteId, moduleId, menuId, subMenuId), + mutationFn: (payload: { nodeId: string; active: boolean }) => + systemSuiteService.setNodeStatus(systemSuiteId, moduleId, payload.nodeId, payload.active), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Submenú Eliminado', - message: 'El submenú fue eliminado.', - type: 'warning' as const, + title: 'Estado del Nodo Actualizado', + message: 'El estado del nodo fue actualizado.', + type: 'info' as const, }), errorNotif: () => ({ - title: 'Error al Eliminar Submenú', - message: 'No se pudo eliminar el submenú.', + title: 'Error de Cambio de Estado', + message: 'No se pudo actualizar el estado del nodo.', }), }); }; -// ─── Option Mutations ───────────────────────────────────────────────────────── - -export const useAddOption = ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string -) => { +export const useLinkNodeAction = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (payload: { - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder: number; - }) => systemSuiteService.addOption(systemSuiteId, moduleId, menuId, subMenuId, payload), + mutationFn: (payload: { nodeId: string; actionCode: string }) => + systemSuiteService.linkNodeAction(systemSuiteId, moduleId, payload.nodeId, payload.actionCode), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Opción Registrada', - message: 'La opción fue agregada correctamente.', + title: 'Funcionalidad Vinculada', + message: 'La funcionalidad fue vinculada al nodo.', }), errorNotif: () => ({ - title: 'Error al Registrar Opción', - message: 'No se pudo agregar la opción.', + title: 'Error al Vincular Funcionalidad', + message: 'No se pudo vincular la funcionalidad.', }), }); }; -export const useUpdateOption = ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string, - optionId: string -) => { +export const useUnlinkNodeAction = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (payload: { - label: string; - description?: string; - actionCode: string; - sortOrder: number; - }) => - systemSuiteService.updateOption( + mutationFn: (payload: { nodeId: string; actionCode: string }) => + systemSuiteService.unlinkNodeAction( systemSuiteId, moduleId, - menuId, - subMenuId, - optionId, - payload + payload.nodeId, + payload.actionCode ), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Opción Actualizada', - message: 'La opción fue actualizada correctamente.', + title: 'Funcionalidad Desvinculada', + message: 'La funcionalidad fue desvinculada del nodo.', + type: 'warning' as const, }), errorNotif: () => ({ - title: 'Error al Actualizar Opción', - message: 'No se pudo actualizar la opción.', + title: 'Error al Desvincular Funcionalidad', + message: 'No se pudo desvincular la funcionalidad.', }), }); }; -export const useRemoveOption = ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string -) => { +export const useSetNodeMetadata = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (optionId: string) => - systemSuiteService.removeOption(systemSuiteId, moduleId, menuId, subMenuId, optionId), + mutationFn: (payload: { + nodeId: string; + responsable?: string | null; + criticidad?: string | null; + productoImpactado?: string | null; + componenteTecnico?: string | null; + dependencias?: string | null; + evidencias?: string | null; + trazabilidadSdlc?: string | null; + }) => { + const { nodeId, ...metadata } = payload; + return systemSuiteService.setNodeMetadata(systemSuiteId, moduleId, nodeId, metadata); + }, invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Opción Eliminada', - message: 'La opción fue eliminada.', - type: 'warning' as const, + title: 'Metadatos Actualizados', + message: 'Los metadatos de gobernanza SDLC fueron guardados.', }), errorNotif: () => ({ - title: 'Error al Eliminar Opción', - message: 'No se pudo eliminar la opción.', + title: 'Error al Guardar Metadatos', + message: 'No se pudieron guardar los metadatos.', }), }); }; diff --git a/src/apps/ums.web-app/src/application/authorization/utils/permission-cascade.ts b/src/apps/ums.web-app/src/application/authorization/utils/permission-cascade.ts index 7da918ab..b5700ebc 100644 --- a/src/apps/ums.web-app/src/application/authorization/utils/permission-cascade.ts +++ b/src/apps/ums.web-app/src/application/authorization/utils/permission-cascade.ts @@ -1,80 +1,68 @@ -import { SystemSuite } from '../../domain/system-suite'; +import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; /** - * Returns an array of parent IDs (Module, Menu, SubMenu) from root up to (but not including) the targetId. - * If the targetId is not found, it returns an empty array. + * Utilidades de cascada de permisos sobre el árbol de nodos recursivo del + * SystemSuite (ADR-0090). Reemplazan el recorrido rígido Menú→Submenú→Opción + * por un recorrido recursivo de `module.nodes`. */ -export function getAscendantIds(suite: SystemSuite, targetId: string): string[] { - const result: string[] = []; - - for (const module of suite.modules) { - if (module.id === targetId) return result; - for (const menu of module.menus) { - if (menu.id === targetId) { - result.push(module.id); - return result; - } +type AscendantType = 'Module' | 'Submodule' | 'Page' | 'Option'; - for (const subMenu of menu.subMenus) { - if (subMenu.id === targetId) { - result.push(module.id, menu.id); - return result; - } +// Mapea el rol del nodo (NodeKind) al tipo de destino usado por la cascada. +// Preserva la semántica previa: Menú→Submodule, Submenú→Page, Opción→Option. +function nodeKindToAscendantType(kind: string): AscendantType { + if (kind === 'Menu') return 'Submodule'; + if (kind === 'SubMenu') return 'Page'; + return 'Option'; +} - for (const option of subMenu.options) { - if (option.id === targetId) { - result.push(module.id, menu.id, subMenu.id); - return result; - } - } - } - } +// Busca el camino (nodos ancestros, de raíz a target sin incluirlo) dentro de +// un subárbol. Devuelve null si el target no está en el subárbol. +function findNodePath(nodes: SystemSuiteNode[], targetId: string): SystemSuiteNode[] | null { + for (const node of nodes) { + if (node.id === targetId) return []; + const childPath = findNodePath(node.children ?? [], targetId); + if (childPath) return [node, ...childPath]; } + return null; +} +/** + * Devuelve los IDs de los ancestros (Módulo y nodos) desde la raíz hasta (sin + * incluir) el targetId. Vacío si no se encuentra. + */ +export function getAscendantIds(suite: SystemSuite, targetId: string): string[] { + for (const module of suite.modules) { + if (module.id === targetId) return []; + const path = findNodePath(module.nodes ?? [], targetId); + if (path) return [module.id, ...path.map(n => n.id)]; + } return []; } +/** + * Igual que {@link getAscendantIds} pero devuelve el tipo de cada ancestro. + */ export function getAscendantsWithTypes( suite: SystemSuite, targetId: string -): { id: string; type: 'Module' | 'Submodule' | 'Page' | 'Option' }[] { - const result: { id: string; type: 'Module' | 'Submodule' | 'Page' | 'Option' }[] = []; - +): { id: string; type: AscendantType }[] { for (const module of suite.modules) { - if (module.id === targetId) return result; - - for (const menu of module.menus) { - if (menu.id === targetId) { - result.push({ id: module.id, type: 'Module' }); - return result; - } - - for (const subMenu of menu.subMenus) { - if (subMenu.id === targetId) { - result.push({ id: module.id, type: 'Module' }, { id: menu.id, type: 'Submodule' }); - return result; - } - - for (const option of subMenu.options) { - if (option.id === targetId) { - result.push( - { id: module.id, type: 'Module' }, - { id: menu.id, type: 'Submodule' }, - { id: subMenu.id, type: 'Page' } - ); - return result; - } - } - } + if (module.id === targetId) return []; + const path = findNodePath(module.nodes ?? [], targetId); + if (path) { + return [ + { id: module.id, type: 'Module' as const }, + ...path.map(n => ({ id: n.id, type: nodeKindToAscendantType(n.kind) })), + ]; } } - return []; } /** - * Returns true if the action code implies a read/view operation. + * Devuelve true si el código de acción implica una operación de lectura/consulta. */ export function isReadAction(actionCode: string): boolean { const upper = actionCode.toUpperCase(); @@ -87,35 +75,46 @@ export function isReadAction(actionCode: string): boolean { ); } +// ¿Alguna de las funcionalidades vinculadas al nodo es de lectura? +function nodeIsReadOnly(node: SystemSuiteNode): boolean { + return node.actionCodes.length > 0 && node.actionCodes.every(isReadAction); +} + +function nodeHasReadAction(node: SystemSuiteNode): boolean { + return node.actionCodes.some(isReadAction); +} + +// Encuentra el nodo objetivo y su lista de hermanos (children de su padre). +function findNodeWithSiblings( + nodes: SystemSuiteNode[], + targetId: string, + siblings: SystemSuiteNode[] +): { node: SystemSuiteNode; siblings: SystemSuiteNode[] } | null { + for (const node of nodes) { + if (node.id === targetId) return { node, siblings }; + const found = findNodeWithSiblings(node.children ?? [], targetId, node.children ?? []); + if (found) return found; + } + return null; +} + /** - * Given an option ID, if it's a "Write/Manage" option, returns the IDs and types of any "Read/View" options - * in the same SubMenu, to enforce CRUD logic where write access implies read access. + * Dado un nodo hoja (Opción) de "escritura/gestión", devuelve los IDs de los + * nodos hermanos de "lectura/consulta" en el mismo padre, para propagar que el + * acceso de escritura implica el de lectura. */ export function getSiblingViewOptions( suite: SystemSuite, optionId: string ): { id: string; type: 'Option' }[] { - const result: { id: string; type: 'Option' }[] = []; - for (const module of suite.modules) { - for (const menu of module.menus) { - for (const subMenu of menu.subMenus) { - const targetOption = subMenu.options.find(o => o.id === optionId); - if (targetOption) { - // If the target option itself is already a read action, there's no implied read to fetch - if (isReadAction(targetOption.actionCode)) { - return []; - } - - // Find all sibling read options - for (const sibling of subMenu.options) { - if (sibling.id !== optionId && isReadAction(sibling.actionCode)) { - result.push({ id: sibling.id, type: 'Option' }); - } - } - return result; - } - } + const found = findNodeWithSiblings(module.nodes ?? [], optionId, module.nodes ?? []); + if (found) { + // Si el propio nodo ya es de lectura, no hay lectura implícita que resolver. + if (nodeIsReadOnly(found.node)) return []; + return found.siblings + .filter(s => s.id !== optionId && s.kind === 'Option' && nodeHasReadAction(s)) + .map(s => ({ id: s.id, type: 'Option' as const })); } } return []; diff --git a/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.ts b/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.ts index e4585361..c8ae76b5 100644 --- a/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.ts +++ b/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.ts @@ -18,6 +18,8 @@ export function useFeatureFlagDashboard() { criteria: 'flagCode', filter: 'all', sortBy: 'flagCode', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/configuration/hooks/use-parameter-catalog-dashboard.ts b/src/apps/ums.web-app/src/application/configuration/hooks/use-parameter-catalog-dashboard.ts index 0805d74e..c779ccf1 100644 --- a/src/apps/ums.web-app/src/application/configuration/hooks/use-parameter-catalog-dashboard.ts +++ b/src/apps/ums.web-app/src/application/configuration/hooks/use-parameter-catalog-dashboard.ts @@ -29,6 +29,8 @@ export function useParameterCatalogDashboard() { criteria: 'code', filter: 'all', sortBy: 'code', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/configuration/parameter-catalog/use-parameter-catalog.ts b/src/apps/ums.web-app/src/application/configuration/parameter-catalog/use-parameter-catalog.ts index 4a370ce0..9182daf0 100644 --- a/src/apps/ums.web-app/src/application/configuration/parameter-catalog/use-parameter-catalog.ts +++ b/src/apps/ums.web-app/src/application/configuration/parameter-catalog/use-parameter-catalog.ts @@ -1,4 +1,5 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { parameterCatalogService } from '@infrastructure/configuration/services/parameter-catalog/parameter-catalog.service'; import type { ParameterDefinitionFilter, @@ -22,35 +23,53 @@ export function useParameterDefinitionById(id: string) { }); } +// Los 3 mutadores usan `useNotifiedMutation` (G-146): es dueño único del toast success/error y +// de la invalidación → un rechazo del backend (p. ej. borrar un parámetro con valores dependientes +// → 409 `ParameterHasActiveValues`) ahora SÍ da feedback visual, en la línea de las demás cards. export function useCreateParameterDefinition() { - const queryClient = useQueryClient(); - return useMutation({ + return useNotifiedMutation({ mutationFn: (payload: CreateParameterDefinitionPayload) => parameterCatalogService.createParameterDefinition(payload), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['parameter-definitions'] }); - }, + invalidateKeys: [['parameter-definitions']], + successNotif: () => ({ + title: 'Parámetro Creado', + message: 'La definición de parámetro fue creada exitosamente.', + }), + errorNotif: () => ({ + title: 'Error al Crear Parámetro', + message: 'No se pudo crear la definición de parámetro.', + }), }); } export function useUpdateParameterDefinition() { - const queryClient = useQueryClient(); - return useMutation({ + return useNotifiedMutation({ mutationFn: ({ id, payload }: { id: string; payload: UpdateParameterDefinitionPayload }) => parameterCatalogService.updateParameterDefinition(id, payload), - onSuccess: data => { - queryClient.invalidateQueries({ queryKey: ['parameter-definitions'] }); - queryClient.setQueryData(['parameter-definition', data.id], data); - }, + // Refresca lista + detalle (prefijo `['parameter-definition']` matchea `['parameter-definition', id]`). + invalidateKeys: [['parameter-definitions'], ['parameter-definition']], + successNotif: () => ({ + title: 'Parámetro Actualizado', + message: 'La definición de parámetro fue actualizada.', + }), + errorNotif: () => ({ + title: 'Error al Actualizar Parámetro', + message: 'No se pudo actualizar la definición de parámetro.', + }), }); } export function useDeleteParameterDefinition() { - const queryClient = useQueryClient(); - return useMutation({ + return useNotifiedMutation({ mutationFn: (id: string) => parameterCatalogService.deleteParameterDefinition(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['parameter-definitions'] }); - }, + invalidateKeys: [['parameter-definitions']], + successNotif: () => ({ + title: 'Parámetro Eliminado', + message: 'La definición de parámetro fue eliminada.', + }), + errorNotif: () => ({ + title: 'Error al Eliminar Parámetro', + message: 'No se pudo eliminar el parámetro (puede tener valores asociados).', + }), }); } diff --git a/src/apps/ums.web-app/src/application/errors/http-error.test.ts b/src/apps/ums.web-app/src/application/errors/http-error.test.ts index 6ea7780b..11f2c374 100644 --- a/src/apps/ums.web-app/src/application/errors/http-error.test.ts +++ b/src/apps/ums.web-app/src/application/errors/http-error.test.ts @@ -4,6 +4,7 @@ import { getHttpStatus, getSupportReferenceId, getHttpErrorMessage, + getBlockedOperation, } from './http-error'; describe('asHttpError', () => { @@ -39,17 +40,6 @@ describe('asHttpError', () => { expect(result.response?.data?.traceId).toBe('trace-456'); }); - it('extracts graphQLErrors', () => { - const error = { - graphQLErrors: [ - { extensions: { errorId: 'gql-err-1' } }, - { extensions: { code: 'INTERNAL_ERROR' } }, - ], - }; - const result = asHttpError(error); - expect(result.graphQLErrors).toHaveLength(2); - }); - it('extracts supportReferenceId from root', () => { const error = { supportReferenceId: 'ref-789' }; const result = asHttpError(error); @@ -100,16 +90,6 @@ describe('getSupportReferenceId', () => { expect(getSupportReferenceId(error)).toBe('trace-456'); }); - it('returns errorId from graphQLErrors', () => { - const error = { graphQLErrors: [{ extensions: { errorId: 'gql-err' } }] }; - expect(getSupportReferenceId(error)).toBe('gql-err'); - }); - - it('returns traceId from graphQLErrors', () => { - const error = { graphQLErrors: [{ extensions: { traceId: 'gql-trace' } }] }; - expect(getSupportReferenceId(error)).toBe('gql-trace'); - }); - it('returns undefined for empty error', () => { expect(getSupportReferenceId({})).toBeUndefined(); }); @@ -140,3 +120,64 @@ describe('getHttpErrorMessage', () => { expect(getHttpErrorMessage(error, 'Fallback')).toBe('Profile not found.'); }); }); + +// ─── 409 con desglose: qué bloquea exactamente la operación (ADR-0164) ─────── + +describe('getBlockedOperation', () => { + const blocked409 = { + response: { + status: 409, + data: { + errorCode: 'tenant.branch_has_live_references', + message: 'No se puede cerrar la sucursal porque todavía tiene usuarios o perfiles activos.', + brokenRule: 'A branch cannot be closed while active references exist.', + blockingDependencies: [ + { entityType: 'UserAccount', status: 'Active', count: 3 }, + { entityType: 'Profile', status: 'Active', count: 1 }, + ], + }, + }, + }; + + it('devuelve las dos clases que bloquean, no solo la primera', () => { + const blocked = getBlockedOperation(blocked409); + + expect(blocked?.dependencies).toHaveLength(2); + expect(blocked?.dependencies[0]).toEqual({ + entityType: 'UserAccount', + status: 'Active', + count: 3, + }); + expect(blocked?.errorCode).toBe('tenant.branch_has_live_references'); + }); + + it('ignora un 409 sin desglose', () => { + expect( + getBlockedOperation({ response: { status: 409, data: { detail: 'Conflict' } } }) + ).toBeNull(); + }); + + it('ignora los errores que no son 409', () => { + expect( + getBlockedOperation({ response: { status: 500, data: { blockingDependencies: [] } } }) + ).toBeNull(); + }); + + it('ignora lo que no es un error HTTP', () => { + expect(getBlockedOperation('boom')).toBeNull(); + }); + + it('descarta entradas del desglose que no tienen forma de dependencia', () => { + const blocked = getBlockedOperation({ + response: { + status: 409, + data: { + errorCode: 'x', + blockingDependencies: [{ entityType: 'Profile', count: 2 }, 'basura', { count: 9 }], + }, + }, + }); + + expect(blocked?.dependencies).toEqual([{ entityType: 'Profile', status: '', count: 2 }]); + }); +}); diff --git a/src/apps/ums.web-app/src/application/errors/http-error.ts b/src/apps/ums.web-app/src/application/errors/http-error.ts index cf02c8bf..62db0f98 100644 --- a/src/apps/ums.web-app/src/application/errors/http-error.ts +++ b/src/apps/ums.web-app/src/application/errors/http-error.ts @@ -16,9 +16,6 @@ interface HttpErrorLike { supportReferenceId?: string; }; }; - graphQLErrors?: ReadonlyArray<{ - extensions?: Record; - }>; } const isRecord = (value: unknown): value is Record => @@ -28,7 +25,6 @@ export const asHttpError = (error: unknown): HttpErrorLike => { if (!isRecord(error)) return {}; const response = isRecord(error.response) ? error.response : undefined; const data = response && isRecord(response.data) ? response.data : undefined; - const graphQLErrors = Array.isArray(error.graphQLErrors) ? error.graphQLErrors : undefined; return { supportReferenceId: typeof error.supportReferenceId === 'string' ? error.supportReferenceId : undefined, @@ -50,7 +46,6 @@ export const asHttpError = (error: unknown): HttpErrorLike => { : undefined, } : undefined, - graphQLErrors, }; }; @@ -59,12 +54,6 @@ export const getHttpStatus = (error: unknown): number | undefined => export const getSupportReferenceId = (error: unknown): string | undefined => { const httpError = asHttpError(error); - const graphqlErrorId = httpError.graphQLErrors - ?.map(item => item.extensions?.errorId) - .find((value): value is string => typeof value === 'string'); - const graphqlTraceId = httpError.graphQLErrors - ?.map(item => item.extensions?.traceId) - .find((value): value is string => typeof value === 'string'); const headerErrorId = httpError.response?.headers?.['x-error-id'] ?? httpError.response?.headers?.['X-Error-Id']; const headerTraceId = @@ -75,10 +64,8 @@ export const getSupportReferenceId = (error: unknown): string | undefined => { httpError.supportReferenceId ?? httpError.response?.data?.supportReferenceId ?? httpError.response?.data?.errorId ?? - graphqlErrorId ?? (typeof headerErrorId === 'string' ? headerErrorId : undefined) ?? httpError.response?.data?.traceId ?? - graphqlTraceId ?? (typeof headerTraceId === 'string' ? headerTraceId : undefined) ); }; @@ -98,3 +85,57 @@ export const getHttpErrorMessage = (error: unknown, fallback: string): string => const data = asHttpError(error).response?.data; return data?.userMessage ?? data?.detail ?? data?.error ?? fallback; }; + +// ─── Operaciones bloqueadas por dependencias vivas (409) ───────────────────── + +/** + * Una clase de dependencia que impide la operación: qué tipo de entidad, en qué estado y cuántas. + * El backend devuelve TODAS las clases que bloquean, no solo la primera. + */ +export interface BlockingDependency { + entityType: string; + status: string; + count: number; +} + +export interface BlockedOperation { + errorCode: string; + message: string; + dependencies: BlockingDependency[]; +} + +/** + * Extrae el desglose de un 409 «operación bloqueada». + * + * Existe porque un «no se pudo» a secas obliga a adivinar: la respuesta trae cuántas cuentas y + * cuántos perfiles siguen vivos, y esa es justo la información que necesita quien va a resolverlo. + * Devuelve `null` si el error no trae ese cuerpo (otro 409, un 500, un fallo de red). + */ +export const getBlockedOperation = (error: unknown): BlockedOperation | null => { + if (!isRecord(error)) return null; + const response = isRecord(error.response) ? error.response : undefined; + if (response?.status !== 409) return null; + + const data = isRecord(response.data) ? response.data : undefined; + if (!data || !Array.isArray(data.blockingDependencies)) return null; + + const dependencies = data.blockingDependencies + .filter(isRecord) + .filter( + (dep): dep is Record & { entityType: string; count: number } => + typeof dep.entityType === 'string' && typeof dep.count === 'number' + ) + .map(dep => ({ + entityType: dep.entityType, + status: typeof dep.status === 'string' ? dep.status : '', + count: dep.count, + })); + + if (dependencies.length === 0) return null; + + return { + errorCode: typeof data.errorCode === 'string' ? data.errorCode : '', + message: typeof data.message === 'string' ? data.message : '', + dependencies, + }; +}; diff --git a/src/apps/ums.web-app/src/application/formatting/index.ts b/src/apps/ums.web-app/src/application/formatting/index.ts index 49f82ba8..d47ff674 100644 --- a/src/apps/ums.web-app/src/application/formatting/index.ts +++ b/src/apps/ums.web-app/src/application/formatting/index.ts @@ -1,2 +1,7 @@ export { formatDate, formatDateTime, formatRelativeTime } from './date'; export { formatNumber, formatCurrency, formatPercentage, formatCompact } from './number'; + +// Los hooks inyectan idioma, zona y moneda de la sesión. Son la vía preferente: los ayudantes de +// arriba traen valores por defecto genéricos ('en', 'USD') que casi nunca son los correctos aquí. +export { useDateFormat } from './use-date-format'; +export { useNumberFormat } from './use-number-format'; diff --git a/src/apps/ums.web-app/src/application/formatting/use-date-format.test.ts b/src/apps/ums.web-app/src/application/formatting/use-date-format.test.ts new file mode 100644 index 00000000..f20b76a2 --- /dev/null +++ b/src/apps/ums.web-app/src/application/formatting/use-date-format.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useDateFormat } from './use-date-format'; + +/** Ajustes que publica el sistema en el grafo (G-178). */ +let settings: Record> = {}; +/** Parámetros del inquilino que viajan en la sesión (ADR-0076). */ +let sessionParameters: { defaultTimezone?: string } | undefined; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { settings }, sessionParameters } }), +})); + +vi.mock('@app/stores/i18n.store', () => ({ + useI18nStore: (selector: (s: unknown) => unknown) => selector({ language: 'es' }), +})); + +describe('useDateFormat — precedencia sistema → inquilino', () => { + beforeEach(() => { + settings = {}; + sessionParameters = { defaultTimezone: 'America/Lima' }; + }); + + it('la zona del sistema manda sobre la del inquilino', () => { + settings = { locale: { timezone: 'Europe/Madrid' } }; + + const { result } = renderHook(() => useDateFormat()); + expect(result.current.timezone).toBe('Europe/Madrid'); + }); + + it('sin zona del sistema queda la del inquilino', () => { + const { result } = renderHook(() => useDateFormat()); + expect(result.current.timezone).toBe('America/Lima'); + }); + + it('sin ninguna de las dos, decide el navegador', () => { + // `undefined` deja que Intl use la zona local: mejor eso que inventar una. + sessionParameters = undefined; + + const { result } = renderHook(() => useDateFormat()); + expect(result.current.timezone).toBeUndefined(); + }); + + it('formatea con el idioma completo que publica el sistema', () => { + // `es-PE` da el orden día/mes; el idioma de la interfaz solo distingue es de en. + settings = { locale: { language: 'es-PE', timezone: 'America/Lima' } }; + + const { result } = renderHook(() => useDateFormat()); + expect(result.current.locale).toBe('es-PE'); + expect( + result.current.formatDate('2026-08-01T05:30:00Z', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + ).toBe('01/08/2026'); + }); + + it('la zona convierte la fecha, no solo la etiqueta', () => { + // 2026-08-01T02:00Z es aún 31 de julio en Lima (UTC-5): si la conversión no ocurre, + // el usuario ve un día que no corresponde. + settings = { locale: { language: 'es-PE', timezone: 'America/Lima' } }; + + const { result } = renderHook(() => useDateFormat()); + expect( + result.current.formatDate('2026-08-01T02:00:00Z', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + ).toBe('31/07/2026'); + }); + + it('sin ajustes del sistema formatea con el idioma de la interfaz', () => { + const { result } = renderHook(() => useDateFormat()); + expect(result.current.locale).toBe('es'); + }); +}); diff --git a/src/apps/ums.web-app/src/application/formatting/use-date-format.ts b/src/apps/ums.web-app/src/application/formatting/use-date-format.ts index b1ac49af..c3beb13f 100644 --- a/src/apps/ums.web-app/src/application/formatting/use-date-format.ts +++ b/src/apps/ums.web-app/src/application/formatting/use-date-format.ts @@ -1,6 +1,14 @@ /** * useDateFormat — React hook that wraps date formatting with the active - * locale (i18n store) and session timezone (auth store). + * locale and timezone. + * + * Precedencia: **sistema → inquilino → navegador**. El sistema publica en el grafo su idioma y su + * zona (`settings.locale.*`, G-178) y son los más específicos que hay: mandan sobre los parámetros + * del inquilino, que a su vez mandan sobre lo que deduzca el navegador. + * + * El idioma del formato no es el mismo dato que el de la interfaz: aquí interesa la etiqueta + * completa —`es-PE` da `31/12/2026`, `es` a secas no lo garantiza—, mientras que la interfaz solo + * distingue `es` de `en` porque son los dos paquetes de traducción que existen. * * ADR-0076: Use this hook in all components instead of calling formatDate/ * formatDateTime directly, so that locale and timezone are always consistent @@ -13,31 +21,38 @@ import { useCallback } from 'react'; import { useI18nStore } from '@app/stores/i18n.store'; import { useAuthStore } from '@app/stores/auth.store'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; import { formatDate as _formatDate, formatDateTime as _formatDateTime, formatRelativeTime as _formatRelativeTime, } from './date'; -import type { Intl as IntlType } from 'typescript'; + +/** Lo que llega de la API: ISO en UTC, o nada. */ +type FechaFormateable = Date | string | null | undefined; export function useDateFormat() { - const locale = useI18nStore(s => s.language); - const timezone = useAuthStore(s => s.user?.sessionParameters?.defaultTimezone); + const idiomaInterfaz = useI18nStore(s => s.language); + const zonaInquilino = useAuthStore(s => s.user?.sessionParameters?.defaultTimezone); + const { locale: localeSistema } = useSystemSettings(); + + const locale = localeSistema.language ?? idiomaInterfaz; + const timezone = localeSistema.timezone ?? zonaInquilino; const formatDate = useCallback( - (date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions) => + (date: FechaFormateable, options?: Intl.DateTimeFormatOptions) => _formatDate(date, locale, timezone, options), [locale, timezone] ); const formatDateTime = useCallback( - (date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions) => + (date: FechaFormateable, options?: Intl.DateTimeFormatOptions) => _formatDateTime(date, locale, timezone, options), [locale, timezone] ); const formatRelativeTime = useCallback( - (date: Date | string | null | undefined) => _formatRelativeTime(date, locale), + (date: FechaFormateable) => _formatRelativeTime(date, locale), [locale] ); diff --git a/src/apps/ums.web-app/src/application/formatting/use-number-format.test.ts b/src/apps/ums.web-app/src/application/formatting/use-number-format.test.ts new file mode 100644 index 00000000..d47a52a0 --- /dev/null +++ b/src/apps/ums.web-app/src/application/formatting/use-number-format.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useNumberFormat } from './use-number-format'; + +/** Ajustes que publica el sistema en el grafo (G-178). */ +let settings: Record> = {}; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { settings } } }), +})); + +vi.mock('@app/stores/i18n.store', () => ({ + useI18nStore: (selector: (s: unknown) => unknown) => selector({ language: 'es' }), +})); + +/** Intl separa el símbolo con un espacio duro; compararlo a ciegas rompe por un carácter invisible. */ +const normalizar = (s: string) => s.replace(/[\u00a0\u202f]/g, ' '); + +describe('useNumberFormat', () => { + beforeEach(() => { + settings = {}; + }); + + it('formatea con la moneda y el idioma que publica el sistema', () => { + settings = { locale: { language: 'es-PE', currency: 'PEN' } }; + + const { result } = renderHook(() => useNumberFormat()); + expect(normalizar(result.current.formatCurrency(1234.5))).toBe('S/ 1,234.50'); + }); + + it('la moneda explícita gana sobre la del sistema', () => { + // Un flete en dólares dentro de un sistema en soles: forzar la del sistema sería una mentira. + settings = { locale: { language: 'es-PE', currency: 'PEN' } }; + + const { result } = renderHook(() => useNumberFormat()); + expect(normalizar(result.current.formatCurrency(1234.5, 'USD'))).toBe('USD 1,234.50'); + }); + + it('sin moneda del sistema no inventa una', () => { + // El ayudante crudo cae en USD por defecto; el hook no lo tapa, solo no aporta contexto falso. + const { result } = renderHook(() => useNumberFormat()); + expect(result.current.currency).toBeUndefined(); + }); + + it('usa el idioma de la interfaz cuando el sistema no publica el suyo', () => { + const { result } = renderHook(() => useNumberFormat()); + expect(result.current.locale).toBe('es'); + }); + + it('formatea cifras y porcentajes con el mismo idioma', () => { + settings = { locale: { language: 'es-PE' } }; + + const { result } = renderHook(() => useNumberFormat()); + expect(result.current.formatNumber(1234567.89)).toBe('1,234,567.89'); + expect(result.current.formatPercentage(12.345, 2)).toBe('12.35%'); + }); + + it('devuelve un guion ante la ausencia de valor', () => { + const { result } = renderHook(() => useNumberFormat()); + expect(result.current.formatNumber(null)).toBe('-'); + expect(result.current.formatCurrency(undefined)).toBe('-'); + }); +}); diff --git a/src/apps/ums.web-app/src/application/formatting/use-number-format.ts b/src/apps/ums.web-app/src/application/formatting/use-number-format.ts new file mode 100644 index 00000000..84cf3b1d --- /dev/null +++ b/src/apps/ums.web-app/src/application/formatting/use-number-format.ts @@ -0,0 +1,58 @@ +/** + * useNumberFormat — cifras con el idioma y la moneda del sistema. + * + * Hermano de `useDateFormat` y con la misma precedencia: **sistema → inquilino → navegador**. El + * sistema publica `settings.locale.language` y `settings.locale.currency` (G-178); son lo más + * específico que hay y mandan sobre el idioma de la interfaz. + * + * Existe porque los ayudantes crudos de `./number` traen `'USD'` y `'en'` por defecto: un importe + * formateado sin contexto sale en dólares y con el separador equivocado, que en un operador + * logístico peruano no es un detalle estético sino una cifra mal leída. Igual que con las fechas + * (ADR-0076), la regla es usar el hook y no los ayudantes sueltos. + */ +import { useCallback } from 'react'; +import { useI18nStore } from '@app/stores/i18n.store'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; +import { + formatNumber as _formatNumber, + formatCurrency as _formatCurrency, + formatPercentage as _formatPercentage, + formatCompact as _formatCompact, +} from './number'; + +export function useNumberFormat() { + const idiomaInterfaz = useI18nStore(s => s.language); + const { locale: localeSistema } = useSystemSettings(); + + const locale = localeSistema.language ?? idiomaInterfaz; + const currency = localeSistema.currency; + + const formatNumber = useCallback( + (value: number | null | undefined, options?: Intl.NumberFormatOptions) => + _formatNumber(value, options, locale), + [locale] + ); + + /** + * La moneda explícita gana sobre la del sistema: un importe puede venir en otra —un flete en + * dólares dentro de un sistema en soles— y forzar la del sistema lo convertiría en una mentira. + */ + const formatCurrency = useCallback( + (value: number | null | undefined, monedaExplicita?: string) => + _formatCurrency(value, monedaExplicita ?? currency, locale), + [currency, locale] + ); + + const formatPercentage = useCallback( + (value: number | null | undefined, decimals?: number) => + _formatPercentage(value, decimals, locale), + [locale] + ); + + const formatCompact = useCallback( + (value: number | null | undefined) => _formatCompact(value, locale), + [locale] + ); + + return { formatNumber, formatCurrency, formatPercentage, formatCompact, locale, currency }; +} diff --git a/src/apps/ums.web-app/src/application/hooks/use-drag-resize.test.ts b/src/apps/ums.web-app/src/application/hooks/use-drag-resize.test.ts index 80dc6e56..ad221780 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-drag-resize.test.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-drag-resize.test.ts @@ -80,6 +80,34 @@ describe('useDragResize', () => { expect(result.current.isDragging).toBe(true); }); + it('handles touch start to start dragging', () => { + const { result } = renderHook(() => useDragResize({ initialSize: 200 })); + + const mockEvent = { + touches: [{ clientY: 100 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.handleTouchStart(mockEvent); + }); + + expect(result.current.isDragging).toBe(true); + }); + + it('ignores multi-touch gestures (solo un dedo redimensiona)', () => { + const { result } = renderHook(() => useDragResize({ initialSize: 200 })); + + const mockEvent = { + touches: [{ clientY: 100 }, { clientY: 200 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.handleTouchStart(mockEvent); + }); + + expect(result.current.isDragging).toBe(false); + }); + it('handles keyboard Enter to toggle collapse', () => { const { result } = renderHook(() => useDragResize({ initialSize: 300 })); diff --git a/src/apps/ums.web-app/src/application/hooks/use-drag-resize.ts b/src/apps/ums.web-app/src/application/hooks/use-drag-resize.ts index b87458d3..40e95e38 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-drag-resize.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-drag-resize.ts @@ -18,6 +18,7 @@ interface UseDragResizeResult { containerRef: React.RefObject; resizableRef: React.RefObject; handleMouseDown: (e: React.MouseEvent) => void; + handleTouchStart: (e: React.TouchEvent) => void; handleKeyDown: (e: React.KeyboardEvent) => void; toggleCollapse: () => void; } @@ -42,26 +43,35 @@ export function useDragResize({ }; }, []); + // Aplica el movimiento a partir de una coordenada Y (mouse o touch). + const applyMove = useCallback( + (clientY: number) => { + if (!isDraggingRef.current || !containerRef.current) return; + const titleBarH = (containerRef.current.firstElementChild as HTMLElement)?.offsetHeight ?? 60; + const rect = containerRef.current.getBoundingClientRect(); + const fromTop = clientY - rect.top - titleBarH; + const clamped = Math.min(rect.height * maxSizeRatio, Math.max(minSize, fromTop)); + setSize(clamped); + prevSizeRef.current = clamped; + if (clamped > 4) setIsCollapsed(false); + }, + [minSize, maxSizeRatio] + ); + + // Núcleo de arrastre común a puntero fino (mouse) y grueso (touch). + const beginDrag = useCallback(() => { + const measured = resizableRef.current?.offsetHeight ?? 200; + setSize(prev => prev ?? measured); + isDraggingRef.current = true; + setIsDragging(true); + }, [setSize]); + const handleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - const measured = resizableRef.current?.offsetHeight ?? 200; - const initial = size ?? measured; - setSize(initial); - isDraggingRef.current = true; - setIsDragging(true); - - const onMouseMove = (ev: MouseEvent) => { - if (!isDraggingRef.current || !containerRef.current) return; - const titleBarH = - (containerRef.current.firstElementChild as HTMLElement)?.offsetHeight ?? 60; - const rect = containerRef.current.getBoundingClientRect(); - const fromTop = ev.clientY - rect.top - titleBarH; - const clamped = Math.min(rect.height * maxSizeRatio, Math.max(minSize, fromTop)); - setSize(clamped); - prevSizeRef.current = clamped; - if (clamped > 4) setIsCollapsed(false); - }; + beginDrag(); + + const onMouseMove = (ev: MouseEvent) => applyMove(ev.clientY); const onMouseUp = () => { isDraggingRef.current = false; @@ -76,7 +86,36 @@ export function useDragResize({ cleanupDragRef.current = onMouseUp; }, - [size, minSize, maxSizeRatio] + [beginDrag, applyMove] + ); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length !== 1) return; + beginDrag(); + + const onTouchMove = (ev: TouchEvent) => { + if (ev.cancelable) ev.preventDefault(); // evita el scroll mientras se arrastra + const touch = ev.touches[0]; + if (touch) applyMove(touch.clientY); + }; + + const onTouchEnd = () => { + isDraggingRef.current = false; + setIsDragging(false); + cleanupDragRef.current = null; + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('touchend', onTouchEnd); + window.removeEventListener('touchcancel', onTouchEnd); + }; + + window.addEventListener('touchmove', onTouchMove, { passive: false }); + window.addEventListener('touchend', onTouchEnd); + window.addEventListener('touchcancel', onTouchEnd); + + cleanupDragRef.current = onTouchEnd; + }, + [beginDrag, applyMove] ); const toggleCollapse = useCallback(() => { @@ -121,6 +160,7 @@ export function useDragResize({ containerRef, resizableRef, handleMouseDown, + handleTouchStart, handleKeyDown, toggleCollapse, }; diff --git a/src/apps/ums.web-app/src/application/hooks/use-focus-trap.test.ts b/src/apps/ums.web-app/src/application/hooks/use-focus-trap.test.ts index 22831d0c..4d697c2d 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-focus-trap.test.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-focus-trap.test.ts @@ -82,9 +82,13 @@ describe('useFocusTrap', () => { const container = document.createElement('div'); document.body.appendChild(container); - const { result, rerender } = renderHook(() => useFocusTrap({ active: true })); + // El contenedor existe ANTES de activar la trampa, que es como ocurre de verdad: React asigna + // las refs antes de correr los efectos del mismo commit. + const { result, rerender } = renderHook(({ active }) => useFocusTrap({ active }), { + initialProps: { active: false }, + }); result.current.containerRef.current = container; - rerender(); + rerender({ active: true }); expect(container.getAttribute('tabindex')).toBe('-1'); }); @@ -96,11 +100,37 @@ describe('useFocusTrap', () => { container.appendChild(button); document.body.appendChild(container); - const { result, rerender } = renderHook(() => useFocusTrap({ active: true })); + const { result, rerender } = renderHook(({ active }) => useFocusTrap({ active }), { + initialProps: { active: false }, + }); result.current.containerRef.current = container; - rerender(); + rerender({ active: true }); + + expect(document.activeElement).toBe(button); + }); + + // Regresión: el foco inicial se toma UNA vez, al activarse. Antes el contenedor entraba en las + // dependencias leído durante el render —donde todavía era null—, así que el efecto se repetía en + // el render siguiente y le quitaba el foco a quien estuviera escribiendo en el diálogo. + it('no vuelve a robar el foco en renders posteriores', () => { + const container = document.createElement('div'); + const button = document.createElement('button'); + const input = document.createElement('input'); + container.appendChild(button); + container.appendChild(input); + document.body.appendChild(container); + const { result, rerender } = renderHook(({ active }) => useFocusTrap({ active }), { + initialProps: { active: false }, + }); + result.current.containerRef.current = container; + rerender({ active: true }); expect(document.activeElement).toBe(button); + + input.focus(); + rerender({ active: true }); + + expect(document.activeElement).toBe(input); }); it('calls onEscape when Escape key is pressed', () => { @@ -109,9 +139,11 @@ describe('useFocusTrap', () => { container.setAttribute('tabindex', '-1'); document.body.appendChild(container); - const { result, rerender } = renderHook(() => useFocusTrap({ active: true, onEscape })); + const { result, rerender } = renderHook(({ active }) => useFocusTrap({ active, onEscape }), { + initialProps: { active: false }, + }); result.current.containerRef.current = container; - rerender(); + rerender({ active: true }); act(() => { const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); diff --git a/src/apps/ums.web-app/src/application/hooks/use-focus-trap.ts b/src/apps/ums.web-app/src/application/hooks/use-focus-trap.ts index e6ebbcb5..bcab26a4 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-focus-trap.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-focus-trap.ts @@ -71,11 +71,15 @@ export function useFocusTrap({ [active, onEscape] ); - const container = containerRef.current; - useEffect(() => { if (!active) return; + // El contenedor se lee AQUÍ, no en el render. Al leerlo en el render, la primera pasada lo veía + // en `null` (las refs se asignan después) y el efecto volvía a dispararse en el siguiente + // render —el que provoca la primera tecla escrita en un campo del diálogo—, robándole el foco + // al campo y llevándolo al primer botón. Con la barra espaciadora, ese botón acababa pulsado. + const container = containerRef.current; + previousActiveElementRef.current = document.activeElement; if (!container) return; @@ -99,7 +103,7 @@ export function useFocusTrap({ previousActiveElementRef.current.focus(); } }; - }, [active, trapFocus, container]); + }, [active, trapFocus]); return { containerRef }; } diff --git a/src/apps/ums.web-app/src/application/hooks/use-notified-mutation.ts b/src/apps/ums.web-app/src/application/hooks/use-notified-mutation.ts index 1bbd9a7a..22f1ecb5 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-notified-mutation.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-notified-mutation.ts @@ -33,8 +33,8 @@ export interface UseNotifiedMutationOptions { mutationFn: (variables: TVariables) => Promise; /** Query keys to invalidate on success. */ invalidateKeys?: QueryKey[]; - /** Notification shown on success. May use the response data. */ - successNotif: (data: TData) => NotifiedMutationNotif; + /** Notification shown on success. Recibe la respuesta y las variables enviadas. */ + successNotif: (data: TData, variables: TVariables) => NotifiedMutationNotif; /** Notification shown on error. Receives the raw error. */ errorNotif: (error: unknown) => NotifiedMutationNotif; /** @@ -58,9 +58,6 @@ export function useNotifiedMutation({ }: UseNotifiedMutationOptions) { let queryClient: ReturnType | null = null; try { - // Degradación intencional: sin QueryClientProvider (p.ej. tests aislados) useQueryClient - // lanza y caemos a null. El hook se invoca siempre en el mismo punto del render. - // eslint-disable-next-line react-hooks/rules-of-hooks queryClient = useQueryClient(); } catch { queryClient = null; @@ -70,11 +67,11 @@ export function useNotifiedMutation({ return useMutation({ mutationFn, - onSuccess: data => { + onSuccess: (data, variables) => { if (invalidateKeys) { invalidateKeys.forEach(key => queryClient?.invalidateQueries({ queryKey: key })); } - const notif = successNotif(data); + const notif = successNotif(data, variables); addNotification({ title: notif.title, message: notif.message, diff --git a/src/apps/ums.web-app/src/application/hooks/use-status-label.test.ts b/src/apps/ums.web-app/src/application/hooks/use-status-label.test.ts index ee7e2be4..9d514af6 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-status-label.test.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-status-label.test.ts @@ -29,11 +29,13 @@ describe('useStatusLabel', () => { expect(getStatusLabel('Pending')).toBe('Pending'); }); - it('returns pending for unknown status', () => { + it('returns the raw value for unmapped status (G-153: no lo disfraza de «Pendiente»)', () => { const { result } = renderHook(() => useStatusLabel()); const getStatusLabel = result.current; - expect(getStatusLabel('Unknown')).toBe('Pending'); + // Un estado sin clave i18n (p. ej. Draft/PendingApproval) se muestra crudo, no como «Pendiente». + expect(getStatusLabel('Unknown')).toBe('Unknown'); + expect(getStatusLabel('PendingApproval')).toBe('PendingApproval'); }); it('returns spanish labels when language is es', () => { diff --git a/src/apps/ums.web-app/src/application/hooks/use-status-label.ts b/src/apps/ums.web-app/src/application/hooks/use-status-label.ts index 07c9d51e..a741c1a8 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-status-label.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-status-label.ts @@ -6,16 +6,24 @@ import { useI18n } from '@app/i18n/use-i18n'; import { TenantStatusSchema } from '@domain/identity/schemas/tenant.schema'; +// Mapa de estado→clave i18n abarcando los estados de varios dominios que comparten esta etiqueta +// (G-153). Los que aún no tienen clave i18n propia (Draft, PendingApproval, Published, Archived, +// Revoked, Rejected, Expired…) caen al fallback = valor CRUDO, no a «Pendiente» (que antes disfrazaba +// de «Pendiente» a Draft/PendingApproval y era engañoso). Localizarlos requiere nuevas claves i18n. const STATUS_KEY_MAP: Record> = { [TenantStatusSchema.enum.Active]: 'active', [TenantStatusSchema.enum.Suspended]: 'suspended', [TenantStatusSchema.enum.Pending]: 'pending', + Inactive: 'inactive', + Blocked: 'blocked', + Maintenance: 'maintenance', + Deprecated: 'deprecated', }; export const useStatusLabel = () => { const t = useI18n(); return (status: string): string => { const key = STATUS_KEY_MAP[status]; - return key ? (t[key] as string) : t.pending; + return key ? (t[key] as string) : status; }; }; diff --git a/src/apps/ums.web-app/src/application/i18n/namespaces/authorization.translations.ts b/src/apps/ums.web-app/src/application/i18n/namespaces/authorization.translations.ts index dfa41d6c..0e2a65cf 100644 --- a/src/apps/ums.web-app/src/application/i18n/namespaces/authorization.translations.ts +++ b/src/apps/ums.web-app/src/application/i18n/namespaces/authorization.translations.ts @@ -3,8 +3,11 @@ export const authorizationTranslations = { // Context labels authorizationContext: 'Autorización', systemSuites: 'Suites del Sistema', + systemSuitesNav: 'Sistemas', permissionTemplates: 'Plantillas de Permisos', profilesHeader: 'Perfiles de Autorización', + observabilityGrafana: 'Grafana (métricas y trazas)', + observabilityLogs: 'Logs (Loki)', permissionTemplateMaintenance: 'Plantillas de Permisos', permissionTemplateMaintenanceSubtitle: 'Configure plantillas de permisos por rol y suite. Asigne efectos (Permitir/Denegar/Neutro) a cada recurso.', @@ -167,8 +170,11 @@ export const authorizationTranslations = { // Context labels authorizationContext: 'Authorization', systemSuites: 'System Suites', + systemSuitesNav: 'Systems', permissionTemplates: 'Permission Templates', profilesHeader: 'Authorization Profiles', + observabilityGrafana: 'Grafana (metrics & traces)', + observabilityLogs: 'Logs (Loki)', permissionTemplateMaintenance: 'Permission Templates', permissionTemplateMaintenanceSubtitle: 'Configure permission templates by role and system suite. Assign Allow/Deny/Neutral effects per resource.', diff --git a/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.test.ts b/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.test.ts index d1cd5c37..3d58e93a 100644 --- a/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.test.ts +++ b/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.test.ts @@ -53,7 +53,6 @@ describe('identityTranslations', () => { it('has tab labels', () => { expect(identityTranslations.es.tabLocations).toBe('Ubicaciones'); expect(identityTranslations.es.tabAuthIdps).toBe('Prov. Identidad'); - expect(identityTranslations.es.tabBranding).toBe('Identidad Visual'); }); it('has branch labels', () => { @@ -74,12 +73,6 @@ describe('identityTranslations', () => { expect(identityTranslations.es.strategyOAuth2).toBe('OAuth 2.0 Genérico'); }); - it('has branding labels', () => { - expect(identityTranslations.es.customBranding).toBe('Identidad Visual'); - expect(identityTranslations.es.brandPrimaryColor).toBe('Color Principal'); - expect(identityTranslations.es.applyBranding).toBe('Guardar Identidad Visual'); - }); - it('has edit labels', () => { expect(identityTranslations.es.editBtn).toBe('Editar'); expect(identityTranslations.es.unsavedChanges).toBe('Cambios sin guardar'); @@ -186,7 +179,6 @@ describe('identityTranslations', () => { it('has tab labels', () => { expect(identityTranslations.en.tabLocations).toBe('Locations'); expect(identityTranslations.en.tabAuthIdps).toBe('Auth IDPs'); - expect(identityTranslations.en.tabBranding).toBe('Branding'); }); it('has branch labels', () => { @@ -207,12 +199,6 @@ describe('identityTranslations', () => { expect(identityTranslations.en.strategyOAuth2).toBe('OAuth 2.0 Generic'); }); - it('has branding labels', () => { - expect(identityTranslations.en.customBranding).toBe('Visual Identity'); - expect(identityTranslations.en.brandPrimaryColor).toBe('Primary Color'); - expect(identityTranslations.en.applyBranding).toBe('Save Visual Identity'); - }); - it('has edit labels', () => { expect(identityTranslations.en.editBtn).toBe('Edit'); expect(identityTranslations.en.unsavedChanges).toBe('Unsaved changes'); diff --git a/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.ts b/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.ts index b459528a..6bbd1927 100644 --- a/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.ts +++ b/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.ts @@ -72,6 +72,7 @@ export const identityTranslations = { tabAuthIdps: 'Prov. Identidad', tabBranding: 'Identidad Visual', tabConfigurations: 'Parámetros', + tabAudit: 'Auditoría', configurationsForTenant: 'Configuraciones del Tenant', // Branch Manager @@ -91,6 +92,31 @@ export const identityTranslations = { noBranches: 'No hay sucursales registradas para este tenant.', addingLocation: 'Nueva Ubicación', + // Cierre definitivo de sucursales (ADR-0164). «Cerrar» y «desactivar» son cosas distintas y + // los rótulos tienen que dejarlo claro ANTES de pulsar: el cierre no se deshace. + closeBranch: 'Cerrar sucursal (definitivo)', + closeBranchTitle: '¿Cerrar la sucursal definitivamente?', + closeBranchMessage: (name: string, code: string) => + `«${name}» quedará cerrada de forma permanente. No podrá reactivarse y su código «${code}» quedará ocupado para siempre; si el negocio vuelve a operar ahí habrá que dar de alta otra sucursal con otro código. Desactivar, en cambio, sí se puede deshacer.`, + closeBranchConfirm: 'Sí, cerrar definitivamente', + closeBranchReason: 'Motivo del cierre (opcional)', + branchClosed: 'Cerrada', + branchClosedOn: (date: string) => `Cerrada el ${date}`, + branchClosedHint: 'Una sucursal cerrada no admite cambios: su ciclo de vida terminó.', + filterOpenBranches: 'Abiertas', + filterClosedBranches: 'Cerradas', + branchLifecycle: 'Bitácora', + branchLifecycleClose: 'Cerrar bitácora', + branchLifecycleTitle: (name: string) => `Bitácora de «${name}»`, + branchLifecycleEmpty: 'Esta sucursal todavía no tiene episodios registrados.', + branchLifecycleLoading: 'Cargando bitácora…', + branchEpisodeOpened: 'Apertura', + branchEpisodeDeactivated: 'Desactivación', + branchEpisodeReactivated: 'Reactivación', + branchEpisodeClosed: 'Cierre definitivo', + branchEpisodeActor: (actor: string) => `por ${actor}`, + branchEpisodeReason: (reason: string) => `Motivo: ${reason}`, + // Identity Providers (IdP) identityProviders: 'Proveedores de Identidad', idpSubtitle: 'Configure sistemas federados OIDC, SAML 2.0 u OAuth 2.0.', @@ -106,33 +132,6 @@ export const identityTranslations = { strategySAML2: 'SAML 2.0 Empresarial', strategyOAuth2: 'OAuth 2.0 Genérico', - // Branding - customBranding: 'Identidad Visual', - brandingSubtitle: 'Configure la apariencia del portal de inicio de sesión para este tenant.', - brandHeadline: 'Título Principal del Portal', - brandSecondary: 'Subtítulo / Tagline', - brandButtonLabel: 'Texto del Botón Principal', - brandFooter: 'Texto del Pie de Página', - brandPrimaryColor: 'Color Principal', - brandBackground: 'Estilo de Fondo', - brandBgSolid: 'Sólido', - brandBgGradientSubtle: 'Gradiente', - brandBgGradientBold: 'Gradiente Intenso', - brandBgImage: 'Imagen de fondo', - brandLogoUrl: 'URL del Logotipo', - brandLogoFormat: 'Formato del Logo', - brandLogoPreview: 'Vista Previa', - brandCustomDomain: 'Dominio Personalizado', - brandMagicLink: 'Magic Link como fallback', - brandingContent: 'Contenido del portal', - brandingVisual: 'Apariencia visual', - brandingDomain: 'Dominio y autenticación', - brandDnsStatus: 'Estado DNS', - brandDnsVerified: 'Verificado', - brandDnsPending: 'Pendiente', - brandDnsFailed: 'Error de verificación', - applyBranding: 'Guardar Identidad Visual', - // Inline editing editBtn: 'Editar', unsavedChanges: 'Cambios sin guardar', @@ -164,8 +163,6 @@ export const identityTranslations = { notifProviderModifiedMsg: 'Estado del protocolo de autenticación actualizado.', notifProviderRemoved: 'Proveedor Desconectado', notifProviderRemovedMsg: 'Configuraciones de identidad de terceros eliminadas.', - notifBrandingApplied: 'Marca Aplicada', - notifBrandingMsg: (color: string) => `Perfil de tema actualizado. Color principal: ${color}`, notifBranchAdded: 'Sucursal Registrada', notifBranchAddedMsg: (code: string) => `Sucursal '${code}' registrada correctamente.`, notifBranchAddFailed: 'Error al Registrar Sucursal', @@ -174,6 +171,14 @@ export const identityTranslations = { notifBranchRemovedMsg: 'La sucursal fue eliminada del tenant.', notifBranchRemoveFailed: 'Error al Eliminar Sucursal', notifBranchRemoveFailedMsg: 'No se pudo eliminar la sucursal.', + notifBranchClosed: 'Sucursal Cerrada', + notifBranchClosedMsg: + 'La sucursal quedó cerrada definitivamente. Su código no puede reutilizarse.', + notifBranchCloseFailed: 'Error al Cerrar Sucursal', + notifBranchCloseFailedMsg: 'No se pudo cerrar la sucursal.', + notifBranchCloseBlocked: 'No se puede cerrar la sucursal', + notifBranchCloseBlockedMsg: (detalle: string) => + `Todavía hay ${detalle} asignados a esta sucursal. Reasígnelos o desactívelos antes de cerrarla.`, notifBranchDeactivated: 'Sucursal Desactivada', notifBranchDeactivatedMsg: 'La sucursal fue desactivada correctamente.', notifBranchDeactivateFailed: 'Error al Desactivar Sucursal', @@ -239,6 +244,8 @@ export const identityTranslations = { permissions: 'Permisos', credentials: 'Contraseña', passwordManagement: 'Gestión de Contraseña', + passwordManagementInfo: + 'Rotar contraseña restablece la clave local del usuario interno a una nueva contraseña temporal, que él deberá cambiar en su próximo inicio de sesión. Útil ante olvido u onboarding. Solo aplica a usuarios internos gestionados localmente.', localPassword: 'Contraseña local', notConfigured: 'No configurada', lastPasswordRotation: 'Última rotación', @@ -332,6 +339,7 @@ export const identityTranslations = { tabAuthIdps: 'Auth IDPs', tabBranding: 'Branding', tabConfigurations: 'Configuration', + tabAudit: 'Audit', configurationsForTenant: 'Tenant Configuration', // Branch Manager @@ -351,6 +359,30 @@ export const identityTranslations = { noBranches: 'No branches registered for this tenant.', addingLocation: 'New Location', + // Permanent branch closure (ADR-0164). + closeBranch: 'Close branch (permanent)', + closeBranchTitle: 'Close this branch permanently?', + closeBranchMessage: (name: string, code: string) => + `"${name}" will be permanently closed. It cannot be reactivated and its code "${code}" stays taken forever; reopening in that location requires a brand-new branch with a different code. Deactivating, by contrast, can be undone.`, + closeBranchConfirm: 'Yes, close permanently', + closeBranchReason: 'Closure reason (optional)', + branchClosed: 'Closed', + branchClosedOn: (date: string) => `Closed on ${date}`, + branchClosedHint: 'A closed branch accepts no changes: its lifecycle has ended.', + filterOpenBranches: 'Open', + filterClosedBranches: 'Closed', + branchLifecycle: 'History', + branchLifecycleClose: 'Close history', + branchLifecycleTitle: (name: string) => `History of "${name}"`, + branchLifecycleEmpty: 'This branch has no recorded episodes yet.', + branchLifecycleLoading: 'Loading history…', + branchEpisodeOpened: 'Opened', + branchEpisodeDeactivated: 'Deactivated', + branchEpisodeReactivated: 'Reactivated', + branchEpisodeClosed: 'Permanently closed', + branchEpisodeActor: (actor: string) => `by ${actor}`, + branchEpisodeReason: (reason: string) => `Reason: ${reason}`, + // Identity Providers (IdP) identityProviders: 'Identity Providers', idpSubtitle: 'Configure federated OIDC, SAML 2.0 or OAuth 2.0 systems.', @@ -366,33 +398,6 @@ export const identityTranslations = { strategySAML2: 'SAML 2.0 Enterprise', strategyOAuth2: 'OAuth 2.0 Generic', - // Branding - customBranding: 'Visual Identity', - brandingSubtitle: 'Configure the login portal appearance for this tenant.', - brandHeadline: 'Portal Main Title', - brandSecondary: 'Subtitle / Tagline', - brandButtonLabel: 'Primary Button Label', - brandFooter: 'Footer Text', - brandPrimaryColor: 'Primary Color', - brandBackground: 'Background Style', - brandBgSolid: 'Solid', - brandBgGradientSubtle: 'Gradient', - brandBgGradientBold: 'Bold Gradient', - brandBgImage: 'Background Image', - brandLogoUrl: 'Logo URL', - brandLogoFormat: 'Logo Format', - brandLogoPreview: 'Preview', - brandCustomDomain: 'Custom Domain', - brandMagicLink: 'Magic Link as fallback', - brandingContent: 'Portal content', - brandingVisual: 'Visual appearance', - brandingDomain: 'Domain & authentication', - brandDnsStatus: 'DNS Status', - brandDnsVerified: 'Verified', - brandDnsPending: 'Pending', - brandDnsFailed: 'Verification failed', - applyBranding: 'Save Visual Identity', - // Inline editing editBtn: 'Edit', unsavedChanges: 'Unsaved changes', @@ -425,8 +430,6 @@ export const identityTranslations = { notifProviderModifiedMsg: 'Client authentication protocol status successfully updated.', notifProviderRemoved: 'Auth Provider Disconnected', notifProviderRemovedMsg: 'Third-party identity configurations dismantled successfully.', - notifBrandingApplied: 'Branding Customized', - notifBrandingMsg: (color: string) => `Theme profiles updated. Primary style token: ${color}`, notifBranchAdded: 'Branch Registered', notifBranchAddedMsg: (code: string) => `Branch '${code}' registered successfully.`, notifBranchAddFailed: 'Branch Registration Failed', @@ -435,6 +438,13 @@ export const identityTranslations = { notifBranchRemovedMsg: 'The branch was removed from the tenant.', notifBranchRemoveFailed: 'Branch Removal Failed', notifBranchRemoveFailedMsg: 'Could not remove the branch.', + notifBranchClosed: 'Branch Closed', + notifBranchClosedMsg: 'The branch is permanently closed. Its code cannot be reused.', + notifBranchCloseFailed: 'Branch Closure Failed', + notifBranchCloseFailedMsg: 'Could not close the branch.', + notifBranchCloseBlocked: 'Cannot close the branch', + notifBranchCloseBlockedMsg: (detail: string) => + `There are still ${detail} assigned to this branch. Reassign or deactivate them before closing it.`, notifBranchDeactivated: 'Branch Deactivated', notifBranchDeactivatedMsg: 'The branch was deactivated successfully.', notifBranchDeactivateFailed: 'Branch Deactivation Failed', @@ -499,6 +509,8 @@ export const identityTranslations = { permissions: 'Permissions', credentials: 'Password', passwordManagement: 'Password Management', + passwordManagementInfo: + "Rotate password resets the internal user's local credential to a new temporary password, which they must change on their next sign-in. Useful for lockouts or onboarding. Only applies to locally-managed internal users.", localPassword: 'Local password', notConfigured: 'Not configured', lastPasswordRotation: 'Last rotation', diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx index de68cca1..edeb9d89 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx @@ -4,25 +4,29 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import { useGetBranches, + useGetBranchLifecycle, useAddBranch, - useRemoveBranch, + useCloseBranch, useDeactivateBranch, useReactivateBranch, + describeBlockingDependencies, } from './use-branch'; import tenantService from '@infra/identity/services/tenant.service'; vi.mock('@infra/identity/services/tenant.service', () => ({ tenantService: { getBranches: vi.fn(), + getBranchLifecycle: vi.fn(), addBranch: vi.fn(), - removeBranch: vi.fn(), + closeBranch: vi.fn(), deactivateBranch: vi.fn(), reactivateBranch: vi.fn(), }, default: { getBranches: vi.fn(), + getBranchLifecycle: vi.fn(), addBranch: vi.fn(), - removeBranch: vi.fn(), + closeBranch: vi.fn(), deactivateBranch: vi.fn(), reactivateBranch: vi.fn(), }, @@ -34,10 +38,12 @@ vi.mock('@app/i18n/use-i18n', () => ({ notifBranchAddedMsg: (code: string) => `Branch ${code} added`, notifBranchAddFailed: 'Add Failed', notifBranchAddFailedMsg: 'Could not add branch', - notifBranchRemoved: 'Branch Removed', - notifBranchRemovedMsg: 'Branch removed successfully', - notifBranchRemoveFailed: 'Remove Failed', - notifBranchRemoveFailedMsg: 'Could not remove branch', + notifBranchClosed: 'Branch Closed', + notifBranchClosedMsg: 'Branch closed permanently', + notifBranchCloseFailed: 'Close Failed', + notifBranchCloseFailedMsg: 'Could not close branch', + notifBranchCloseBlocked: 'Cannot close', + notifBranchCloseBlockedMsg: (detalle: string) => `Blocked by ${detalle}`, notifBranchDeactivated: 'Branch Deactivated', notifBranchDeactivatedMsg: 'Branch deactivated', notifBranchDeactivateFailed: 'Deactivate Failed', @@ -78,7 +84,17 @@ describe('use-branch hooks', () => { }); it('useGetBranches returns branches for tenant', async () => { - const mockBranches = [{ branchId: 'b1', code: 'B1', name: 'Branch 1', isActive: true }]; + const mockBranches = [ + { + branchId: 'b1', + code: 'B1', + name: 'Branch 1', + isActive: true, + geofencingMetadata: null, + isClosed: false, + closedAtUtc: null, + }, + ]; vi.mocked(tenantService.getBranches).mockResolvedValue(mockBranches); const wrapper = createWrapper(); @@ -89,7 +105,21 @@ describe('use-branch hooks', () => { }); expect(result.current.data?.[0].name).toBe('Branch 1'); - expect(tenantService.getBranches).toHaveBeenCalledWith('t1'); + // Por defecto NO se piden las cerradas (ADR-0164). + expect(tenantService.getBranches).toHaveBeenCalledWith('t1', false); + }); + + it('useGetBranches pide las cerradas cuando se le indica', async () => { + vi.mocked(tenantService.getBranches).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetBranches('t1', true), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(tenantService.getBranches).toHaveBeenCalledWith('t1', true); }); it('useGetBranches returns empty array on 404', async () => { @@ -123,12 +153,49 @@ describe('use-branch hooks', () => { expect(typeof result.current.mutate).toBe('function'); }); - it('useRemoveBranch returns mutation object', () => { + it('useCloseBranch cierra la sucursal contra el servicio de cierre, con su motivo', async () => { + vi.mocked(tenantService.closeBranch).mockResolvedValue(); + const wrapper = createWrapper(); - const { result } = renderHook(() => useRemoveBranch('t1'), { wrapper }); + const { result } = renderHook(() => useCloseBranch('t1'), { wrapper }); - expect(result.current.mutate).toBeDefined(); - expect(typeof result.current.mutate).toBe('function'); + await act(async () => { + await result.current.mutateAsync({ branchId: 'b1', reason: 'Cese de operaciones' }); + }); + + expect(tenantService.closeBranch).toHaveBeenCalledWith('t1', 'b1', 'Cese de operaciones'); + }); + + it('useGetBranchLifecycle no consulta sin sucursal seleccionada', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetBranchLifecycle('t1', null), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(tenantService.getBranchLifecycle).not.toHaveBeenCalled(); + }); + + it('useGetBranchLifecycle trae los episodios de la sucursal', async () => { + vi.mocked(tenantService.getBranchLifecycle).mockResolvedValue([ + { + entryId: 'e1', + episode: 'Closed', + occurredAtUtc: '2026-07-31T15:04:05Z', + actorId: 'admin@beyondnet.com.pe', + nameSnapshot: 'Sucursal Callao', + geofencingSnapshot: null, + reason: null, + }, + ]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetBranchLifecycle('t1', 'b1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.[0].episode).toBe('Closed'); + expect(tenantService.getBranchLifecycle).toHaveBeenCalledWith('t1', 'b1'); }); it('useDeactivateBranch returns mutation object', () => { @@ -147,3 +214,27 @@ describe('use-branch hooks', () => { expect(typeof result.current.mutate).toBe('function'); }); }); + +// El 409 del cierre trae las dos clases desglosadas; un «no se pudo» a secas obliga a adivinar. +describe('describeBlockingDependencies', () => { + it('nombra una sola clase en singular', () => { + expect(describeBlockingDependencies([{ entityType: 'Profile', count: 1 }])).toBe( + '1 perfil activo' + ); + }); + + it('pluraliza y enumera las dos clases', () => { + expect( + describeBlockingDependencies([ + { entityType: 'UserAccount', count: 3 }, + { entityType: 'Profile', count: 2 }, + ]) + ).toBe('3 cuentas de usuario activas y 2 perfiles activos'); + }); + + it('no oculta una clase desconocida: la muestra tal cual', () => { + expect(describeBlockingDependencies([{ entityType: 'Delegation', count: 4 }])).toBe( + '4 × Delegation' + ); + }); +}); diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.ts index bec0cfcf..537a5263 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.ts @@ -2,19 +2,28 @@ import { useQuery } from '@tanstack/react-query'; import tenantService from '@infra/identity/services/tenant.service'; import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { useI18n } from '@app/i18n/use-i18n'; -import { AddBranchPayload, Branch } from '@domain/identity/models/branch.model'; +import { + AddBranchPayload, + Branch, + BranchLifecycleEntry, +} from '@domain/identity/models/branch.model'; import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; +import { getBlockedOperation } from '@app/errors/http-error'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; // ─── Query ────────────────────────────────────────────────────────────────── -export const useGetBranches = (tenantId: string | null) => { +/** + * ADR-0164: por defecto el backend NO devuelve las sucursales cerradas. `includeClosed` entra en la + * clave de caché porque son dos conjuntos distintos, no dos vistas del mismo. + */ +export const useGetBranches = (tenantId: string | null, includeClosed = false) => { return useQuery({ - queryKey: ['tenants', tenantId, 'branches'], + queryKey: ['tenants', tenantId, 'branches', { includeClosed }], queryFn: async () => { if (!tenantId) throw new Error('Tenant ID required'); try { - return await tenantService.getBranches(tenantId); + return await tenantService.getBranches(tenantId, includeClosed); } catch (err: unknown) { if (getHttpStatus(err) === 404) return []; throw err; @@ -26,6 +35,23 @@ export const useGetBranches = (tenantId: string | null) => { }); }; +/** + * Bitácora de la sucursal. Se consulta solo cuando alguien la abre (`enabled`), porque es una + * pregunta de auditoría puntual y no algo que haya que traer con cada listado. + */ +export const useGetBranchLifecycle = (tenantId: string | null, branchId: string | null) => { + return useQuery({ + queryKey: ['tenants', tenantId, 'branches', branchId, 'bitacora'], + queryFn: async () => { + if (!tenantId || !branchId) throw new Error('Tenant ID and Branch ID required'); + return await tenantService.getBranchLifecycle(tenantId, branchId); + }, + enabled: !!tenantId && !!branchId, + ...CONTEXT_QUERY_CONFIG.BRANCH, + ...getRetryOptions({ maxRetries: 1 }), + }); +}; + // ─── Mutations ────────────────────────────────────────────────────────────── export const useAddBranch = (tenantId: string) => { @@ -44,23 +70,89 @@ export const useAddBranch = (tenantId: string) => { }); }; -export const useRemoveBranch = (tenantId: string) => { +export const useUpdateBranch = (tenantId: string) => { const t = useI18n(); return useNotifiedMutation({ - mutationFn: (branchId: string) => tenantService.removeBranch(tenantId, branchId), + mutationFn: async (vars: { + branchId: string; + name: string; + geofencingMetadata?: string | null; + }) => { + await tenantService.updateBranch(tenantId, vars.branchId, { + name: vars.name, + geofencingMetadata: vars.geofencingMetadata, + }); + return vars; + }, invalidateKeys: [['tenants', tenantId, 'branches']], - successNotif: () => ({ - title: t.notifBranchRemoved, - message: t.notifBranchRemovedMsg, - type: 'warning' as const, + successNotif: data => ({ + title: t.notifBranchUpdated, + message: t.notifBranchUpdatedMsg(data.name), }), errorNotif: () => ({ - title: t.notifBranchRemoveFailed ?? 'Error al Eliminar Sucursal', - message: t.notifBranchRemoveFailedMsg ?? 'No se pudo eliminar la sucursal.', + title: 'Error al Actualizar Sucursal', + message: 'No se pudo actualizar la sucursal.', }), }); }; +/** + * Cómo se nombra cada clase de dependencia que puede bloquear el cierre. Sin esto el desglose diría + * «UserAccount: 3», que es el nombre del agregado, no algo que quien opera reconozca. + */ +const BLOCKING_LABELS: Record = { + UserAccount: { singular: 'cuenta de usuario activa', plural: 'cuentas de usuario activas' }, + Profile: { singular: 'perfil activo', plural: 'perfiles activos' }, +}; + +/** «3 cuentas de usuario activas y 1 perfil activo»: enumera cuanto bloquea, no solo lo primero. */ +export function describeBlockingDependencies( + dependencies: { entityType: string; count: number }[] +): string { + const parts = dependencies.map(dep => { + const labels = BLOCKING_LABELS[dep.entityType]; + if (!labels) return `${dep.count} × ${dep.entityType}`; + return `${dep.count} ${dep.count === 1 ? labels.singular : labels.plural}`; + }); + + if (parts.length <= 1) return parts.join(''); + return `${parts.slice(0, -1).join(', ')} y ${parts[parts.length - 1]}`; +} + +/** + * Cierra la sucursal (ADR-0164). Antes se llamaba «eliminar»; el endpoint es el mismo, lo que + * cambió es que ya no borra nada y que no hay vuelta atrás. + * + * Si el backend responde 409 el mensaje NOMBRA lo que bloquea —cuántas cuentas y cuántos perfiles + * siguen activos—, porque es lo que hay que resolver antes de reintentar. + */ +export const useCloseBranch = (tenantId: string) => { + const t = useI18n(); + return useNotifiedMutation({ + mutationFn: (vars: { branchId: string; reason?: string }) => + tenantService.closeBranch(tenantId, vars.branchId, vars.reason), + invalidateKeys: [['tenants', tenantId, 'branches']], + successNotif: () => ({ + title: t.notifBranchClosed, + message: t.notifBranchClosedMsg, + type: 'warning' as const, + }), + errorNotif: error => { + const blocked = getBlockedOperation(error); + if (blocked) { + return { + title: t.notifBranchCloseBlocked, + message: t.notifBranchCloseBlockedMsg(describeBlockingDependencies(blocked.dependencies)), + }; + } + return { + title: t.notifBranchCloseFailed, + message: t.notifBranchCloseFailedMsg, + }; + }, + }); +}; + export const useDeactivateBranch = (tenantId: string) => { const t = useI18n(); return useNotifiedMutation({ diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.ts index 71518dd1..34fa24fe 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.ts @@ -69,6 +69,8 @@ export function useDelegationDashboard(): DelegationDashboardState & criteria: 'id', filter: 'all', sortBy: 'status', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts index 8b4e7fbe..87391a55 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts @@ -1,7 +1,7 @@ /** * use-delegation.ts — TanStack Query hooks for UserManagementDelegation bounded context * - * Queries use useQuery directly (GraphQL reads). + * Queries use useQuery directly (REST reads). * Mutations use useNotifiedMutation factory (REST writes). */ import { useQuery } from '@tanstack/react-query'; @@ -93,7 +93,7 @@ export const useActivateDelegation = (delegationId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: () => delegationService.activateDelegation(delegationId), - invalidateKeys: [['delegations', delegationId]], + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) successNotif: () => ({ title: t.notifActivated ?? 'Delegation activated', message: t.notifUserActivatedMsg ?? 'The delegation is now active.', @@ -109,7 +109,7 @@ export const useRevokeDelegation = (delegationId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: (reason: string) => delegationService.revokeDelegation(delegationId, reason), - invalidateKeys: [['delegations', delegationId]], + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) successNotif: () => ({ title: t.notifBlocked ?? 'Delegation revoked', message: t.notifUserBlockedMsg ?? 'The delegation has been revoked.', @@ -121,3 +121,51 @@ export const useRevokeDelegation = (delegationId: string) => { }), }); }; + +// G-132: máquina de aprobación de delegaciones (espejo de IGA RolePromotion, ADR-UMS-086). + +export const useSubmitDelegationForApproval = (delegationId: string) => { + return useNotifiedMutation({ + mutationFn: () => delegationService.submitDelegationForApproval(delegationId), + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) + successNotif: () => ({ + title: 'Delegación enviada a aprobación', + message: 'La delegación quedó pendiente de aprobación.', + }), + errorNotif: () => ({ + title: 'No se pudo enviar a aprobación', + message: 'No se pudo enviar la delegación a aprobación.', + }), + }); +}; + +export const useApproveDelegation = (delegationId: string) => { + return useNotifiedMutation({ + mutationFn: () => delegationService.approveDelegation(delegationId), + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) + successNotif: () => ({ + title: 'Delegación aprobada', + message: 'La delegación fue aprobada y ahora está activa.', + }), + errorNotif: () => ({ + title: 'No se pudo aprobar', + message: 'No se pudo aprobar la delegación.', + }), + }); +}; + +export const useRejectDelegation = (delegationId: string) => { + return useNotifiedMutation({ + mutationFn: (reason: string) => delegationService.rejectDelegation(delegationId, reason), + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) + successNotif: () => ({ + title: 'Delegación rechazada', + message: 'La delegación fue rechazada.', + type: 'warning', + }), + errorNotif: () => ({ + title: 'No se pudo rechazar', + message: 'No se pudo rechazar la delegación.', + }), + }); +}; diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.test.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.test.ts index 3dfe7dc2..bc78f0b4 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.test.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.test.ts @@ -154,26 +154,6 @@ describe('useTenantDashboard', () => { expect(result.current.parentTenant).toBeNull(); }); - it('includes branding tab only for root tenant', () => { - const { result } = renderHook(() => useTenantDashboard()); - - act(() => { - result.current.setSelectedId('t-1'); - }); - - expect(result.current.consoleTabs).toContain('branding'); - }); - - it('excludes branding tab for non-root tenant', () => { - const { result } = renderHook(() => useTenantDashboard()); - - act(() => { - result.current.setSelectedId('t-2'); - }); - - expect(result.current.consoleTabs).not.toContain('branding'); - }); - it('handleSelectTenant selects a tenant when not editing', () => { const { result } = renderHook(() => useTenantDashboard()); diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.ts index 12e2b48d..55e86a8e 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.ts @@ -14,7 +14,7 @@ export interface TenantDashboardState { selectedId: string; showDiscardDialog: boolean; pendingNavigationId: string | null; - activeConsoleTab: 'branches' | 'providers' | 'branding' | 'configurations'; + activeConsoleTab: 'branches' | 'providers' | 'branding' | 'configurations' | 'audit'; isTenantEditing: boolean; isCreateOpen: boolean; viewMode: 'list' | 'thumbnail'; @@ -27,12 +27,13 @@ export interface TenantDashboardActions { setShowDiscardDialog: React.Dispatch>; setPendingNavigationId: React.Dispatch>; setActiveConsoleTab: React.Dispatch< - React.SetStateAction<'branches' | 'providers' | 'branding' | 'configurations'> + React.SetStateAction<'branches' | 'providers' | 'branding' | 'configurations' | 'audit'> >; setIsTenantEditing: React.Dispatch>; setIsCreateOpen: React.Dispatch>; setViewMode: React.Dispatch>; handleSelectTenant: (id: string) => void; + handleSelectOwnTenant: (tenantId: string, tenantCode: string) => void; confirmDiscard: () => void; patchTenant: (tenantId: string, patch: Partial) => void; handleCreateSuccess: (newTenantId: string) => void; @@ -46,7 +47,7 @@ export function useTenantDashboard(): TenantDashboardState & activeTenant: Tenant | undefined; parentTenant: Tenant | null; isRootTenant: boolean; - consoleTabs: Array<'branches' | 'providers' | 'branding' | 'configurations'>; + consoleTabs: Array<'branches' | 'providers' | 'branding' | 'configurations' | 'audit'>; totalItems: number; totalPages: number; startIndex: number; @@ -56,7 +57,7 @@ export function useTenantDashboard(): TenantDashboardState & const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [pendingNavigationId, setPendingNavigationId] = useState(null); const [activeConsoleTab, setActiveConsoleTab] = useState< - 'branches' | 'providers' | 'branding' | 'configurations' + 'branches' | 'providers' | 'branding' | 'configurations' | 'audit' >('branches'); const [isTenantEditing, setIsTenantEditing] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false); @@ -66,6 +67,10 @@ export function useTenantDashboard(): TenantDashboardState & criteria: 'name', filter: 'all', sortBy: 'name', + // Patrón estándar de lista de administración: la lista se carga al entrar + // (todos los tenants) en vez de exigir aplicar un filtro primero. Búsqueda, + // «Mi Organización» y «Limpiar» son refinamientos sobre esa carga. + appliedFilter: true, }); const paginationState = usePaginationState({ @@ -100,11 +105,14 @@ export function useTenantDashboard(): TenantDashboardState & const activeTenant = knownTenants.find(tenant => tenant.tenantId === selectedId); const isRootTenant = activeTenant?.parentTenantId === null; - const consoleTabs = ( - ['branches', 'providers', 'branding', 'configurations'] as Array< - 'branches' | 'providers' | 'branding' | 'configurations' - > - ).filter(tab => tab !== 'branding' || isRootTenant); + const consoleTabs: Array<'branches' | 'providers' | 'branding' | 'configurations' | 'audit'> = [ + 'branches', + 'providers', + // Identidad visual por inquilino: pestaña propia del satélite. + 'branding', + 'configurations', + 'audit', + ]; const parentTenant = activeTenant?.parentTenantId ? (knownTenants.find(t => t.tenantId === activeTenant.parentTenantId) ?? null) @@ -131,6 +139,18 @@ export function useTenantDashboard(): TenantDashboardState & [selectedId, hasPendingChanges, applyTenantSelection] ); + // FS-26 «Mi Organización»: carga la lista filtrada por el código del propio tenant + // (así el registro aparece seleccionado en la lista) y lo abre en el detalle. + // Al pulsar «Buscar» de nuevo se aplica una consulta nueva y la carga se reinicia + // (una sola fuente de datos: la lista). + const handleSelectOwnTenant = useCallback( + (tenantId: string, tenantCode: string) => { + queryState.applyQuery('code', tenantCode); + applyTenantSelection(tenantId); + }, + [queryState, applyTenantSelection] + ); + const confirmDiscard = useCallback(() => { if (pendingNavigationId) applyTenantSelection(pendingNavigationId); setPendingNavigationId(null); @@ -189,6 +209,7 @@ export function useTenantDashboard(): TenantDashboardState & queryState, paginationState, handleSelectTenant, + handleSelectOwnTenant, confirmDiscard, patchTenant, handleCreateSuccess, diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts index 4a60f951..f5206283 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts @@ -69,9 +69,11 @@ export const useCreateTenant = () => { return useNotifiedMutation({ mutationFn: (payload: CreateTenantPayload) => tenantService.createTenant(payload), invalidateKeys: [['tenants']], - successNotif: data => ({ + // El nombre/código vienen del payload enviado (`variables`), no de la respuesta + // (que solo trae `tenantId`). Antes se leían de `data` → mensaje vacío / parse roto. + successNotif: (_data, variables) => ({ title: t.notifTenantCreated, - message: t.notifTenantCreatedMsg(data.name, data.code), + message: t.notifTenantCreatedMsg(variables.name, variables.code), }), errorNotif: () => ({ title: t.notifTenantCreateFailed, @@ -113,6 +115,34 @@ export const useSuspendTenant = (tenantId: string) => { }); }; +export const useUpdateTenant = () => { + const t = useI18n(); + return useNotifiedMutation({ + mutationFn: async (vars: { + tenantId: string; + name: string; + type: string; + companyReference?: string | null; + }) => { + await tenantService.updateTenant(vars.tenantId, { + name: vars.name, + type: vars.type, + companyReference: vars.companyReference, + }); + return vars; + }, + invalidateKeys: [['tenants']], + successNotif: data => ({ + title: t.notifTenantUpdated, + message: t.notifTenantUpdatedMsg(data.name), + }), + errorNotif: () => ({ + title: 'Error al Actualizar Tenant', + message: 'No se pudo actualizar el tenant.', + }), + }); +}; + export const useSetManagementOwner = (tenantId: string) => { return useNotifiedMutation({ mutationFn: (value: boolean) => tenantService.setManagementOwner(tenantId, value), diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.ts index 9c05b30d..91da2487 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.ts @@ -74,6 +74,8 @@ export function useUserAccountDashboard(sessionTenantId?: string): UserAccountDa criteria: 'email', filter: 'all', sortBy: 'email', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts index a64660de..2ea66b11 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts @@ -1,7 +1,7 @@ /** * use-user-account.ts — TanStack Query hooks for UserAccount bounded context * - * Queries use useQuery directly (GraphQL reads). + * Queries use useQuery directly (REST reads). * Mutations use useNotifiedMutation factory (REST writes). */ import { useQuery } from '@tanstack/react-query'; @@ -95,7 +95,7 @@ export const useActivateUserAccount = (userAccountId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: () => userAccountService.activateUserAccount(userAccountId), - invalidateKeys: [['user-accounts', userAccountId]], + invalidateKeys: [['user-accounts'], ['user-accounts', userAccountId]], successNotif: () => ({ title: t.notifActivated, message: t.notifUserActivatedMsg, @@ -111,7 +111,7 @@ export const useBlockUserAccount = (userAccountId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: (reason: string) => userAccountService.blockUserAccount(userAccountId, reason), - invalidateKeys: [['user-accounts', userAccountId]], + invalidateKeys: [['user-accounts'], ['user-accounts', userAccountId]], successNotif: () => ({ title: t.notifBlocked, message: t.notifUserBlockedMsg, @@ -128,7 +128,7 @@ export const useRestoreUserAccount = (userAccountId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: () => userAccountService.restoreUserAccount(userAccountId), - invalidateKeys: [['user-accounts', userAccountId]], + invalidateKeys: [['user-accounts'], ['user-accounts', userAccountId]], successNotif: () => ({ title: t.notifRestored, message: t.notifUserRestoredMsg, diff --git a/src/apps/ums.web-app/src/application/identity/services/auth.service.ts b/src/apps/ums.web-app/src/application/identity/services/auth.service.ts index 40ca6182..57598883 100644 --- a/src/apps/ums.web-app/src/application/identity/services/auth.service.ts +++ b/src/apps/ums.web-app/src/application/identity/services/auth.service.ts @@ -164,21 +164,19 @@ class AuthService { }; if (errorData) { - // `fail` lanza (retorna `never`); usamos `return fail(...)` para que cada rama - // termine explícitamente el flujo (evita no-fallthrough) sin código inalcanzable. switch (errorData.code) { case AUTH_ERROR_CODES.INVALID_CREDENTIALS: - return fail('No pudimos iniciar sesión. Verifique su usuario y contraseña.'); + fail('No pudimos iniciar sesión. Verifique su usuario y contraseña.'); case AUTH_ERROR_CODES.TENANT_NOT_FOUND: - return fail('No pudimos iniciar sesión. Verifique el código del tenant.'); + fail('No pudimos iniciar sesión. Verifique el código del tenant.'); case AUTH_ERROR_CODES.TENANT_INACTIVE: - return fail('El tenant no está activo. Contacte al administrador.'); + fail('El tenant no está activo. Contacte al administrador.'); case AUTH_ERROR_CODES.USER_NOT_ACTIVE: - return fail('Su cuenta no está activa. Contacte al administrador.'); + fail('Su cuenta no está activa. Contacte al administrador.'); case AUTH_ERROR_CODES.SESSION_EXPIRED: - return fail('La sesión expiró. Vuelva a iniciar sesión.'); + fail('La sesión expiró. Vuelva a iniciar sesión.'); default: - return fail(errorData.message || 'No pudimos iniciar sesión. Intente nuevamente.'); + fail(errorData.message || 'No pudimos iniciar sesión. Intente nuevamente.'); } } diff --git a/src/apps/ums.web-app/src/application/security/securityInterceptor.ts b/src/apps/ums.web-app/src/application/security/securityInterceptor.ts index 2d1f596b..77cd2066 100644 --- a/src/apps/ums.web-app/src/application/security/securityInterceptor.ts +++ b/src/apps/ums.web-app/src/application/security/securityInterceptor.ts @@ -107,12 +107,11 @@ class SecurityInterceptor { this.onUnauthorized(); break; - case 429: { + case 429: const retryAfter = headers?.get('Retry-After'); const retryMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60000; this.onRateLimited(retryMs); break; - } case 500: case 502: diff --git a/src/apps/ums.web-app/src/application/shared/hooks/use-navigation-prefetch.ts b/src/apps/ums.web-app/src/application/shared/hooks/use-navigation-prefetch.ts index 46d8af73..fd7e90a3 100644 --- a/src/apps/ums.web-app/src/application/shared/hooks/use-navigation-prefetch.ts +++ b/src/apps/ums.web-app/src/application/shared/hooks/use-navigation-prefetch.ts @@ -21,9 +21,6 @@ const PREFETCH_DEBOUNCE_MS = 150; export function useNavigationPrefetch() { let queryClient: ReturnType | null = null; try { - // Degradación intencional: sin QueryClientProvider (p.ej. tests aislados) useQueryClient - // lanza y caemos a null. El hook se invoca siempre en el mismo punto del render. - // eslint-disable-next-line react-hooks/rules-of-hooks queryClient = useQueryClient(); } catch { queryClient = null; diff --git a/src/apps/ums.web-app/src/application/shared/hooks/use-query-state.ts b/src/apps/ums.web-app/src/application/shared/hooks/use-query-state.ts index 968d07d3..21f10c2f 100644 --- a/src/apps/ums.web-app/src/application/shared/hooks/use-query-state.ts +++ b/src/apps/ums.web-app/src/application/shared/hooks/use-query-state.ts @@ -33,6 +33,14 @@ export function useQueryState( [searchCriteria, searchValue] ); + // Aplica una búsqueda concreta de forma programática (criterio + término), + // reflejándola también en los campos de entrada. Útil para atajos como «Mi Organización». + const applyQuery = useCallback((criteria: string, term: string) => { + setSearchCriteria(criteria); + setSearchValue(term); + setAppliedQuery({ criteria, term: term.trim(), filterApplied: true }); + }, []); + const handleFilterChange = useCallback((filter: TFilter) => { setActiveFilter(filter); setAppliedQuery(prev => ({ ...prev, filterApplied: true })); @@ -65,6 +73,7 @@ export function useQueryState( setSortOrder, toggleSortOrder, appliedQuery, + applyQuery, handleQuerySubmit, handleResetQuery, }; diff --git a/src/apps/ums.web-app/src/application/stores/auth.store.ts b/src/apps/ums.web-app/src/application/stores/auth.store.ts index 991002ab..696be7cc 100644 --- a/src/apps/ums.web-app/src/application/stores/auth.store.ts +++ b/src/apps/ums.web-app/src/application/stores/auth.store.ts @@ -90,6 +90,12 @@ export interface AuthState { updateActivity: () => void; getTimeUntilRefresh: () => number; switchTenant: (tenantId: string, enableCrossTenantAccess?: boolean) => Promise; + /** + * Cambia el perfil vigente sin volver a autenticarse. Reemplaza el grafo COMPLETO: el árbol de + * navegación, los permisos y el rol cambian de golpe, así que parchear el estado a trozos + * dejaría la interfaz mostrando el menú de un perfil con los permisos de otro. + */ + switchProfile: (profileId: string) => Promise; setAvailableTenants: (tenants: Array<{ id: string; code: string; name: string }>) => void; } @@ -320,6 +326,51 @@ export const useAuthStore = create()( } }, + switchProfile: async (profileId: string) => { + const state = get(); + if (!state.user?.token) return false; + + try { + const response = await fetch('/api/v1/auth/switch-profile', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${state.user.token}`, + }, + credentials: 'include', + body: JSON.stringify({ profileId }), + }); + + if (!response.ok) { + console.error('[Auth] Profile switch failed:', await response.text()); + return false; + } + + const data = await response.json(); + + set({ + user: { + ...state.user, + role: data.role ?? state.user.role, + profileId: data.profileId, + permissions: data.permissions ?? [], + token: data.token ?? state.user.token, + authorizationGraph: data.authorizationGraph ?? null, + }, + // El token nuevo trae su propia vigencia: la sesión no se alarga por cambiar de perfil. + sessionExpiresAt: data.expiresIn + ? Date.now() + data.expiresIn * 1000 + : state.sessionExpiresAt, + }); + + console.info('[Auth] Profile switched', { profileId, role: data.role }); + return true; + } catch (error) { + console.error('[Auth] Profile switch error:', error); + return false; + } + }, + setAvailableTenants: tenants => { set({ availableTenants: tenants }); }, diff --git a/src/apps/ums.web-app/src/application/stores/devTools.store.ts b/src/apps/ums.web-app/src/application/stores/devTools.store.ts index e5a294b2..6339d3dd 100644 --- a/src/apps/ums.web-app/src/application/stores/devTools.store.ts +++ b/src/apps/ums.web-app/src/application/stores/devTools.store.ts @@ -24,7 +24,8 @@ interface DevToolsState { } export const useDevToolsStore = create(set => ({ - devUserId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + // ADR-0071 / FS-26: usuario dev por defecto = root admin de BEYONDNET (admin@beyondnet.com.pe). + devUserId: '5f4e3d14-1b0a-9f8e-7d6c-543210987654', devLanguage: 'es', setDevUserId: id => set({ devUserId: id }), setDevLanguage: lang => { diff --git a/src/apps/ums.web-app/src/application/stores/i18n.store.test.ts b/src/apps/ums.web-app/src/application/stores/i18n.store.test.ts index 51a216e1..a40cec89 100644 --- a/src/apps/ums.web-app/src/application/stores/i18n.store.test.ts +++ b/src/apps/ums.web-app/src/application/stores/i18n.store.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useI18nStore } from './i18n.store'; +import { useI18nStore, toSupportedLanguage } from './i18n.store'; describe('i18n.store', () => { beforeEach(() => { - useI18nStore.setState({ language: 'es' }); + useI18nStore.setState({ language: 'es', chosenByUser: false }); document.documentElement.lang = 'es'; }); @@ -35,4 +35,48 @@ describe('i18n.store', () => { it('has setLanguage function', () => { expect(typeof useI18nStore.getState().setLanguage).toBe('function'); }); + + describe('idioma publicado por el sistema', () => { + it('lo aplica cuando el usuario no ha elegido', () => { + useI18nStore.getState().applySystemLanguage('en-US'); + + expect(useI18nStore.getState().language).toBe('en'); + expect(document.documentElement.lang).toBe('en'); + }); + + it('respeta la elección explícita del usuario', () => { + // Quien acaba de pulsar el conmutador no debe ver cómo se le revierte al resolverse el grafo. + useI18nStore.getState().setLanguage('en'); + useI18nStore.getState().applySystemLanguage('es-PE'); + + expect(useI18nStore.getState().language).toBe('en'); + }); + + it('ignora un idioma que el producto no traduce', () => { + // Mejor la interfaz entera en español que media traducida a un idioma inexistente. + useI18nStore.getState().applySystemLanguage('pt-BR'); + + expect(useI18nStore.getState().language).toBe('es'); + }); + + it('ignora la ausencia de idioma', () => { + useI18nStore.getState().applySystemLanguage(undefined); + + expect(useI18nStore.getState().language).toBe('es'); + }); + }); + + describe('toSupportedLanguage', () => { + it('se queda con el subtag primario', () => { + expect(toSupportedLanguage('es-PE')).toBe('es'); + expect(toSupportedLanguage('EN_us')).toBe('en'); + expect(toSupportedLanguage(' es ')).toBe('es'); + }); + + it('devuelve undefined para lo que no se traduce', () => { + expect(toSupportedLanguage('pt')).toBeUndefined(); + expect(toSupportedLanguage('')).toBeUndefined(); + expect(toSupportedLanguage(undefined)).toBeUndefined(); + }); + }); }); diff --git a/src/apps/ums.web-app/src/application/stores/i18n.store.ts b/src/apps/ums.web-app/src/application/stores/i18n.store.ts index 7f9376b2..79c849fe 100644 --- a/src/apps/ums.web-app/src/application/stores/i18n.store.ts +++ b/src/apps/ums.web-app/src/application/stores/i18n.store.ts @@ -12,13 +12,42 @@ export type SupportedLanguage = 'en' | 'es'; interface I18nState { /** Active UI language */ language: SupportedLanguage; + /** + * El usuario eligió idioma explícitamente en esta sesión del navegador. + * + * Existe para que el idioma que publica el sistema (`settings.locale.language`) actúe como + * DEFECTO y no como imposición: quien acaba de pulsar el conmutador no debería ver cómo se le + * revierte al resolverse el grafo. + */ + chosenByUser: boolean; /** Set the active language and update document.documentElement.lang */ setLanguage: (lang: SupportedLanguage) => void; + /** Aplica el idioma publicado por el sistema, salvo que el usuario ya haya elegido. */ + applySystemLanguage: (tag: string | undefined) => void; } -export const useI18nStore = create(set => ({ +/** + * `es-PE` → `es`. Solo se aceptan los idiomas que el producto traduce: un `pt-BR` publicado por + * error debe dejar la interfaz como está, no a medio traducir. + */ +export function toSupportedLanguage(tag: string | undefined): SupportedLanguage | undefined { + const primario = tag?.trim().toLowerCase().split(/[-_]/)[0]; + return primario === 'es' || primario === 'en' ? primario : undefined; +} + +export const useI18nStore = create((set, get) => ({ language: 'es', + chosenByUser: false, setLanguage: lang => { + document.documentElement.lang = lang; + set({ language: lang, chosenByUser: true }); + }, + applySystemLanguage: tag => { + if (get().chosenByUser) return; + + const lang = toSupportedLanguage(tag); + if (!lang || lang === get().language) return; + document.documentElement.lang = lang; set({ language: lang }); }, diff --git a/src/apps/ums.web-app/src/application/stores/theme.store.test.ts b/src/apps/ums.web-app/src/application/stores/theme.store.test.ts index fa611860..3906861d 100644 --- a/src/apps/ums.web-app/src/application/stores/theme.store.test.ts +++ b/src/apps/ums.web-app/src/application/stores/theme.store.test.ts @@ -11,11 +11,11 @@ vi.mock('zustand/middleware', async importOriginal => { describe('theme.store', () => { beforeEach(() => { - useThemeStore.setState({ isDarkMode: true }); + useThemeStore.setState({ isDarkMode: false, chosenByUser: false }); }); - it('initializes with dark mode enabled', () => { - expect(useThemeStore.getState().isDarkMode).toBe(true); + it('initializes with light mode enabled (BEYONDNET corporate default)', () => { + expect(useThemeStore.getState().isDarkMode).toBe(false); }); it('toggles dark mode from true to false', () => { @@ -43,4 +43,25 @@ describe('theme.store', () => { it('has toggleDarkMode function', () => { expect(typeof useThemeStore.getState().toggleDarkMode).toBe('function'); }); + + describe('modo publicado por el sistema', () => { + it('lo aplica cuando el usuario no ha elegido', () => { + useThemeStore.getState().applyDefaultDarkMode(true); + expect(useThemeStore.getState().isDarkMode).toBe(true); + }); + + it('respeta la elección explícita del usuario', () => { + // La preferencia se persiste; sin esta marca el sistema la revertiría en cada recarga. + useThemeStore.getState().toggleDarkMode(); + useThemeStore.getState().applyDefaultDarkMode(false); + + expect(useThemeStore.getState().isDarkMode).toBe(true); + }); + + it('el conmutador marca la elección del usuario', () => { + expect(useThemeStore.getState().chosenByUser).toBe(false); + useThemeStore.getState().toggleDarkMode(); + expect(useThemeStore.getState().chosenByUser).toBe(true); + }); + }); }); diff --git a/src/apps/ums.web-app/src/application/stores/theme.store.ts b/src/apps/ums.web-app/src/application/stores/theme.store.ts index 9acab019..1b1e7b82 100644 --- a/src/apps/ums.web-app/src/application/stores/theme.store.ts +++ b/src/apps/ums.web-app/src/application/stores/theme.store.ts @@ -9,14 +9,29 @@ import { persist } from 'zustand/middleware'; interface ThemeState { isDarkMode: boolean; + /** + * El usuario eligió modo explícitamente. + * + * Se persiste con la preferencia porque la preferencia también se persiste: sin esta marca, el + * modo que publica el sistema (`settings.theme.mode`) volvería a imponerse en cada recarga sobre + * lo que el usuario ya había decidido. + */ + chosenByUser: boolean; toggleDarkMode: () => void; + /** Aplica el modo publicado por el sistema, salvo que el usuario ya haya elegido. */ + applyDefaultDarkMode: (isDarkMode: boolean) => void; } export const useThemeStore = create()( persist( - set => ({ - isDarkMode: true, - toggleDarkMode: () => set(state => ({ isDarkMode: !state.isDarkMode })), + (set, get) => ({ + isDarkMode: false, + chosenByUser: false, + toggleDarkMode: () => set(state => ({ isDarkMode: !state.isDarkMode, chosenByUser: true })), + applyDefaultDarkMode: isDarkMode => { + if (get().chosenByUser || get().isDarkMode === isDarkMode) return; + set({ isDarkMode }); + }, }), { name: 'ums-theme' } ) diff --git a/src/apps/ums.web-app/src/application/utils/error-utils.ts b/src/apps/ums.web-app/src/application/utils/error-utils.ts index 1b8e561d..eadb4d83 100644 --- a/src/apps/ums.web-app/src/application/utils/error-utils.ts +++ b/src/apps/ums.web-app/src/application/utils/error-utils.ts @@ -1,5 +1,3 @@ -import { GraphQlValidationError, GraphQlUnavailableError } from '@infra/http/graphqlClient'; - export const HTTP_NON_RECOVERABLE_STATUSES = [400, 401, 403, 404, 422] as const; export function getHttpStatus(error: unknown): number { @@ -23,20 +21,15 @@ export function isHttpNonRecoverable(error: unknown): boolean { ); } -export function isGraphQlValidationError(error: unknown): boolean { - return error instanceof GraphQlValidationError; -} - -export function isGraphQlUnavailableError(error: unknown): boolean { - return error instanceof GraphQlUnavailableError; -} - +/** + * A network error is one where no HTTP response was received (status 0): + * the backend was unreachable, the connection failed, or the request was aborted. + */ export function isNetworkError(error: unknown): boolean { - return error instanceof GraphQlUnavailableError; + return getHttpStatus(error) === 0; } export function isNonRecoverable(error: unknown): boolean { - if (isGraphQlValidationError(error)) return true; return isHttpNonRecoverable(error); } diff --git a/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.test.ts b/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.test.ts new file mode 100644 index 00000000..76de62fb --- /dev/null +++ b/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { + AuditRecordSchema, + AuditRecordPageSchema, +} from './audit-record.schema'; + +const validRecord = { + auditRecordId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + whoActed: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + subjectType: 'UserAccount', + whenOccurred: '2024-01-01T00:00:00Z', + whatChanged: 'status: Active -> Suspended', + eventType: 'UserSuspended', + auditResult: 'Success', + affectedEntityId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', + affectedEntityType: 'UserAccount', + rootTenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa9', + metadata: '{"reason":"policy"}', +}; + +describe('AuditRecordSchema', () => { + it('acepta un registro de auditoría válido', () => { + const record = AuditRecordSchema.parse(validRecord); + expect(record.eventType).toBe('UserSuspended'); + expect(record.auditResult).toBe('Success'); + expect(record.metadata).toBe('{"reason":"policy"}'); + }); + + it('acepta metadata nula', () => { + const record = AuditRecordSchema.parse({ ...validRecord, metadata: null }); + expect(record.metadata).toBeNull(); + }); + + it('acepta metadata omitida (opcional)', () => { + const { metadata: _omitted, ...withoutMetadata } = validRecord; + const record = AuditRecordSchema.parse(withoutMetadata); + expect(record.metadata).toBeUndefined(); + }); + + it('rechaza auditRecordId no-UUID', () => { + expect(() => + AuditRecordSchema.parse({ ...validRecord, auditRecordId: 'no-es-uuid' }) + ).toThrow(); + }); + + it('rechaza whoActed no-UUID', () => { + expect(() => AuditRecordSchema.parse({ ...validRecord, whoActed: '123' })).toThrow(); + }); + + it('rechaza affectedEntityId no-UUID', () => { + expect(() => + AuditRecordSchema.parse({ ...validRecord, affectedEntityId: 'x' }) + ).toThrow(); + }); + + it('rechaza rootTenantId no-UUID', () => { + expect(() => AuditRecordSchema.parse({ ...validRecord, rootTenantId: 'x' })).toThrow(); + }); + + it('rechaza subjectType vacío', () => { + expect(() => AuditRecordSchema.parse({ ...validRecord, subjectType: '' })).toThrow(); + }); + + it('rechaza whenOccurred vacío', () => { + expect(() => AuditRecordSchema.parse({ ...validRecord, whenOccurred: '' })).toThrow(); + }); + + it('rechaza whatChanged vacío', () => { + expect(() => AuditRecordSchema.parse({ ...validRecord, whatChanged: '' })).toThrow(); + }); + + it('rechaza eventType vacío', () => { + expect(() => AuditRecordSchema.parse({ ...validRecord, eventType: '' })).toThrow(); + }); + + it('rechaza auditResult vacío', () => { + expect(() => AuditRecordSchema.parse({ ...validRecord, auditResult: '' })).toThrow(); + }); + + it('rechaza affectedEntityType vacío', () => { + expect(() => + AuditRecordSchema.parse({ ...validRecord, affectedEntityType: '' }) + ).toThrow(); + }); +}); + +describe('AuditRecordPageSchema', () => { + const validPage = { + items: [validRecord], + page: 1, + pageSize: 20, + totalItems: 1, + totalPages: 1, + }; + + it('acepta una página válida con items', () => { + const page = AuditRecordPageSchema.parse(validPage); + expect(page.items).toHaveLength(1); + expect(page.page).toBe(1); + expect(page.pageSize).toBe(20); + }); + + it('acepta una página vacía con totales en cero', () => { + const page = AuditRecordPageSchema.parse({ + items: [], + page: 1, + pageSize: 20, + totalItems: 0, + totalPages: 0, + }); + expect(page.items).toHaveLength(0); + expect(page.totalItems).toBe(0); + expect(page.totalPages).toBe(0); + }); + + it('rechaza page menor que 1', () => { + expect(() => AuditRecordPageSchema.parse({ ...validPage, page: 0 })).toThrow(); + }); + + it('rechaza pageSize menor que 1', () => { + expect(() => AuditRecordPageSchema.parse({ ...validPage, pageSize: 0 })).toThrow(); + }); + + it('rechaza totalItems negativo', () => { + expect(() => AuditRecordPageSchema.parse({ ...validPage, totalItems: -1 })).toThrow(); + }); + + it('rechaza totalPages negativo', () => { + expect(() => AuditRecordPageSchema.parse({ ...validPage, totalPages: -1 })).toThrow(); + }); + + it('rechaza page no entero', () => { + expect(() => AuditRecordPageSchema.parse({ ...validPage, page: 1.5 })).toThrow(); + }); + + it('rechaza un item con forma inválida dentro de la página', () => { + expect(() => + AuditRecordPageSchema.parse({ + ...validPage, + items: [{ ...validRecord, auditRecordId: 'invalido' }], + }) + ).toThrow(); + }); +}); diff --git a/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.ts b/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.ts index 37dfd2de..f8bab349 100644 --- a/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.ts +++ b/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.ts @@ -1,16 +1,16 @@ import { z } from 'zod'; export const AuditRecordSchema = z.object({ - auditRecordId: z.string().uuid(), - whoActed: z.string().uuid(), + auditRecordId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), + whoActed: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), subjectType: z.string().min(1), whenOccurred: z.string().min(1), whatChanged: z.string().min(1), eventType: z.string().min(1), auditResult: z.string().min(1), - affectedEntityId: z.string().uuid(), + affectedEntityId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), affectedEntityType: z.string().min(1), - rootTenantId: z.string().uuid(), + rootTenantId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), metadata: z.string().nullable().optional(), }); diff --git a/src/apps/ums.web-app/src/domain/authorization/models/permission-template.model.ts b/src/apps/ums.web-app/src/domain/authorization/models/permission-template.model.ts index c8386651..d4ca55da 100644 --- a/src/apps/ums.web-app/src/domain/authorization/models/permission-template.model.ts +++ b/src/apps/ums.web-app/src/domain/authorization/models/permission-template.model.ts @@ -11,4 +11,9 @@ export type { AddTemplateItemPayload, } from '../schemas/permission-template.schema'; -export { itemEffect } from '../schemas/permission-template.schema'; +export { + itemEffect, + itemStoredEffect, + isRetiredItem, + primaryItem, +} from '../schemas/permission-template.schema'; diff --git a/src/apps/ums.web-app/src/domain/authorization/schemas/authorization-graph.schema.ts b/src/apps/ums.web-app/src/domain/authorization/schemas/authorization-graph.schema.ts index d870d7c6..7debee48 100644 --- a/src/apps/ums.web-app/src/domain/authorization/schemas/authorization-graph.schema.ts +++ b/src/apps/ums.web-app/src/domain/authorization/schemas/authorization-graph.schema.ts @@ -10,16 +10,25 @@ export enum AccessEffect { export enum PermissionSource { Template = 'Template', - ProfileOverride = 'ProfileOverride', + Override = 'Override', } // ── Authentication ── +export const GraphIdpProviderSchema = z.object({ + id: z.string().uuid().optional(), + code: z.string(), + name: z.string(), + /** Estrategia del proveedor: AzureAd, Okta, GenericOidc… */ + value: z.string(), +}); + export const GraphAuthenticationSchema = z.object({ - sessionId: z.string().uuid(), method: z.string(), - issuedAt: z.string().datetime(), - expiresAt: z.string().datetime(), + provider: GraphIdpProviderSchema.nullable(), + mfaRequired: z.boolean(), + issuedAt: z.string().datetime({ offset: true }), + sessionExpiresAt: z.string().datetime({ offset: true }), }); export type GraphAuthentication = z.infer; @@ -27,6 +36,7 @@ export type GraphAuthentication = z.infer; // ── Context ── export const GraphUserSchema = z.object({ + id: z.string().uuid().optional(), email: z.string().email(), username: z.string(), value: z.string(), @@ -34,6 +44,7 @@ export const GraphUserSchema = z.object({ }); export const GraphTenantSchema = z.object({ + id: z.string().uuid().optional(), code: z.string(), value: z.string(), status: z.string(), @@ -41,23 +52,27 @@ export const GraphTenantSchema = z.object({ }); export const GraphSystemSuiteSchema = z.object({ + id: z.string().uuid().optional(), code: z.string(), value: z.string(), status: z.string(), }); export const GraphRoleSchema = z.object({ + id: z.string().uuid().optional(), code: z.string(), value: z.string(), hierarchyLevel: z.number().int(), }); export const GraphProfileSchema = z.object({ + id: z.string().uuid().optional(), scope: z.string(), isActive: z.boolean(), }); export const GraphBranchSchema = z.object({ + id: z.string().uuid().optional(), code: z.string(), value: z.string(), }); @@ -65,58 +80,106 @@ export const GraphBranchSchema = z.object({ export const GraphContextSchema = z.object({ user: GraphUserSchema, tenant: GraphTenantSchema, - systemSuite: GraphSystemSuiteSchema, - role: GraphRoleSchema, - profile: GraphProfileSchema, + // null en el grafo lobby (onboardingPending, G-043) + systemSuite: GraphSystemSuiteSchema.nullable(), + role: GraphRoleSchema.nullable(), + profile: GraphProfileSchema.nullable(), branch: GraphBranchSchema.nullable(), + /** + * v2.4.0 — eco del sistema que se pidió al autenticarse, o `null` si no se pidió ninguno. + * Opcional aquí para que un grafo 2.3 emitido por un servidor sin actualizar siga validando. + */ + requestedSystem: z.object({ code: z.string() }).nullable().optional(), }); export type GraphContext = z.infer; // ── Actions ── +/** + * Un perfil al que el usuario tiene acceso: rol dentro de un sistema, opcionalmente acotado a una + * sucursal. Exactamente uno lleva `isCurrent`. La lista llega ordenada por jerarquía, sistema y rol. + */ +export const GraphProfileOptionSchema = z.object({ + /** + * v2.4.0 — el servidor lo emite SIEMPRE: es la clave del cambio de perfil, no un metadato + * técnico. Se mantiene opcional en la validación para no rechazar un grafo 2.3, pero el código + * que ofrezca el selector debe tratar su ausencia como «no puedo cambiar a este perfil». + */ + id: z.string().uuid().optional(), + system: z.object({ code: z.string(), value: z.string() }), + role: z.object({ code: z.string(), value: z.string(), hierarchyLevel: z.number().int() }), + branch: z.object({ code: z.string(), value: z.string() }).nullable(), + scope: z.string(), + isCurrent: z.boolean(), +}); + +export type GraphProfileOption = z.infer; + export const GraphActionSchema = z.object({ code: z.string(), value: z.string(), - description: z.string().nullable(), }); export type GraphAction = z.infer; // ── Menu Access ── -export const GraphMenuOptionSchema = z.object({ - code: z.string(), - value: z.string(), +/** + * Acción resuelta sobre un nodo. `NotGranted` no viaja: es lo que significa la ausencia. + * `Deny` sí, porque gana sobre cualquier concesión heredada y debe pintarse como bloqueado. + */ +export const GraphNodeActionSchema = z.object({ actionCode: z.string(), effect: z.nativeEnum(AccessEffect), source: z.nativeEnum(PermissionSource), }); -export const GraphSubMenuSchema = z.object({ - code: z.string(), - value: z.string(), - sortOrder: z.number().int(), - options: z.array(GraphMenuOptionSchema), -}); +/** + * Nodo del árbol de navegación, RECURSIVO desde el contrato v2.0.0. `kind` clasifica su papel sin + * fijar su profundidad: la forma anterior, de tres niveles, perdía los nodos que no encajaban. + */ +export interface GraphNavigationNode { + id?: string; + code: string; + value: string; + kind: string; + sortOrder: number; + /** Identificador de icono; el catálogo gráfico lo elige el cliente. */ + icon: string | null; + /** Ruta relativa destino; null en los nodos que solo agrupan. */ + route: string | null; + actions: Array>; + children: GraphNavigationNode[]; +} -export const GraphMenuSchema = z.object({ - code: z.string(), - value: z.string(), - sortOrder: z.number().int(), - subMenus: z.array(GraphSubMenuSchema), -}); +export const GraphNavigationNodeSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string().uuid().optional(), + code: z.string(), + value: z.string(), + kind: z.string(), + sortOrder: z.number().int(), + icon: z.string().nullable(), + route: z.string().nullable(), + actions: z.array(GraphNodeActionSchema), + children: z.array(GraphNavigationNodeSchema), + }) +); export const GraphMenuModuleSchema = z.object({ + id: z.string().uuid().optional(), code: z.string(), value: z.string(), sortOrder: z.number().int(), status: z.string(), - menus: z.array(GraphMenuSchema), + /** v2.3.0: icono del módulo, mismo criterio que el del nodo (identificador, no recurso). */ + icon: z.string().nullable(), + nodes: z.array(GraphNavigationNodeSchema), }); export type GraphMenuModule = z.infer; -export type GraphMenuOption = z.infer; +export type GraphNodeAction = z.infer; // ── Domain Permissions ── @@ -128,9 +191,12 @@ export const GraphDomainActionSchema = z.object({ }); export const GraphDomainPermissionSchema = z.object({ + resourceId: z.string().uuid().optional(), resourceType: z.string(), resourceCode: z.string(), value: z.string(), + moduleId: z.string().uuid().nullable().optional(), + parentResourceId: z.string().uuid().nullable().optional(), actions: z.array(GraphDomainActionSchema), }); @@ -139,6 +205,7 @@ export type GraphDomainPermission = z.infer; // ── Feature Flags ── export const GraphFeatureFlagSchema = z.object({ + systemSuiteId: z.string().uuid().optional(), flagCode: z.string(), isEnabled: z.boolean(), matchedCriteriaType: z.string().nullable(), @@ -164,16 +231,31 @@ export type GraphEffectiveConfig = z.infer; export const AuthorizationGraphSchema = z.object({ schemaVersion: z.string(), + onboardingPending: z.boolean(), + /** + * v2.4.0 — estado de acceso. `NoProfileInSystem` es el caso que `onboardingPending` no sabía + * contar: el usuario tiene perfiles, pero ninguno en el sistema pedido. + * Opcional para que un grafo 2.3 de un servidor sin actualizar siga validando; por defecto se + * interpreta según `onboardingPending`. + */ + accessState: z.enum(['Granted', 'NoProfileInSystem', 'OnboardingPending']).optional(), context: GraphContextSchema, authentication: GraphAuthenticationSchema, actions: z.array(GraphActionSchema), + profiles: z.array(GraphProfileOptionSchema), menuAccess: z.array(GraphMenuModuleSchema), domainPermissions: z.array(GraphDomainPermissionSchema), featureFlags: z.array(GraphFeatureFlagSchema), effectiveConfig: GraphEffectiveConfigSchema, + /** + * Ajustes del sistema visibles para el cliente, por espacio de nombres: + * `settings.brand.logo_url`, `settings.ui.home_route`, `settings.locale.language`… + * Diccionario abierto a propósito: añadir un espacio nuevo no rompe a nadie. + */ + settings: z.record(z.string(), z.record(z.string(), z.string())), scopes: z.array(z.string()), - generatedAt: z.string().datetime(), - validUntil: z.string().datetime(), + generatedAt: z.string().datetime({ offset: true }), + validUntil: z.string().datetime({ offset: true }), }); export type AuthorizationGraph = z.infer; diff --git a/src/apps/ums.web-app/src/domain/authorization/schemas/permission-template.schema.test.ts b/src/apps/ums.web-app/src/domain/authorization/schemas/permission-template.schema.test.ts index ee280ae2..f7d413f7 100644 --- a/src/apps/ums.web-app/src/domain/authorization/schemas/permission-template.schema.test.ts +++ b/src/apps/ums.web-app/src/domain/authorization/schemas/permission-template.schema.test.ts @@ -9,6 +9,9 @@ import { PermissionTemplatePageSchema, CreatePermissionTemplateResponseSchema, itemEffect, + itemStoredEffect, + isRetiredItem, + primaryItem, } from './permission-template.schema'; describe('Permission template enum schemas', () => { @@ -137,4 +140,51 @@ describe('itemEffect', () => { it('prioritizes Allow over Deny', () => { expect(itemEffect({ isAllowed: true, isDenied: true })).toBe('Allow'); }); + + // ADR-0164: la fila retirada sobrevive con sus booleanos intactos, pero ya no concede nada. + it('deriva a Neutral el ítem retirado aunque conserve isAllowed', () => { + expect(itemEffect({ isAllowed: true, isDenied: false, isActive: false })).toBe('Neutral'); + }); + + it('deriva a Neutral el ítem retirado aunque conserve isDenied', () => { + expect(itemEffect({ isAllowed: false, isDenied: true, isActive: false })).toBe('Neutral'); + }); + + it('respeta el efecto del ítem vigente', () => { + expect(itemEffect({ isAllowed: true, isDenied: false, isActive: true })).toBe('Allow'); + }); +}); + +describe('itemStoredEffect', () => { + it('ignora el retiro y devuelve el efecto guardado en la fila', () => { + expect(itemStoredEffect({ isAllowed: true, isDenied: false })).toBe('Allow'); + expect(itemStoredEffect({ isAllowed: false, isDenied: true })).toBe('Deny'); + expect(itemStoredEffect({ isAllowed: false, isDenied: false })).toBe('Neutral'); + }); +}); + +describe('isRetiredItem', () => { + it('solo considera retirado el isActive false explícito', () => { + expect(isRetiredItem({ isActive: false })).toBe(true); + expect(isRetiredItem({ isActive: true })).toBe(false); + expect(isRetiredItem({})).toBe(false); + }); +}); + +describe('primaryItem', () => { + it('devuelve undefined cuando no hay filas', () => { + expect(primaryItem([])).toBeUndefined(); + }); + + it('prefiere la fila vigente sobre la retirada', () => { + const retirada = { itemId: 'retirada', isActive: false }; + const vigente = { itemId: 'vigente', isActive: true }; + expect(primaryItem([retirada, vigente])?.itemId).toBe('vigente'); + }); + + it('devuelve la primera retirada cuando ninguna sigue vigente, porque es la que ocupa la clave', () => { + const primera = { itemId: 'primera', isActive: false }; + const segunda = { itemId: 'segunda', isActive: false }; + expect(primaryItem([primera, segunda])?.itemId).toBe('primera'); + }); }); diff --git a/src/apps/ums.web-app/src/domain/authorization/schemas/permission-template.schema.ts b/src/apps/ums.web-app/src/domain/authorization/schemas/permission-template.schema.ts index 80abcc01..aa440d5e 100644 --- a/src/apps/ums.web-app/src/domain/authorization/schemas/permission-template.schema.ts +++ b/src/apps/ums.web-app/src/domain/authorization/schemas/permission-template.schema.ts @@ -3,7 +3,9 @@ * * PermissionTemplate lifecycle: Draft → Published → Deprecated * PermissionTemplateItem effect: isAllowed=true ✓ | isDenied=true ✗ | both false ~ - * ExclusiveArcTarget: SystemSuite | Module | Submodule | Option + * ExclusiveArcTarget: SystemSuite | Module | Submodule | Option | Aggregate | Entity + * Los dos últimos apuntan a un objeto de dominio del catálogo de la suite; el backend los + * admite desde G-192 (antes su validador copiaba a mano solo los cuatro de navegación). */ import { z } from 'zod'; @@ -91,11 +93,51 @@ export interface AddTemplateItemPayload { isDenied: boolean; } -/** Derives the human-readable effect string from an item's booleans. */ -export function itemEffect( +/** + * Efecto GUARDADO en la fila, ignorando si sigue vigente. + * + * Solo interesa en el camino de escritura: al reactivar un ítem retirado hay que saber qué efecto + * conserva para no lanzar un PUT redundante. Para pintar la pantalla se usa `itemEffect`. + */ +export function itemStoredEffect( item: Pick ): PermissionEffect { if (item.isAllowed) return 'Allow'; if (item.isDenied) return 'Deny'; return 'Neutral'; } + +/** + * Un ítem retirado sobrevive en la tabla con `isActive: false` (ADR-0164: solo hay borrado lógico). + * + * La ausencia del campo se interpreta como vigente: hay llamadores que derivan el efecto de un + * objeto parcial (`{ isAllowed, isDenied }`) y ahí no hay retiro que valga. + */ +export function isRetiredItem(item: Partial>): boolean { + return item.isActive === false; +} + +/** + * Efecto EFECTIVO de la concesión: lo que el ítem realmente concede hoy. + * + * Un ítem retirado no concede nada —el backend es fail-closed—, así que se deriva a `Neutral` + * aunque conserve `isAllowed: true`. Mirar solo los booleanos hacía que la pantalla pintara como + * `Allow` una fila que ya no autoriza nada. + */ +export function itemEffect( + item: Pick & + Partial> +): PermissionEffect { + if (isRetiredItem(item)) return 'Neutral'; + return itemStoredEffect(item); +} + +/** + * De varias filas sobre el mismo objetivo, la que manda para editar es la vigente; si todas están + * retiradas, la primera (que es la que ocupa la clave y habrá que reactivar). + */ +export function primaryItem>>( + items: readonly T[] +): T | undefined { + return items.find(item => !isRetiredItem(item)) ?? items[0]; +} diff --git a/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.commands.schema.ts b/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.commands.schema.ts index 79ec166c..ef19e3c0 100644 --- a/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.commands.schema.ts +++ b/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.commands.schema.ts @@ -2,7 +2,7 @@ * system-suite.commands.schema.ts * * Zod schemas for REST command payloads (write DTOs). - * These are separate from system-suite.schema.ts which holds read/GraphQL response shapes. + * These are separate from system-suite.schema.ts which holds read response shapes. */ import { z } from 'zod'; @@ -56,8 +56,8 @@ export const RegisterActionCommandSchema = z.object({ export const AddDomainResourceCommandSchema = z .object({ - moduleId: z.string().uuid().nullable().optional(), - parentResourceId: z.string().uuid().nullable().optional(), + moduleId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), + parentResourceId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), type: z.enum(['Aggregate', 'Entity', 'DomainMethod']), code: z .string() diff --git a/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.schema.test.ts b/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.schema.test.ts index 9084230b..9458fce1 100644 --- a/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.schema.test.ts +++ b/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.schema.test.ts @@ -6,7 +6,7 @@ import { CreateSystemSuitePayloadSchema, CreateSystemSuiteResponseSchema, SystemSuiteActionSchema, - SystemSuiteOptionSchema, + SystemSuiteNodeSchema, SystemSuiteModuleSchema, SystemSuiteDomainResourceSchema, } from './system-suite.schema'; @@ -34,22 +34,60 @@ describe('SystemSuiteActionSchema', () => { }); }); -describe('SystemSuiteOptionSchema', () => { - it('accepts a valid option', () => { - const option = SystemSuiteOptionSchema.parse({ +describe('SystemSuiteNodeSchema', () => { + it('accepts a valid leaf node with N:M action codes', () => { + const node = SystemSuiteNodeSchema.parse({ id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + parentNodeId: null, + kind: 'Option', code: 'OPT1', label: 'Option 1', description: 'Test option', - actionCode: 'CREATE', + status: 'Active', sortOrder: 1, + actionCodes: ['CREATE', 'VIEW'], + metadata: null, + children: [], }); - expect(option.label).toBe('Option 1'); + expect(node.label).toBe('Option 1'); + expect(node.actionCodes).toEqual(['CREATE', 'VIEW']); + }); + + it('accepts a recursive branch node with children and SDLC metadata', () => { + const node = SystemSuiteNodeSchema.parse({ + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + parentNodeId: null, + kind: 'Menu', + code: 'MENU1', + label: 'Menu 1', + description: 'Branch', + status: 'Active', + sortOrder: 1, + actionCodes: [], + metadata: { responsable: 'Equipo A', criticidad: 'Alta' }, + children: [ + { + id: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + parentNodeId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + kind: 'Option', + code: 'CHILD', + label: 'Child', + description: '', + status: 'Active', + sortOrder: 1, + actionCodes: [], + metadata: null, + children: [], + }, + ], + }); + expect(node.children).toHaveLength(1); + expect(node.metadata?.responsable).toBe('Equipo A'); }); }); describe('SystemSuiteModuleSchema', () => { - it('accepts a valid module', () => { + it('accepts a valid module with a node tree', () => { const module = SystemSuiteModuleSchema.parse({ id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', code: 'MOD1', @@ -57,9 +95,10 @@ describe('SystemSuiteModuleSchema', () => { description: 'Test module', status: 'Active', sortOrder: 1, - menus: [], + nodes: [], }); expect(module.name).toBe('Module 1'); + expect(module.nodes).toEqual([]); }); }); @@ -165,23 +204,40 @@ describe('SystemSuitePageSchema', () => { }); describe('CreateSystemSuitePayloadSchema', () => { - it('accepts a valid payload', () => { + it('accepts a valid payload with description', () => { const payload = CreateSystemSuitePayloadSchema.parse({ tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', code: 'NEW', name: 'New System', + description: 'A new system suite', }); expect(payload.code).toBe('NEW'); + expect(payload.description).toBe('A new system suite'); }); - it('accepts optional description', () => { - const payload = CreateSystemSuitePayloadSchema.parse({ + // Contrato: el backend exige `Description.NotEmpty()` (G-107). La descripción es + // requerida en el payload del frontend para no llegar a un 400. + it('rejects a missing description', () => { + const result = CreateSystemSuitePayloadSchema.safeParse({ tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', code: 'NEW', name: 'New System', - description: 'A new system suite', }); - expect(payload.description).toBe('A new system suite'); + expect(result.success).toBe(false); + }); + + it('rejects an empty description with the required message', () => { + const result = CreateSystemSuitePayloadSchema.safeParse({ + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + code: 'NEW', + name: 'New System', + description: '', + }); + expect(result.success).toBe(false); + if (!result.success) { + const descIssue = result.error.issues.find(issue => issue.path[0] === 'description'); + expect(descIssue?.message).toBe('Descripción requerida'); + } }); it('rejects code exceeding max length', () => { @@ -190,6 +246,7 @@ describe('CreateSystemSuitePayloadSchema', () => { tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', code: 'A'.repeat(51), name: 'New System', + description: 'A new system suite', }) ).toThrow(); }); diff --git a/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.schema.ts b/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.schema.ts index aa258e3a..cc88eb7e 100644 --- a/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.schema.ts +++ b/src/apps/ums.web-app/src/domain/authorization/schemas/system-suite.schema.ts @@ -16,50 +16,66 @@ const GuidSchema = z .regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/); export const SystemSuiteActionSchema = z.object({ - id: z.string().uuid(), + id: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string(), name: z.string(), }); -export const SystemSuiteOptionSchema = z.object({ - id: z.string().uuid(), - code: z.string(), - label: z.string(), - description: z.string(), - actionCode: z.string(), - sortOrder: z.number(), +// ── Árbol de nodos recursivo (ADR-0090) ── +export const SystemSuiteNodeMetadataSchema = z.object({ + responsable: z.string().nullable().optional(), + criticidad: z.string().nullable().optional(), + productoImpactado: z.string().nullable().optional(), + componenteTecnico: z.string().nullable().optional(), + dependencias: z.string().nullable().optional(), + evidencias: z.string().nullable().optional(), + trazabilidadSdlc: z.string().nullable().optional(), }); -export const SystemSuiteSubMenuSchema = z.object({ - id: z.string().uuid(), - code: z.string(), - label: z.string(), - description: z.string(), - sortOrder: z.number(), - options: z.array(SystemSuiteOptionSchema), -}); - -export const SystemSuiteMenuSchema = z.object({ - id: z.string().uuid(), - code: z.string(), - label: z.string(), - description: z.string(), - sortOrder: z.number(), - subMenus: z.array(SystemSuiteSubMenuSchema), -}); +export type SystemSuiteNodeMetadata = z.infer; + +export type SystemSuiteNode = { + id: string; + parentNodeId: string | null; + kind: string; + code: string; + label: string; + description: string; + status: string; + sortOrder: number; + actionCodes: string[]; + metadata: SystemSuiteNodeMetadata | null; + children: SystemSuiteNode[]; +}; + +export const SystemSuiteNodeSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), + parentNodeId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable(), + kind: z.string(), + code: z.string(), + label: z.string(), + description: z.string(), + status: z.string(), + sortOrder: z.number(), + actionCodes: z.array(z.string()).default([]), + metadata: SystemSuiteNodeMetadataSchema.nullable().optional(), + children: z.array(SystemSuiteNodeSchema).default([]), + }) +); export const SystemSuiteModuleSchema = z.object({ - id: z.string().uuid(), + id: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string(), name: z.string(), description: z.string(), status: z.string(), sortOrder: z.number(), - menus: z.array(SystemSuiteMenuSchema), + nodes: z.array(SystemSuiteNodeSchema).optional().default([]), }); export const SystemSuiteCrudOperationSchema = z.object({ - id: z.string().uuid(), + id: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string(), name: z.string(), description: z.string(), @@ -67,7 +83,7 @@ export const SystemSuiteCrudOperationSchema = z.object({ }); export const SystemSuiteCustomActionSchema = z.object({ - id: z.string().uuid(), + id: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string(), name: z.string(), description: z.string(), @@ -75,9 +91,9 @@ export const SystemSuiteCustomActionSchema = z.object({ }); export const SystemSuiteDomainResourceSchema = z.object({ - id: z.string().uuid(), - moduleId: z.string().uuid().nullable().optional(), - parentResourceId: z.string().uuid().nullable().optional(), + id: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), + moduleId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), + parentResourceId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), type: z.enum(['Aggregate', 'Entity', 'DomainMethod']), code: z.string(), name: z.string(), @@ -109,14 +125,24 @@ export const SystemSuitePageSchema = z.object({ }); export const CreateSystemSuitePayloadSchema = z.object({ - tenantId: z.string().uuid(), + // `GuidSchema` (regex laxo), NO `z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)`: Zod v4 exige los bits de + // variante RFC 4122 en `.uuid()`, demasiado estricto para los GUID de SQL Server + // (p. ej. el tenant sembrado `5f4e3d2c-…`). Con `.uuid()` el `validate()` del + // formulario devolvía null y bloqueaba silenciosamente la creación (sin request + // ni toast). Se alinea con `permission-template.schema.ts` y `profile.schema.ts`, + // que ya usan el mismo regex laxo por esta razón. + tenantId: GuidSchema, code: z.string().min(1).max(50), name: z.string().min(1).max(120), - description: z.string().max(500).optional(), + // Requerida: el backend `CreateSystemSuiteCommandValidator` exige + // `Description.NotEmpty().MaximumLength(500)`. Un POST sin descripción devolvía + // 400. El frontend se alinea al contrato (fuente de verdad = backend) para que + // el usuario reciba la validación antes de la llamada. Ver G-107. + description: z.string().min(1, 'Descripción requerida').max(500, 'Máximo 500 caracteres'), }); export const CreateSystemSuiteResponseSchema = z.object({ - systemSuiteId: z.string().uuid(), + systemSuiteId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), }); export type SystemSuite = z.infer; diff --git a/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.commands.schema.test.ts b/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.commands.schema.test.ts new file mode 100644 index 00000000..77d20d5e --- /dev/null +++ b/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.commands.schema.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + CreateAppConfigurationPayloadSchema, + UpdateAppConfigurationPayloadSchema, +} from './app-configuration.commands.schema'; + +describe('CreateAppConfigurationPayloadSchema', () => { + it('acepta un payload mínimo y aplica valores por defecto', () => { + const parsed = CreateAppConfigurationPayloadSchema.parse({ + code: 'FEATURE.LIMIT', + value: '42', + }); + expect(parsed.description).toBe(''); + expect(parsed.isInheritable).toBe(false); + expect(parsed.isEncrypted).toBe(false); + }); + + it('acepta identificadores opcionales como UUID', () => { + const parsed = CreateAppConfigurationPayloadSchema.parse({ + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + systemSuiteId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + moduleId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', + code: 'FEATURE.LIMIT', + value: '42', + description: 'desc', + isInheritable: true, + isEncrypted: true, + }); + expect(parsed.tenantId).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa6'); + expect(parsed.isInheritable).toBe(true); + }); + + it('acepta tenantId nulo', () => { + const parsed = CreateAppConfigurationPayloadSchema.parse({ + tenantId: null, + code: 'FEATURE.LIMIT', + value: '42', + }); + expect(parsed.tenantId).toBeNull(); + }); + + it('rechaza code vacío', () => { + expect(() => CreateAppConfigurationPayloadSchema.parse({ code: '', value: '42' })).toThrow(); + }); + + it('rechaza tenantId con formato no-UUID', () => { + expect(() => + CreateAppConfigurationPayloadSchema.parse({ + tenantId: 'no-uuid', + code: 'FEATURE.LIMIT', + value: '42', + }) + ).toThrow(); + }); + + it('rechaza description que excede 500 caracteres', () => { + expect(() => + CreateAppConfigurationPayloadSchema.parse({ + code: 'FEATURE.LIMIT', + value: '42', + description: 'x'.repeat(501), + }) + ).toThrow(); + }); +}); + +describe('UpdateAppConfigurationPayloadSchema', () => { + it('acepta un payload de actualización válido', () => { + const parsed = UpdateAppConfigurationPayloadSchema.parse({ + value: '99', + description: 'nueva descripción', + }); + expect(parsed.value).toBe('99'); + }); + + it('acepta description omitida', () => { + const parsed = UpdateAppConfigurationPayloadSchema.parse({ value: '99' }); + expect(parsed.description).toBeUndefined(); + }); + + it('rechaza value ausente', () => { + expect(() => UpdateAppConfigurationPayloadSchema.parse({ description: 'x' })).toThrow(); + }); + + it('rechaza description que excede 500 caracteres', () => { + expect(() => + UpdateAppConfigurationPayloadSchema.parse({ value: '1', description: 'x'.repeat(501) }) + ).toThrow(); + }); +}); diff --git a/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.commands.schema.ts b/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.commands.schema.ts index 55bbb2c7..8e887358 100644 --- a/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.commands.schema.ts +++ b/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.commands.schema.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; export const CreateAppConfigurationPayloadSchema = z.object({ - tenantId: z.string().uuid().nullable().optional(), - systemSuiteId: z.string().uuid().nullable().optional(), - moduleId: z.string().uuid().nullable().optional(), + tenantId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), + systemSuiteId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), + moduleId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), code: z.string().min(1).max(100), value: z.string(), description: z.string().max(500).optional().default(''), diff --git a/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.schema.test.ts b/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.schema.test.ts new file mode 100644 index 00000000..14150ede --- /dev/null +++ b/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.schema.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + AppConfigurationSchema, + AppConfigurationPageSchema, + CreateAppConfigurationResponseSchema, +} from './app-configuration.schema'; + +const validConfig = { + appConfigurationId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + tenantId: null, + systemSuiteId: null, + moduleId: null, + code: 'FEATURE.LIMIT', + value: '42', + description: 'Límite máximo permitido', + scope: 'Global', + isInheritable: true, + isEncrypted: false, + version: '1.0.0', + status: 'Published', + rowVersion: null, +}; + +describe('AppConfigurationSchema', () => { + it('acepta una configuración válida', () => { + const parsed = AppConfigurationSchema.parse(validConfig); + expect(parsed.code).toBe('FEATURE.LIMIT'); + expect(parsed.scope).toBe('Global'); + expect(parsed.status).toBe('Published'); + }); + + it('acepta rowVersion omitido (opcional)', () => { + const withoutRowVersion: Record = { ...validConfig }; + delete withoutRowVersion.rowVersion; + const parsed = AppConfigurationSchema.parse(withoutRowVersion); + expect(parsed.rowVersion).toBeUndefined(); + }); + + it('rechaza appConfigurationId no-UUID', () => { + expect(() => + AppConfigurationSchema.parse({ ...validConfig, appConfigurationId: 'no-uuid' }) + ).toThrow(); + }); + + it('rechaza code vacío', () => { + expect(() => AppConfigurationSchema.parse({ ...validConfig, code: '' })).toThrow(); + }); + + it('rechaza code que excede 100 caracteres', () => { + expect(() => AppConfigurationSchema.parse({ ...validConfig, code: 'x'.repeat(101) })).toThrow(); + }); + + it('rechaza scope fuera del enum', () => { + expect(() => AppConfigurationSchema.parse({ ...validConfig, scope: 'Regional' })).toThrow(); + }); + + it('rechaza status fuera del enum', () => { + expect(() => AppConfigurationSchema.parse({ ...validConfig, status: 'Deleted' })).toThrow(); + }); + + it('rechaza version que no cumple el patrón semver X.Y.Z', () => { + expect(() => AppConfigurationSchema.parse({ ...validConfig, version: '1.0' })).toThrow(); + }); + + it('rechaza description que excede 500 caracteres', () => { + expect(() => + AppConfigurationSchema.parse({ ...validConfig, description: 'x'.repeat(501) }) + ).toThrow(); + }); +}); + +describe('AppConfigurationPageSchema', () => { + const validPage = { + items: [validConfig], + page: 1, + pageSize: 20, + totalItems: 1, + totalPages: 1, + }; + + it('acepta una página válida', () => { + const parsed = AppConfigurationPageSchema.parse(validPage); + expect(parsed.items).toHaveLength(1); + expect(parsed.page).toBe(1); + }); + + it('acepta totalItems y totalPages en cero', () => { + const parsed = AppConfigurationPageSchema.parse({ + ...validPage, + items: [], + totalItems: 0, + totalPages: 0, + }); + expect(parsed.totalItems).toBe(0); + expect(parsed.totalPages).toBe(0); + }); + + it('rechaza page no positivo', () => { + expect(() => AppConfigurationPageSchema.parse({ ...validPage, page: 0 })).toThrow(); + }); + + it('rechaza totalItems negativo', () => { + expect(() => AppConfigurationPageSchema.parse({ ...validPage, totalItems: -1 })).toThrow(); + }); +}); + +describe('CreateAppConfigurationResponseSchema', () => { + it('acepta una respuesta de creación válida', () => { + const parsed = CreateAppConfigurationResponseSchema.parse({ + appConfigurationId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + code: 'FEATURE.LIMIT', + }); + expect(parsed.code).toBe('FEATURE.LIMIT'); + }); + + it('rechaza appConfigurationId no-UUID', () => { + expect(() => + CreateAppConfigurationResponseSchema.parse({ + appConfigurationId: 'x', + code: 'FEATURE.LIMIT', + }) + ).toThrow(); + }); +}); diff --git a/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.schema.ts b/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.schema.ts index a96757bd..1ba171d4 100644 --- a/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.schema.ts +++ b/src/apps/ums.web-app/src/domain/configuration/schemas/app-configuration.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const AppConfigurationSchema = z.object({ - appConfigurationId: z.string().uuid(), + appConfigurationId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), tenantId: z.string().nullable(), systemSuiteId: z.string().nullable(), moduleId: z.string().nullable(), @@ -29,8 +29,10 @@ export const AppConfigurationPageSchema = z.object({ export type AppConfigurationPage = z.infer; export const CreateAppConfigurationResponseSchema = z.object({ - appConfigurationId: z.string().uuid(), - code: z.string(), + appConfigurationId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), + // El backend (`CreateAppConfigurationResponse`) responde 201 con SOLO el id; exigir `code` + // hacía fallar el parseo del alta y disparaba un toast de error pese al 201. (G-147) + code: z.string().optional(), }); export type CreateAppConfigurationResponse = z.infer; diff --git a/src/apps/ums.web-app/src/domain/configuration/schemas/feature-flag.commands.schema.ts b/src/apps/ums.web-app/src/domain/configuration/schemas/feature-flag.commands.schema.ts index a08e5349..e108474a 100644 --- a/src/apps/ums.web-app/src/domain/configuration/schemas/feature-flag.commands.schema.ts +++ b/src/apps/ums.web-app/src/domain/configuration/schemas/feature-flag.commands.schema.ts @@ -7,8 +7,8 @@ import { z } from 'zod'; import { FlagTypeSchema, CriteriaTypeSchema, CriteriaOperatorSchema } from './feature-flag.schema'; export const CreateFeatureFlagPayloadSchema = z.object({ - systemSuiteId: z.string().uuid(), - tenantId: z.string().uuid().nullable().optional(), + systemSuiteId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), + tenantId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), flagCode: z .string() .min(1) diff --git a/src/apps/ums.web-app/src/domain/identity/constants/idp.constants.test.ts b/src/apps/ums.web-app/src/domain/identity/constants/idp.constants.test.ts index ab369c9e..18802ab4 100644 --- a/src/apps/ums.web-app/src/domain/identity/constants/idp.constants.test.ts +++ b/src/apps/ums.web-app/src/domain/identity/constants/idp.constants.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect } from 'vitest'; import { IDP_STRATEGIES } from '@domain/identity/constants/idp.constants'; describe('idp.constants', () => { - it('exports correct strategies', () => { - expect(IDP_STRATEGIES).toContain('OIDC'); - expect(IDP_STRATEGIES).toContain('SAML2'); - expect(IDP_STRATEGIES).toContain('OAuth2'); - expect(IDP_STRATEGIES).toHaveLength(3); + it('expone estrategias que coinciden con el enum IdpStrategy del backend (G-155)', () => { + // Deben ser nombres EXACTOS del enum backend; OIDC/OAuth2 no existían y daban 400. + expect(IDP_STRATEGIES).toContain('GenericOidc'); + expect(IDP_STRATEGIES).toContain('Saml2'); + expect(IDP_STRATEGIES).toContain('AzureAd'); + expect(IDP_STRATEGIES).not.toContain('OIDC'); + expect(IDP_STRATEGIES).not.toContain('OAuth2'); }); }); diff --git a/src/apps/ums.web-app/src/domain/identity/constants/idp.constants.ts b/src/apps/ums.web-app/src/domain/identity/constants/idp.constants.ts index c0a07d2e..3a75dc0f 100644 --- a/src/apps/ums.web-app/src/domain/identity/constants/idp.constants.ts +++ b/src/apps/ums.web-app/src/domain/identity/constants/idp.constants.ts @@ -5,6 +5,21 @@ * panels so changes propagate from one place. */ -/** Valid identity provider strategy values. */ -export const IDP_STRATEGIES = ['OIDC', 'SAML2', 'OAuth2'] as const; +/** + * Estrategias de proveedor de identidad EXTERNO que acepta el backend (enum `IdpStrategy`, + * DomainEnums.cs). Los valores deben coincidir EXACTO con los nombres del enum o el alta da 400 + * («Invalid identity provider strategy»). Se omite `InternalBcrypt` (auth local, no es un proveedor + * externo registrable). (G-155) + */ +export const IDP_STRATEGIES = [ + 'GenericOidc', + 'Saml2', + 'AzureAd', + 'Okta', + 'Keycloak', + 'Auth0', + 'Google', + 'Zitadel', + 'Ldap', +] as const; export type IdpStrategy = (typeof IDP_STRATEGIES)[number]; diff --git a/src/apps/ums.web-app/src/domain/identity/constants/tenant.constants.ts b/src/apps/ums.web-app/src/domain/identity/constants/tenant.constants.ts index 7b2bd26b..65a48838 100644 --- a/src/apps/ums.web-app/src/domain/identity/constants/tenant.constants.ts +++ b/src/apps/ums.web-app/src/domain/identity/constants/tenant.constants.ts @@ -20,27 +20,81 @@ export interface DevTenant { } export const DEV_TENANTS: DevTenant[] = [ + // ADR-0071 / FS-26: BEYONDNET es el Tenant Raíz / Admin Root del ecosistema. + { + id: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', + code: 'BEYONDNET', + name: 'BeyondNet S.A.C. — Tenant Raíz / Admin Root', + }, { id: '11111111-1111-1111-1111-111111111111', code: 'INTERNAL_ADMIN', - name: 'Internal Admin (SuperAdmin)', + name: 'Internal Admin (break-glass / recuperación)', + }, + { + id: 'c0e1a000-1111-4c0e-a000-000000000001', + code: 'COMEX_ANDINA', + name: 'Comercializadora Andina S.A.C. (cliente)', + }, + { + id: 'c0e1b000-1111-4c0e-b000-000000000011', + code: 'IMPO_ANDINA_SUB', + name: 'Importadora Sub-Cliente (cliente del cliente)', + }, +]; + +/** Persona de acceso sembrada (seed FS-25). Usada por el popup "Datos de prueba". */ +export interface DevPersona { + label: string; + tenantCode: string; + email: string; + password: string; +} + +/** Personas de prueba materializadas por el seed de BEYONDNET (contraseña dev uniforme). */ +export const DEV_PERSONAS: DevPersona[] = [ + { + // ADR-0071 / FS-26: BEYONDNET es el Admin Root — gobierna el ecosistema y administra su propia casa. + label: 'BEYONDNET — Admin Root (plataforma + su organización)', + tenantCode: 'BEYONDNET', + email: 'admin@beyondnet.com.pe', + password: 'BeyondNet.Dev.2026', + }, + { + label: 'BEYONDNET — Callao · Jefe de Almacén', + tenantCode: 'BEYONDNET', + email: 'jefe.almacen.callao@beyondnet.com.pe', + password: 'BeyondNet.Dev.2026', + }, + { + label: 'BEYONDNET — Callao · Agente de Aduanas', + tenantCode: 'BEYONDNET', + email: 'agente.aduanas.callao@beyondnet.com.pe', + password: 'BeyondNet.Dev.2026', + }, + { + label: 'BEYONDNET — Paita · Jefe de Almacén', + tenantCode: 'BEYONDNET', + email: 'jefe.almacen.paita@beyondnet.com.pe', + password: 'BeyondNet.Dev.2026', }, - { id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', code: 'RANSA_PERU', name: 'Ransa Comercial S.A.' }, - { id: 'c9b736b4-6a84-48f8-b34d-176bc5a6d542', code: 'NEPTUNIA', name: 'Neptunia S.A. — Callao' }, { - id: 'a3f5b9d2-7c3d-4c8e-a9b0-123456789abc', - code: 'APM_CALLAO', - name: 'APM Terminals Callao S.A.', + label: 'Cliente externo (Comex Andina)', + tenantCode: 'COMEX_ANDINA', + email: 'usuario.impo@comexandina.com.pe', + password: 'BeyondNet.Dev.2026', }, { - id: '9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe', - code: 'PAITA_PORT', - name: 'Terminal Portuario de Paita S.A.', + label: 'Cliente de mi cliente (sub-cliente)', + tenantCode: 'IMPO_ANDINA_SUB', + email: 'usuario@impo-subcliente.com.pe', + password: 'BeyondNet.Dev.2026', }, - { id: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', code: 'UNIMAR', name: 'Unimar S.A. — Lima' }, { - id: 'f3e2d1c0-b9a8-7f6e-5d4c-321098765432', - code: 'INTRADEVCO', - name: 'Intradevco Industrial S.A.', + // ADR-0071 / FS-26: cuenta de emergencia (break-glass), degradada — sin gobierno global. + label: 'Plataforma — break-glass (recuperación)', + tenantCode: 'INTERNAL_ADMIN', + email: 'admin@ums.local', + password: 'root', }, ]; diff --git a/src/apps/ums.web-app/src/domain/identity/models/branch.model.ts b/src/apps/ums.web-app/src/domain/identity/models/branch.model.ts index 31480a8b..c210aea6 100644 --- a/src/apps/ums.web-app/src/domain/identity/models/branch.model.ts +++ b/src/apps/ums.web-app/src/domain/identity/models/branch.model.ts @@ -1,4 +1,17 @@ /** * branch.model.ts — re-exports from Zod schema (single source of truth) */ -export type { Branch, AddBranchPayload, AddBranchResponse } from '../schemas/branch.schema'; +export type { + Branch, + AddBranchPayload, + AddBranchResponse, + BranchLifecycleEntry, + BranchState, +} from '../schemas/branch.schema'; + +export { + branchState, + canReactivateBranch, + canDeactivateBranch, + canCloseBranch, +} from '../schemas/branch.schema'; diff --git a/src/apps/ums.web-app/src/domain/identity/models/branding.model.test.ts b/src/apps/ums.web-app/src/domain/identity/models/branding.model.test.ts deleted file mode 100644 index 1a6813ef..00000000 --- a/src/apps/ums.web-app/src/domain/identity/models/branding.model.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { DEFAULT_BRANDING, type BrandingConfig } from './branding.model'; - -describe('branding.model', () => { - it('exports DEFAULT_BRANDING with correct shape', () => { - expect(DEFAULT_BRANDING).toBeDefined(); - expect(DEFAULT_BRANDING.headlineText).toBe(''); - expect(DEFAULT_BRANDING.secondaryText).toBe(''); - expect(DEFAULT_BRANDING.primaryButtonLabel).toBe(''); - expect(DEFAULT_BRANDING.footerText).toBe(''); - expect(DEFAULT_BRANDING.primaryColor).toBe('#3b5bdb'); - expect(DEFAULT_BRANDING.backgroundStyle).toBe('solid'); - expect(DEFAULT_BRANDING.logo).toBe(''); - expect(DEFAULT_BRANDING.logoFormat).toBe('png'); - expect(DEFAULT_BRANDING.customDomain).toBe(''); - expect(DEFAULT_BRANDING.magicLinkFallbackEnabled).toBe(false); - expect(DEFAULT_BRANDING.dnsVerificationStatus).toBe('Pending'); - }); - - it('DEFAULT_BRANDING satisfies BrandingConfig interface', () => { - const config: BrandingConfig = DEFAULT_BRANDING; - expect(config).toHaveProperty('headlineText'); - expect(config).toHaveProperty('primaryColor'); - expect(config).toHaveProperty('dnsVerificationStatus'); - }); -}); diff --git a/src/apps/ums.web-app/src/domain/identity/models/branding.model.ts b/src/apps/ums.web-app/src/domain/identity/models/branding.model.ts deleted file mode 100644 index 6db9591f..00000000 --- a/src/apps/ums.web-app/src/domain/identity/models/branding.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface BrandingConfig { - headlineText: string; - secondaryText: string; - primaryButtonLabel: string; - footerText: string; - primaryColor: string; - backgroundStyle: string; - logo: string; - logoFormat: string; - customDomain: string; - magicLinkFallbackEnabled: boolean; - dnsVerificationStatus: string; -} - -export const DEFAULT_BRANDING: BrandingConfig = { - headlineText: '', - secondaryText: '', - primaryButtonLabel: '', - footerText: '', - primaryColor: '#3b5bdb', - backgroundStyle: 'solid', - logo: '', - logoFormat: 'png', - customDomain: '', - magicLinkFallbackEnabled: false, - dnsVerificationStatus: 'Pending', -}; - -export interface UpdateBrandingPayload { - tenantId: string; - config: Omit; -} diff --git a/src/apps/ums.web-app/src/domain/identity/models/tenant.schema.ts b/src/apps/ums.web-app/src/domain/identity/models/tenant.schema.ts index cd6eb396..81ecdda5 100644 --- a/src/apps/ums.web-app/src/domain/identity/models/tenant.schema.ts +++ b/src/apps/ums.web-app/src/domain/identity/models/tenant.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const TenantSchema = z.object({ - tenantId: z.string().uuid(), + tenantId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string().min(1).max(50), name: z.string().min(1).max(200), type: z.enum(['INTERNAL', 'SUPPLIER', 'CLIENT']), @@ -12,13 +12,13 @@ export const TenantSchema = z.object({ }); export const CreateTenantResponseSchema = z.object({ - tenantId: z.string().uuid(), + tenantId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string().min(1).max(50), name: z.string().min(1).max(200), }); export const BranchSchema = z.object({ - branchId: z.string().uuid(), + branchId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string().min(1).max(50), name: z.string().min(1).max(200), isActive: z.boolean(), @@ -26,7 +26,7 @@ export const BranchSchema = z.object({ }); export const AddBranchResponseSchema = z.object({ - branchId: z.string().uuid(), + branchId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string().min(1).max(50), }); diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/branch.schema.test.ts b/src/apps/ums.web-app/src/domain/identity/schemas/branch.schema.test.ts index bf01ead8..08a123ac 100644 --- a/src/apps/ums.web-app/src/domain/identity/schemas/branch.schema.test.ts +++ b/src/apps/ums.web-app/src/domain/identity/schemas/branch.schema.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest'; import { BranchSchema, BranchListSchema, + BranchLifecycleSchema, AddBranchPayloadSchema, AddBranchResponseSchema, + branchState, + canReactivateBranch, + canDeactivateBranch, + canCloseBranch, } from './branch.schema'; describe('BranchSchema', () => { @@ -78,6 +83,109 @@ describe('BranchSchema', () => { }); }); +// ─── ADR-0164: cierre definitivo frente a desactivación reversible ─────────── + +describe('BranchSchema · cierre definitivo', () => { + const closed = { + branchId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + code: 'BR009', + name: 'Sucursal Callao', + isActive: false, + isClosed: true, + closedAtUtc: '2026-07-31T15:04:05Z', + }; + + it('conserva la marca y la fecha de cierre', () => { + const branch = BranchSchema.parse(closed); + + expect(branch.isClosed).toBe(true); + expect(branch.closedAtUtc).toBe('2026-07-31T15:04:05Z'); + }); + + it('trata como abierta la sucursal que llega sin la marca', () => { + const branch = BranchSchema.parse({ + branchId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + code: 'BR010', + name: 'Sucursal Lima', + isActive: true, + }); + + expect(branch.isClosed).toBe(false); + expect(branch.closedAtUtc).toBeNull(); + }); +}); + +describe('branchState', () => { + it('distingue activa, pausada y cerrada', () => { + expect(branchState({ isActive: true, isClosed: false })).toBe('Active'); + expect(branchState({ isActive: false, isClosed: false })).toBe('Inactive'); + expect(branchState({ isActive: false, isClosed: true })).toBe('Closed'); + }); + + it('el cierre manda sobre la actividad: una cerrada nunca se pinta activa', () => { + expect(branchState({ isActive: true, isClosed: true })).toBe('Closed'); + }); +}); + +describe('transiciones permitidas', () => { + const activa = { isActive: true, isClosed: false }; + const pausada = { isActive: false, isClosed: false }; + const cerrada = { isActive: false, isClosed: true }; + + it('solo la pausada se puede reactivar: el cierre es irreversible', () => { + expect(canReactivateBranch(pausada)).toBe(true); + expect(canReactivateBranch(activa)).toBe(false); + expect(canReactivateBranch(cerrada)).toBe(false); + }); + + it('solo la activa se puede desactivar', () => { + expect(canDeactivateBranch(activa)).toBe(true); + expect(canDeactivateBranch(pausada)).toBe(false); + expect(canDeactivateBranch(cerrada)).toBe(false); + }); + + it('lo ya cerrado no se vuelve a cerrar', () => { + expect(canCloseBranch(activa)).toBe(true); + expect(canCloseBranch(pausada)).toBe(true); + expect(canCloseBranch(cerrada)).toBe(false); + }); +}); + +describe('BranchLifecycleSchema', () => { + it('acepta los asientos de la bitácora', () => { + const entries = BranchLifecycleSchema.parse([ + { + entryId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + episode: 'Opened', + occurredAtUtc: '2026-01-02T10:00:00Z', + actorId: 'admin@beyondnet.com.pe', + nameSnapshot: 'Sucursal Callao', + geofencingSnapshot: '-12.0464,-77.0428;r=800', + reason: null, + }, + ]); + + expect(entries[0].episode).toBe('Opened'); + expect(entries[0].reason).toBeNull(); + }); + + // Si el backend añade un episodio, la bitácora debe seguir viéndose: es justo cuando se audita. + it('no rechaza un episodio que todavía no conoce', () => { + const entries = BranchLifecycleSchema.parse([ + { + entryId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + episode: 'Relocated', + occurredAtUtc: '2026-03-02T10:00:00Z', + actorId: 'admin@beyondnet.com.pe', + nameSnapshot: 'Sucursal Callao', + }, + ]); + + expect(entries[0].episode).toBe('Relocated'); + expect(entries[0].geofencingSnapshot).toBeNull(); + }); +}); + describe('BranchListSchema', () => { it('accepts an array of valid branches', () => { const branches = BranchListSchema.parse([ diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/branch.schema.ts b/src/apps/ums.web-app/src/domain/identity/schemas/branch.schema.ts index 19480cda..26a1358e 100644 --- a/src/apps/ums.web-app/src/domain/identity/schemas/branch.schema.ts +++ b/src/apps/ums.web-app/src/domain/identity/schemas/branch.schema.ts @@ -1,7 +1,24 @@ +/** + * branch.schema.ts — contrato de sucursal validado en frontera (Zod). + * + * ADR-0164: la baja de una sucursal dejó de ser un borrado. Ahora hay DOS verbos distintos y la + * diferencia importa mucho para quien opera: + * + * - Desactivar / reactivar → pausa REVERSIBLE. La sucursal sigue existiendo y puede volver. + * - Cerrar → TERMINAL. No se reactiva nunca y su código queda ocupado para + * siempre; si el negocio reabre la plaza, se da de alta otra sucursal + * con otro código. + * + * El listado excluye por defecto las cerradas; hay que pedirlas con `includeClosed=true`. + */ import { z } from 'zod'; +const guidSchema = z + .string() + .regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/); + export const BranchSchema = z.object({ - branchId: z.string().uuid(), + branchId: guidSchema, code: z.string().min(1), name: z.string().min(1), isActive: z.boolean(), @@ -10,6 +27,17 @@ export const BranchSchema = z.object({ .nullable() .optional() .transform(v => v ?? null), + // Opcionales en el parseo, no porque el API los omita, sino para que un backend anterior al + // cierre no rompa la pantalla: sin el campo, la sucursal simplemente no está cerrada. + isClosed: z + .boolean() + .optional() + .transform(v => v ?? false), + closedAtUtc: z + .string() + .nullable() + .optional() + .transform(v => v ?? null), }); export const BranchListSchema = z.array(BranchSchema); @@ -21,11 +49,67 @@ export const AddBranchPayloadSchema = z.object({ }); export const AddBranchResponseSchema = z.object({ - branchId: z.string().uuid(), - tenantId: z.string().uuid(), + branchId: guidSchema, + tenantId: guidSchema, code: z.string(), }); +/** + * Un asiento de la bitácora. El episodio viaja como nombre ("Opened", "Deactivated", + * "Reactivated", "Closed") y se valida como cadena libre a propósito: si el backend añade un + * episodio nuevo, la bitácora debe seguir mostrándose en vez de reventar el parseo. + */ +export const BranchLifecycleEntrySchema = z.object({ + entryId: guidSchema, + episode: z.string().min(1), + occurredAtUtc: z.string().min(1), + actorId: z.string(), + nameSnapshot: z.string(), + geofencingSnapshot: z + .string() + .nullable() + .optional() + .transform(v => v ?? null), + reason: z + .string() + .nullable() + .optional() + .transform(v => v ?? null), +}); + +export const BranchLifecycleSchema = z.array(BranchLifecycleEntrySchema); + export type Branch = z.infer; export type AddBranchPayload = z.infer; export type AddBranchResponse = z.infer; +export type BranchLifecycleEntry = z.infer; + +/** + * Estado de una sucursal para la interfaz. Es una unión cerrada porque son tres situaciones + * excluyentes con acciones distintas, no dos booleanos independientes (ADR-0109 P-FE-01). + */ +export type BranchState = 'Active' | 'Inactive' | 'Closed'; + +/** + * El cierre manda sobre la actividad: una sucursal cerrada no admite NINGUNA transición, así que + * jamás debe pintarse como activa aunque llegara con `isActive: true`. + */ +export function branchState(branch: Pick): BranchState { + if (branch.isClosed) return 'Closed'; + return branch.isActive ? 'Active' : 'Inactive'; +} + +/** Solo una sucursal abierta y pausada puede volver: el cierre es irreversible (ADR-0164 §2.4). */ +export function canReactivateBranch(branch: Pick): boolean { + return branchState(branch) === 'Inactive'; +} + +/** Solo se pausa lo que está activo; lo cerrado ya terminó su ciclo de vida. */ +export function canDeactivateBranch(branch: Pick): boolean { + return branchState(branch) === 'Active'; +} + +/** Cerrar dos veces no es idempotente en el backend: responde «ya está cerrada». */ +export function canCloseBranch(branch: Pick): boolean { + return branchState(branch) !== 'Closed'; +} diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/delegation.schema.ts b/src/apps/ums.web-app/src/domain/identity/schemas/delegation.schema.ts index 991660e1..c5b24e62 100644 --- a/src/apps/ums.web-app/src/domain/identity/schemas/delegation.schema.ts +++ b/src/apps/ums.web-app/src/domain/identity/schemas/delegation.schema.ts @@ -84,11 +84,11 @@ export const DelegationSchema = z.object({ export const DelegationListSchema = z.array(DelegationSchema); export const CreateDelegationPayloadSchema = z.object({ - tenantId: z.string().uuid(), - delegatingAdminId: z.string().uuid(), - delegatedAdminId: z.string().uuid(), + tenantId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), + delegatingAdminId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), + delegatedAdminId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), scopeType: DelegationScopeTypeSchema, - scopeId: z.string().uuid().nullable().optional(), + scopeId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), allowedActions: z.array(DelegatedActionSchema).min(1), validFrom: z.string(), validUntil: z.string(), @@ -97,7 +97,7 @@ export const CreateDelegationPayloadSchema = z.object({ }); export const CreateDelegationResponseSchema = z.object({ - delegationId: z.string().uuid(), + delegationId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), }); export const RevokeDelegationPayloadSchema = z.object({ diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/identity-provider.schema.ts b/src/apps/ums.web-app/src/domain/identity/schemas/identity-provider.schema.ts index f3349df7..0ac033e4 100644 --- a/src/apps/ums.web-app/src/domain/identity/schemas/identity-provider.schema.ts +++ b/src/apps/ums.web-app/src/domain/identity/schemas/identity-provider.schema.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; export const IdentityProviderSchema = z.object({ - identityProviderId: z.string().uuid(), + identityProviderId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), code: z.string().min(1), name: z.string().min(1), description: z.string().default(''), diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/inbox.schema.test.ts b/src/apps/ums.web-app/src/domain/identity/schemas/inbox.schema.test.ts new file mode 100644 index 00000000..2c651f2f --- /dev/null +++ b/src/apps/ums.web-app/src/domain/identity/schemas/inbox.schema.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { + PendingUserSignupSchema, + PendingProfileRequestSchema, + PendingUserSignupListSchema, + PendingProfileRequestListSchema, +} from './inbox.schema'; + +const validSignup = { + userAccountId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + email: 'nuevo@beyondnet.com.pe', + displayName: 'Nuevo Usuario', + category: 'Standard', + requestedAt: '2024-01-01T00:00:00Z', +}; + +const validProfileRequest = { + approvalRequestId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + targetUserId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + requestedSystemId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', + requestedBranchId: '3fa85f64-5717-4562-b3fc-2c963f66afa9', + requestedRoleId: '3fa85f64-5717-4562-b3fc-2c963f66afaa', + justification: 'Requiere acceso al módulo de despacho', + requestedAt: '2024-01-01T00:00:00Z', +}; + +describe('PendingUserSignupSchema', () => { + it('acepta un alta pendiente válida', () => { + const parsed = PendingUserSignupSchema.parse(validSignup); + expect(parsed.email).toBe('nuevo@beyondnet.com.pe'); + expect(parsed.displayName).toBe('Nuevo Usuario'); + }); + + it('transforma displayName nulo a null', () => { + const parsed = PendingUserSignupSchema.parse({ ...validSignup, displayName: null }); + expect(parsed.displayName).toBeNull(); + }); + + it('transforma displayName omitido a null', () => { + const withoutDisplayName: Record = { ...validSignup }; + delete withoutDisplayName.displayName; + const parsed = PendingUserSignupSchema.parse(withoutDisplayName); + expect(parsed.displayName).toBeNull(); + }); + + it('rechaza email inválido', () => { + expect(() => PendingUserSignupSchema.parse({ ...validSignup, email: 'no-es-email' })).toThrow(); + }); + + it('rechaza userAccountId no-UUID', () => { + expect(() => PendingUserSignupSchema.parse({ ...validSignup, userAccountId: 'x' })).toThrow(); + }); +}); + +describe('PendingProfileRequestSchema', () => { + it('acepta una solicitud de perfil válida', () => { + const parsed = PendingProfileRequestSchema.parse(validProfileRequest); + expect(parsed.justification).toBe('Requiere acceso al módulo de despacho'); + }); + + it('transforma requestedBranchId nulo a null', () => { + const parsed = PendingProfileRequestSchema.parse({ + ...validProfileRequest, + requestedBranchId: null, + }); + expect(parsed.requestedBranchId).toBeNull(); + }); + + it('transforma justification omitida a null', () => { + const withoutJustification: Record = { ...validProfileRequest }; + delete withoutJustification.justification; + const parsed = PendingProfileRequestSchema.parse(withoutJustification); + expect(parsed.justification).toBeNull(); + }); + + it('rechaza requestedRoleId no-UUID', () => { + expect(() => + PendingProfileRequestSchema.parse({ ...validProfileRequest, requestedRoleId: 'x' }) + ).toThrow(); + }); +}); + +describe('listas de bandeja', () => { + it('PendingUserSignupListSchema acepta un arreglo de altas', () => { + const parsed = PendingUserSignupListSchema.parse([validSignup, validSignup]); + expect(parsed).toHaveLength(2); + }); + + it('PendingProfileRequestListSchema acepta un arreglo vacío', () => { + const parsed = PendingProfileRequestListSchema.parse([]); + expect(parsed).toHaveLength(0); + }); +}); diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/inbox.schema.ts b/src/apps/ums.web-app/src/domain/identity/schemas/inbox.schema.ts index c9ce59ba..7adeddb8 100644 --- a/src/apps/ums.web-app/src/domain/identity/schemas/inbox.schema.ts +++ b/src/apps/ums.web-app/src/domain/identity/schemas/inbox.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -const GuidSchema = z.string().uuid(); +const GuidSchema = z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/); export const PendingUserSignupSchema = z.object({ userAccountId: GuidSchema, diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/tenant-signup-request.schema.test.ts b/src/apps/ums.web-app/src/domain/identity/schemas/tenant-signup-request.schema.test.ts new file mode 100644 index 00000000..261e3872 --- /dev/null +++ b/src/apps/ums.web-app/src/domain/identity/schemas/tenant-signup-request.schema.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + TenantSignupRequestSchema, + TenantSignupRequestListSchema, + ApproveTenantSignupResponseSchema, +} from './tenant-signup-request.schema'; + +const validRequest = { + tenantSignupRequestId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + companyName: 'Naviera del Pacífico S.A.', + companyReference: 'REF-2024-001', + contactName: 'Ana Torres', + contactEmail: 'ana.torres@naviera.com.pe', + status: 'Pending', + approvedTenantId: null, + requestedAtUtc: '2024-01-01T00:00:00Z', +}; + +describe('TenantSignupRequestSchema', () => { + it('acepta una solicitud de alta de inquilino válida', () => { + const parsed = TenantSignupRequestSchema.parse(validRequest); + expect(parsed.companyName).toBe('Naviera del Pacífico S.A.'); + expect(parsed.status).toBe('Pending'); + }); + + it('transforma approvedTenantId omitido a null', () => { + const withoutApproved: Record = { ...validRequest }; + delete withoutApproved.approvedTenantId; + const parsed = TenantSignupRequestSchema.parse(withoutApproved); + expect(parsed.approvedTenantId).toBeNull(); + }); + + it('acepta approvedTenantId como UUID válido', () => { + const parsed = TenantSignupRequestSchema.parse({ + ...validRequest, + approvedTenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + }); + expect(parsed.approvedTenantId).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa7'); + }); + + it('rechaza companyName vacío', () => { + expect(() => TenantSignupRequestSchema.parse({ ...validRequest, companyName: '' })).toThrow(); + }); + + it('rechaza contactEmail inválido', () => { + expect(() => + TenantSignupRequestSchema.parse({ ...validRequest, contactEmail: 'no-email' }) + ).toThrow(); + }); + + it('rechaza requestedAtUtc que no es datetime ISO', () => { + expect(() => + TenantSignupRequestSchema.parse({ ...validRequest, requestedAtUtc: '2024-01-01' }) + ).toThrow(); + }); + + it('rechaza tenantSignupRequestId con GUID malformado', () => { + expect(() => + TenantSignupRequestSchema.parse({ ...validRequest, tenantSignupRequestId: '123' }) + ).toThrow(); + }); +}); + +describe('TenantSignupRequestListSchema', () => { + it('acepta un arreglo de solicitudes', () => { + const parsed = TenantSignupRequestListSchema.parse([validRequest]); + expect(parsed).toHaveLength(1); + }); +}); + +describe('ApproveTenantSignupResponseSchema', () => { + const validResponse = { + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + userAccountId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + temporaryPassword: 'Temp!2024', + message: 'Inquilino aprobado correctamente', + }; + + it('acepta una respuesta de aprobación válida', () => { + const parsed = ApproveTenantSignupResponseSchema.parse(validResponse); + expect(parsed.temporaryPassword).toBe('Temp!2024'); + }); + + it('rechaza temporaryPassword vacío', () => { + expect(() => + ApproveTenantSignupResponseSchema.parse({ ...validResponse, temporaryPassword: '' }) + ).toThrow(); + }); + + it('rechaza tenantId no-UUID', () => { + expect(() => + ApproveTenantSignupResponseSchema.parse({ ...validResponse, tenantId: 'x' }) + ).toThrow(); + }); +}); diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/tenant.schema.ts b/src/apps/ums.web-app/src/domain/identity/schemas/tenant.schema.ts index 60c2127d..eed54223 100644 --- a/src/apps/ums.web-app/src/domain/identity/schemas/tenant.schema.ts +++ b/src/apps/ums.web-app/src/domain/identity/schemas/tenant.schema.ts @@ -46,10 +46,11 @@ export const CreateTenantPayloadSchema = z.object({ companyReference: z.string().optional(), }); +// La API `POST /tenants` devuelve solo `{ tenantId }` (201 Created). El nombre y el +// código los conoce el cliente desde el payload que envió; no se esperan en la +// respuesta (exigirlos rompía el parse pese al 201 → G-078). export const CreateTenantResponseSchema = z.object({ - tenantId: z.string().uuid(), - code: z.string(), - name: z.string(), + tenantId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), }); // Infer TS types from schemas (single source of truth) diff --git a/src/apps/ums.web-app/src/domain/identity/schemas/user-account.schema.ts b/src/apps/ums.web-app/src/domain/identity/schemas/user-account.schema.ts index 397d8700..98e8a231 100644 --- a/src/apps/ums.web-app/src/domain/identity/schemas/user-account.schema.ts +++ b/src/apps/ums.web-app/src/domain/identity/schemas/user-account.schema.ts @@ -72,8 +72,8 @@ export const UserAccountPageSchema = z.object({ }); export const CreateUserAccountPayloadSchema = z.object({ - tenantId: z.string().uuid(), - branchId: z.string().uuid().nullable().optional(), + tenantId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), + branchId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/).nullable().optional(), email: z.string().email().max(150), category: UserCategorySchema, identityReference: z.string().max(150).optional(), @@ -81,16 +81,16 @@ export const CreateUserAccountPayloadSchema = z.object({ }); export const CreateUserAccountResponseSchema = z.object({ - userAccountId: z.string().uuid(), + userAccountId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), }); export const SetUserAccountPasswordPayloadSchema = z.object({ - userAccountId: z.string().uuid(), + userAccountId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), password: z.string().min(12).max(128), }); export const SetUserAccountPasswordResponseSchema = z.object({ - credentialId: z.string().uuid(), + credentialId: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), }); export type UserAccount = z.infer; diff --git a/src/apps/ums.web-app/src/index.css b/src/apps/ums.web-app/src/index.css index a817aa70..57edd41a 100644 --- a/src/apps/ums.web-app/src/index.css +++ b/src/apps/ums.web-app/src/index.css @@ -5,27 +5,30 @@ @tailwind utilities; :root { - /* ── MD3 Light Theme — Indigo/Violet key colour ─────────────────────────── + /* ── MD3 Light Theme — BEYONDNET corporate navy key colour ──────────────────── + Brand palette taken from beyondnet.com.pe: navy #0f3e67 (primary), deep navy + #042139 (footer/inverse), heading blue #003c6b, green accent #41a62a. All values are HSL channel triples (no hsl() wrapper) so Tailwind can compose them with opacity modifiers: hsl(var(--token) / ). */ - /* Primary role */ - --m3-primary: 262 52% 47%; + /* Primary role — BEYONDNET navy #0f3e67 */ + --m3-primary: 208 75% 23%; --m3-on-primary: 0 0% 100%; - --m3-primary-container: 262 90% 92%; - --m3-on-primary-container: 262 95% 15%; + --m3-primary-container: 208 70% 90%; + --m3-on-primary-container: 208 90% 14%; - /* Secondary role */ - --m3-secondary: 262 10% 45%; + /* Secondary role — desaturated corporate blue */ + --m3-secondary: 208 18% 40%; --m3-on-secondary: 0 0% 100%; - --m3-secondary-container: 262 35% 88%; - --m3-on-secondary-container: 262 50% 12%; + --m3-secondary-container: 208 32% 88%; + --m3-on-secondary-container: 208 45% 14%; - /* Tertiary role (warm complement) */ - --m3-tertiary: 320 38% 48%; + /* Tertiary role — BEYONDNET green accent, oscurecido a #317e20 para cumplir + WCAG AA (texto verde sobre surface y blanco sobre verde ≥ 4.5:1). */ + --m3-tertiary: 109 60% 31%; --m3-on-tertiary: 0 0% 100%; - --m3-tertiary-container: 320 80% 92%; - --m3-on-tertiary-container: 320 60% 12%; + --m3-tertiary-container: 109 52% 88%; + --m3-on-tertiary-container: 109 70% 13%; /* Error role */ --m3-error: 0 75% 45%; @@ -33,45 +36,67 @@ --m3-error-container: 0 90% 92%; --m3-on-error-container: 0 80% 20%; - /* Surface roles */ - --m3-surface: 220 20% 98%; - --m3-on-surface: 220 40% 10%; - --m3-surface-variant: 262 18% 90%; - --m3-on-surface-variant: 262 12% 35%; + /* Surface roles — cool neutrals with a subtle navy tint */ + --m3-surface: 208 30% 98%; + --m3-on-surface: 208 32% 11%; + --m3-surface-variant: 208 22% 90%; + --m3-on-surface-variant: 208 14% 34%; --m3-surface-container: 0 0% 100%; - --m3-surface-container-low: 220 20% 96%; - --m3-surface-container-high: 220 15% 93%; + --m3-surface-container-low: 208 28% 96%; + --m3-surface-container-high: 208 22% 93%; /* Outline roles */ - --m3-outline: 220 12% 85%; - --m3-outline-variant: 220 8% 90%; + --m3-outline: 208 14% 82%; + --m3-outline-variant: 208 10% 88%; /* Inverse */ - --m3-inverse-surface: 220 30% 20%; - --m3-inverse-on-surface: 220 20% 90%; - --m3-inverse-primary: 262 85% 75%; + --m3-inverse-surface: 207 60% 16%; + --m3-inverse-on-surface: 208 20% 92%; + --m3-inverse-primary: 208 70% 68%; + + /* ── Densidad de la interfaz (settings.ui.density, G-178) ────────────────── + El sistema publica su densidad y el shell la escribe como `data-density` en + la raíz; aquí se traduce a espaciado. Son dos ritmos porque hay dos tipos de + fila: la celda de una tabla y la fila de una lista, que ya eran distintas. + `comfortable` reproduce exactamente el espaciado que el producto tenía + escrito a mano (py-3.5 y py-2.5), para que no cambie nada si nadie configura + la densidad. */ + --densidad-y: 0.875rem; + --densidad-y-sm: 0.625rem; +} + +:root[data-density='compact'] { + --densidad-y: 0.5rem; + --densidad-y-sm: 0.375rem; +} + +:root[data-density='spacious'] { + --densidad-y: 1.25rem; + --densidad-y-sm: 0.875rem; } .dark { - /* ── MD3 Dark Theme ───────────────────────────────────────────────────── */ + /* ── MD3 Dark Theme — BEYONDNET navy ───────────────────────────────────────── + Deep navy #042139 anchors the surfaces; a lighter blue carries primary so + it stays legible on dark, matching BEYONDNET's corporate identity. */ - /* Primary role */ - --m3-primary: 262 85% 75%; - --m3-on-primary: 262 95% 15%; - --m3-primary-container: 262 50% 24%; - --m3-on-primary-container: 262 90% 90%; + /* Primary role — lighter navy for contrast on dark surfaces */ + --m3-primary: 208 70% 68%; + --m3-on-primary: 208 90% 14%; + --m3-primary-container: 208 55% 26%; + --m3-on-primary-container: 208 80% 90%; /* Secondary role */ - --m3-secondary: 262 15% 70%; - --m3-on-secondary: 262 95% 15%; - --m3-secondary-container: 262 25% 22%; - --m3-on-secondary-container: 262 35% 88%; + --m3-secondary: 208 20% 72%; + --m3-on-secondary: 208 90% 14%; + --m3-secondary-container: 208 24% 24%; + --m3-on-secondary-container: 208 32% 88%; - /* Tertiary role */ - --m3-tertiary: 320 55% 72%; - --m3-on-tertiary: 320 60% 12%; - --m3-tertiary-container: 320 35% 22%; - --m3-on-tertiary-container: 320 80% 92%; + /* Tertiary role — BEYONDNET green */ + --m3-tertiary: 109 50% 62%; + --m3-on-tertiary: 109 70% 12%; + --m3-tertiary-container: 109 35% 24%; + --m3-on-tertiary-container: 109 65% 88%; /* Error role */ --m3-error: 0 85% 70%; @@ -79,23 +104,44 @@ --m3-error-container: 0 55% 22%; --m3-on-error-container: 0 90% 88%; - /* Surface roles */ - --m3-surface: 220 30% 6%; - --m3-on-surface: 220 25% 90%; - --m3-surface-variant: 262 18% 18%; - --m3-on-surface-variant: 262 12% 72%; - --m3-surface-container: 220 20% 10%; - --m3-surface-container-low: 220 25% 8%; - --m3-surface-container-high: 220 18% 14%; + /* Surface roles — anchored on BEYONDNET deep navy #042139 */ + --m3-surface: 207 60% 7%; + --m3-on-surface: 208 22% 90%; + --m3-surface-variant: 208 20% 18%; + --m3-on-surface-variant: 208 14% 72%; + --m3-surface-container: 207 45% 11%; + --m3-surface-container-low: 207 52% 9%; + --m3-surface-container-high: 208 30% 15%; /* Outline roles */ - --m3-outline: 220 12% 20%; - --m3-outline-variant: 220 8% 16%; + --m3-outline: 208 14% 22%; + --m3-outline-variant: 208 10% 17%; /* Inverse */ - --m3-inverse-surface: 220 20% 90%; - --m3-inverse-on-surface: 220 30% 6%; - --m3-inverse-primary: 262 52% 47%; + --m3-inverse-surface: 208 20% 90%; + --m3-inverse-on-surface: 207 60% 7%; + --m3-inverse-primary: 208 75% 30%; +} + +/* «Bajar el zoom» en escritorio: escala TODA la UI de forma uniforme (incluye + los tamaños en px fijos, no solo los rem) para una interfaz más compacta y + proporcionada en laptop/desktop. Con `font-size` los textos en px quedaban + grandes respecto al layout; `zoom` los reduce por igual. Móvil/tablet quedan + a escala natural para no sacrificar legibilidad táctil. */ +@media (min-width: 1024px) { + body { + zoom: 0.85; + } +} + +/* Accesibilidad táctil: en dispositivos sin hover (móvil/tablet) los grupos de + acciones revelados por `group-hover` quedarían invisibles e inalcanzables. + Se revelan siempre (opacidad completa para targets claros al tacto). Solo + afecta a elementos que combinan `opacity-0` con una clase `group-hover…`. */ +@media (hover: none) { + .opacity-0[class*='group-hover'] { + opacity: 1; + } } @layer base { diff --git a/src/apps/ums.web-app/src/infrastructure/audit/services/audit-record.service.test.ts b/src/apps/ums.web-app/src/infrastructure/audit/services/audit-record.service.test.ts new file mode 100644 index 00000000..b2d9ccbb --- /dev/null +++ b/src/apps/ums.web-app/src/infrastructure/audit/services/audit-record.service.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { auditRecordService } from './audit-record.service'; +import * as httpClientModule from '@infra/http/httpClient'; +import * as loggerModule from '@app/utils/logger'; + +vi.mock('@infra/http/httpClient', () => ({ + httpClient: { + get: vi.fn(), + }, +})); + +vi.mock('@app/utils/logger', () => ({ + logger: { + error: vi.fn(), + }, +})); + +const record = { + auditRecordId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + whoActed: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + subjectType: 'UserAccount', + whenOccurred: '2024-01-01T00:00:00Z', + whatChanged: 'status changed', + eventType: 'UserSuspended', + auditResult: 'Success', + affectedEntityId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', + affectedEntityType: 'UserAccount', + rootTenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa9', + metadata: null, +}; + +const emptyPage = { + items: [], + page: 1, + pageSize: 20, + totalItems: 0, + totalPages: 0, +}; + +describe('auditRecordService', () => { + beforeEach(() => { + vi.mocked(httpClientModule.httpClient.get).mockClear(); + vi.mocked(loggerModule.logger.error).mockClear(); + }); + + describe('getAll', () => { + it('llama al endpoint sin querystring cuando no se pasan parámetros', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ data: emptyPage }); + + const result = await auditRecordService.getAll(); + + expect(result.items).toHaveLength(0); + expect(result.page).toBe(1); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith('/audit-records?'); + }); + + it('aplica page y pageSize por defecto cuando se pasa un objeto vacío', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ data: emptyPage }); + + await auditRecordService.getAll({}); + + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/audit-records?page=1&pageSize=20' + ); + }); + + it('propaga todos los filtros al querystring', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { ...emptyPage, items: [record], page: 2, pageSize: 10, totalItems: 1, totalPages: 1 }, + }); + + const result = await auditRecordService.getAll({ + page: 2, + pageSize: 10, + eventType: 'UserSuspended', + actorId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + entityId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', + entityType: 'UserAccount', + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa9', + from: '2024-01-01', + to: '2024-12-31', + }); + + expect(result.items).toHaveLength(1); + expect(result.page).toBe(2); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/audit-records?page=2&pageSize=10&eventType=UserSuspended' + + '&actorId=3fa85f64-5717-4562-b3fc-2c963f66afa7' + + '&entityId=3fa85f64-5717-4562-b3fc-2c963f66afa8' + + '&entityType=UserAccount' + + '&tenantId=3fa85f64-5717-4562-b3fc-2c963f66afa9' + + '&from=2024-01-01&to=2024-12-31' + ); + }); + + it('omite los filtros opcionales no provistos', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ data: emptyPage }); + + await auditRecordService.getAll({ page: 3, pageSize: 5, eventType: 'RoleAssigned' }); + + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/audit-records?page=3&pageSize=5&eventType=RoleAssigned' + ); + }); + + it('lanza y registra el error cuando la respuesta REST tiene forma inválida', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { invalid: 'shape' }, + }); + + await expect(auditRecordService.getAll()).rejects.toThrow( + 'Invalid REST response shape for audit records query' + ); + expect(loggerModule.logger.error).toHaveBeenCalled(); + }); + + it('lanza cuando un item de la página no valida contra el esquema', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { ...emptyPage, items: [{ ...record, auditRecordId: 'no-uuid' }], totalItems: 1 }, + }); + + await expect(auditRecordService.getAll({})).rejects.toThrow( + 'Invalid REST response shape for audit records query' + ); + expect(loggerModule.logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/services/permission-template.service.test.ts b/src/apps/ums.web-app/src/infrastructure/authorization/services/permission-template.service.test.ts index c38c79cb..9ae60ee9 100644 --- a/src/apps/ums.web-app/src/infrastructure/authorization/services/permission-template.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/authorization/services/permission-template.service.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { permissionTemplateService } from './permission-template.service'; import * as httpClientModule from '@infra/http/httpClient'; -import * as graphqlPermissionTemplateQueriesModule from '@infra/authorization/queries/permission-template.graphql'; import * as loggerModule from '@app/utils/logger'; vi.mock('@infra/http/httpClient', () => ({ @@ -13,13 +12,6 @@ vi.mock('@infra/http/httpClient', () => ({ }, })); -vi.mock('@infra/authorization/queries/permission-template.graphql', () => ({ - graphqlPermissionTemplateQueries: { - getPermissionTemplates: vi.fn(), - getPermissionTemplateById: vi.fn(), - }, -})); - vi.mock('@app/utils/logger', () => ({ logger: { error: vi.fn(), @@ -32,23 +24,13 @@ describe('permissionTemplateService', () => { vi.mocked(httpClientModule.httpClient.post).mockClear(); vi.mocked(httpClientModule.httpClient.put).mockClear(); vi.mocked(httpClientModule.httpClient.delete).mockClear(); - vi.mocked( - graphqlPermissionTemplateQueriesModule.graphqlPermissionTemplateQueries.getPermissionTemplates - ).mockClear(); - vi.mocked( - graphqlPermissionTemplateQueriesModule.graphqlPermissionTemplateQueries - .getPermissionTemplateById - ).mockClear(); vi.mocked(loggerModule.logger.error).mockClear(); }); describe('getAll', () => { - it('calls graphql with default params', async () => { - vi.mocked( - graphqlPermissionTemplateQueriesModule.graphqlPermissionTemplateQueries - .getPermissionTemplates - ).mockResolvedValue({ - permissionTemplates: { + it('calls GET with default params', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { items: [], page: 1, pageSize: 20, @@ -60,14 +42,17 @@ describe('permissionTemplateService', () => { const result = await permissionTemplateService.getAll(); expect(result.items).toHaveLength(0); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + expect.stringContaining('/permission-templates?') + ); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + expect.stringContaining('page=1') + ); }); it('passes custom params', async () => { - vi.mocked( - graphqlPermissionTemplateQueriesModule.graphqlPermissionTemplateQueries - .getPermissionTemplates - ).mockResolvedValue({ - permissionTemplates: { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { items: [ { templateId: '12345678-1234-1234-1234-123456789012', @@ -95,14 +80,15 @@ describe('permissionTemplateService', () => { expect(result.items).toHaveLength(1); expect(result.page).toBe(2); + const calledUrl = vi.mocked(httpClientModule.httpClient.get).mock.calls[0][0]; + expect(calledUrl).toContain('page=2'); + expect(calledUrl).toContain('pageSize=10'); + expect(calledUrl).toContain('search=test'); }); it('throws on invalid response', async () => { - vi.mocked( - graphqlPermissionTemplateQueriesModule.graphqlPermissionTemplateQueries - .getPermissionTemplates - ).mockResolvedValue({ - permissionTemplates: { invalid: 'shape' }, + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { invalid: 'shape' }, }); await expect(permissionTemplateService.getAll()).rejects.toThrow('Invalid response shape'); @@ -112,11 +98,8 @@ describe('permissionTemplateService', () => { describe('getById', () => { it('returns parsed template detail', async () => { - vi.mocked( - graphqlPermissionTemplateQueriesModule.graphqlPermissionTemplateQueries - .getPermissionTemplateById - ).mockResolvedValue({ - permissionTemplateById: { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { templateId: '12345678-1234-1234-1234-123456789012', tenantId: '12345678-1234-1234-1234-123456789012', roleId: '12345678-1234-1234-1234-123456789012', @@ -132,14 +115,12 @@ describe('permissionTemplateService', () => { const result = await permissionTemplateService.getById('t1'); expect(result.templateId).toBe('12345678-1234-1234-1234-123456789012'); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith('/permission-templates/t1'); }); it('throws when not found', async () => { - vi.mocked( - graphqlPermissionTemplateQueriesModule.graphqlPermissionTemplateQueries - .getPermissionTemplateById - ).mockResolvedValue({ - permissionTemplateById: null, + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: null, }); await expect(permissionTemplateService.getById('nonexistent')).rejects.toThrow( diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/services/permission-template.service.ts b/src/apps/ums.web-app/src/infrastructure/authorization/services/permission-template.service.ts index 716d6336..7d462295 100644 --- a/src/apps/ums.web-app/src/infrastructure/authorization/services/permission-template.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/authorization/services/permission-template.service.ts @@ -1,11 +1,11 @@ /** * permission-template.service.ts * - * Queries → GraphQL via graphqlPermissionTemplateQueries + * Queries → REST via httpClient * Commands → REST via httpClient + * All responses are validated at runtime with Zod before returning. */ import { httpClient } from '@infra/http/httpClient'; -import { graphqlPermissionTemplateQueries } from '@infra/authorization/queries/permission-template.graphql'; import { logger } from '@app/utils/logger'; import { PermissionTemplatePageSchema, @@ -18,22 +18,42 @@ import { type AddTemplateItemPayload, } from '@domain/authorization/schemas/permission-template.schema'; +type PermissionTemplateListParams = { + page?: number; + pageSize?: number; + search?: string; + criteria?: string; + status?: string; + sortBy?: string; + sortOrder?: string; + tenantId?: string; + systemSuiteId?: string; + roleId?: string; +}; + +function buildPermissionTemplateQueryString( + params: Required> & + PermissionTemplateListParams +): string { + const searchParams = new URLSearchParams(); + searchParams.set('page', String(params.page)); + searchParams.set('pageSize', String(params.pageSize)); + if (params.search !== undefined) searchParams.set('search', params.search); + if (params.criteria !== undefined) searchParams.set('criteria', params.criteria); + if (params.status !== undefined) searchParams.set('status', params.status); + if (params.sortBy !== undefined) searchParams.set('sortBy', params.sortBy); + if (params.sortOrder !== undefined) searchParams.set('sortOrder', params.sortOrder); + if (params.tenantId !== undefined) searchParams.set('tenantId', params.tenantId); + if (params.systemSuiteId !== undefined) searchParams.set('systemSuiteId', params.systemSuiteId); + if (params.roleId !== undefined) searchParams.set('roleId', params.roleId); + return searchParams.toString(); +} + export const permissionTemplateService = { - // ── Queries (GraphQL) ──────────────────────────────────────────────────────── - - getAll: async (params?: { - page?: number; - pageSize?: number; - search?: string; - criteria?: string; - status?: string; - sortBy?: string; - sortOrder?: string; - tenantId?: string; - systemSuiteId?: string; - roleId?: string; - }): Promise => { - const response = await graphqlPermissionTemplateQueries.getPermissionTemplates({ + // ── Queries (REST) ──────────────────────────────────────────────────────────── + + getAll: async (params?: PermissionTemplateListParams): Promise => { + const queryString = buildPermissionTemplateQueryString({ page: params?.page ?? 1, pageSize: params?.pageSize ?? 20, search: params?.search, @@ -46,21 +66,24 @@ export const permissionTemplateService = { roleId: params?.roleId, }); - const result = PermissionTemplatePageSchema.safeParse( - (response as Record).permissionTemplates + const { data } = await httpClient.get( + `/permission-templates?${queryString}` ); + + const result = PermissionTemplatePageSchema.safeParse(data); if (!result.success) { - logger.error('Invalid GraphQL response for permission templates', result.error); + logger.error('Invalid REST response for permission templates', result.error); throw new Error('Invalid response shape for permission templates'); } return result.data; }, getById: async (templateId: string): Promise => { - const response = await graphqlPermissionTemplateQueries.getPermissionTemplateById(templateId); - const raw = (response as Record).permissionTemplateById; - if (!raw) throw new Error('Permission template not found'); - return PermissionTemplateDetailSchema.parse(raw); + const { data } = await httpClient.get( + `/permission-templates/${templateId}` + ); + if (!data) throw new Error('Permission template not found'); + return PermissionTemplateDetailSchema.parse(data); }, // ── Commands (REST) ────────────────────────────────────────────────────────── diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/services/profile.service.test.ts b/src/apps/ums.web-app/src/infrastructure/authorization/services/profile.service.test.ts index 9f6fc546..7687d589 100644 --- a/src/apps/ums.web-app/src/infrastructure/authorization/services/profile.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/authorization/services/profile.service.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { profileService } from './profile.service'; import * as httpClientModule from '@infra/http/httpClient'; -import * as graphqlProfileQueriesModule from '../queries/profile.graphql'; import * as loggerModule from '@app/utils/logger'; vi.mock('@infra/http/httpClient', () => ({ @@ -13,13 +12,6 @@ vi.mock('@infra/http/httpClient', () => ({ }, })); -vi.mock('../queries/profile.graphql', () => ({ - graphqlProfileQueries: { - getProfiles: vi.fn(), - getProfileById: vi.fn(), - }, -})); - vi.mock('@app/utils/logger', () => ({ logger: { error: vi.fn(), @@ -32,15 +24,13 @@ describe('profileService', () => { vi.mocked(httpClientModule.httpClient.post).mockClear(); vi.mocked(httpClientModule.httpClient.put).mockClear(); vi.mocked(httpClientModule.httpClient.delete).mockClear(); - vi.mocked(graphqlProfileQueriesModule.graphqlProfileQueries.getProfiles).mockClear(); - vi.mocked(graphqlProfileQueriesModule.graphqlProfileQueries.getProfileById).mockClear(); vi.mocked(loggerModule.logger.error).mockClear(); }); describe('getAll', () => { - it('calls graphql with default params', async () => { - vi.mocked(graphqlProfileQueriesModule.graphqlProfileQueries.getProfiles).mockResolvedValue({ - profiles: { + it('calls GET /profiles with default params', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { items: [], page: 1, pageSize: 20, @@ -52,11 +42,12 @@ describe('profileService', () => { const result = await profileService.getAll(); expect(result.items).toHaveLength(0); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith('/profiles?page=1&pageSize=20'); }); - it('passes custom params', async () => { - vi.mocked(graphqlProfileQueriesModule.graphqlProfileQueries.getProfiles).mockResolvedValue({ - profiles: { + it('passes custom params in the query string', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { items: [ { profileId: '12345678-1234-1234-1234-123456789012', @@ -90,11 +81,14 @@ describe('profileService', () => { expect(result.items).toHaveLength(1); expect(result.page).toBe(2); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/profiles?page=2&pageSize=10&search=test' + ); }); it('throws on invalid response', async () => { - vi.mocked(graphqlProfileQueriesModule.graphqlProfileQueries.getProfiles).mockResolvedValue({ - profiles: { invalid: 'shape' }, + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { invalid: 'shape' }, }); await expect(profileService.getAll()).rejects.toThrow('Invalid response shape'); @@ -104,40 +98,35 @@ describe('profileService', () => { describe('getById', () => { it('returns parsed profile', async () => { - vi.mocked(graphqlProfileQueriesModule.graphqlProfileQueries.getProfileById).mockResolvedValue( - { - profileById: { - profileId: '12345678-1234-1234-1234-123456789012', - tenantId: '12345678-1234-1234-1234-123456789012', - tenantCode: 'TENANT', - tenantName: 'Tenant', - userId: '12345678-1234-1234-1234-123456789012', - userEmail: 'user@example.com', - roleId: '12345678-1234-1234-1234-123456789012', - roleCode: 'ADMIN', - roleName: 'Admin', - scope: 'Tenant', - isActive: true, - permissionCount: 0, - permissions: [], - systemSuiteId: '12345678-1234-1234-1234-123456789012', - systemSuiteCode: 'CORE', - systemSuiteName: 'Core', - }, - } - ); + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { + profileId: '12345678-1234-1234-1234-123456789012', + tenantId: '12345678-1234-1234-1234-123456789012', + tenantCode: 'TENANT', + tenantName: 'Tenant', + userId: '12345678-1234-1234-1234-123456789012', + userEmail: 'user@example.com', + roleId: '12345678-1234-1234-1234-123456789012', + roleCode: 'ADMIN', + roleName: 'Admin', + scope: 'Tenant', + isActive: true, + permissionCount: 0, + permissions: [], + systemSuiteId: '12345678-1234-1234-1234-123456789012', + systemSuiteCode: 'CORE', + systemSuiteName: 'Core', + }, + }); const result = await profileService.getById('p1'); expect(result.profileId).toBe('12345678-1234-1234-1234-123456789012'); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith('/profiles/p1'); }); it('throws when not found', async () => { - vi.mocked(graphqlProfileQueriesModule.graphqlProfileQueries.getProfileById).mockResolvedValue( - { - profileById: null, - } - ); + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ data: null }); await expect(profileService.getById('nonexistent')).rejects.toThrow('Profile not found'); }); diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/services/profile.service.ts b/src/apps/ums.web-app/src/infrastructure/authorization/services/profile.service.ts index c6e0a523..5c859860 100644 --- a/src/apps/ums.web-app/src/infrastructure/authorization/services/profile.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/authorization/services/profile.service.ts @@ -1,5 +1,4 @@ import { httpClient } from '@infra/http/httpClient'; -import { graphqlProfileQueries } from '../queries/profile.graphql'; import { logger } from '@app/utils/logger'; import { ProfilePageSchema, @@ -11,6 +10,34 @@ import { type CreateProfileResponse, } from '@domain/authorization/schemas/profile.schema'; +type ProfileListParams = { + page?: number; + pageSize?: number; + search?: string; + criteria?: string; + status?: string; + sortBy?: string; + sortOrder?: string; + tenantId?: string; + userId?: string; +}; + +function buildProfileQueryString( + params: Required> & ProfileListParams +): string { + const searchParams = new URLSearchParams(); + searchParams.set('page', String(params.page)); + searchParams.set('pageSize', String(params.pageSize)); + if (params.search !== undefined) searchParams.set('search', params.search); + if (params.criteria !== undefined) searchParams.set('criteria', params.criteria); + if (params.status !== undefined) searchParams.set('status', params.status); + if (params.sortBy !== undefined) searchParams.set('sortBy', params.sortBy); + if (params.sortOrder !== undefined) searchParams.set('sortOrder', params.sortOrder); + if (params.tenantId !== undefined) searchParams.set('tenantId', params.tenantId); + if (params.userId !== undefined) searchParams.set('userId', params.userId); + return searchParams.toString(); +} + /** Response from GET /profiles/{id}/auth-graph/preview */ export interface PreviewAuthGraphResponse { format: string; @@ -25,20 +52,10 @@ export interface PreviewAuthGraphResponse { } export const profileService = { - // ── Queries (GraphQL) ──────────────────────────────────────────────────────── - - getAll: async (params?: { - page?: number; - pageSize?: number; - search?: string; - criteria?: string; - status?: string; - sortBy?: string; - sortOrder?: string; - tenantId?: string; - userId?: string; - }): Promise => { - const response = await graphqlProfileQueries.getProfiles({ + // ── Queries (REST) ─────────────────────────────────────────────────────────── + + getAll: async (params?: ProfileListParams): Promise => { + const queryString = buildProfileQueryString({ page: params?.page ?? 1, pageSize: params?.pageSize ?? 20, search: params?.search, @@ -50,19 +67,19 @@ export const profileService = { userId: params?.userId, }); - const result = ProfilePageSchema.safeParse((response as Record).profiles); + const { data } = await httpClient.get(`/profiles?${queryString}`); + const result = ProfilePageSchema.safeParse(data); if (!result.success) { - logger.error('Invalid GraphQL response for profiles', result.error); + logger.error('Invalid REST response for profiles', result.error); throw new Error('Invalid response shape for profiles'); } return result.data; }, getById: async (profileId: string): Promise => { - const response = await graphqlProfileQueries.getProfileById(profileId); - const raw = (response as Record).profileById; - if (!raw) throw new Error('Profile not found'); - return ProfileSchema.parse(raw); + const { data } = await httpClient.get(`/profiles/${profileId}`); + if (!data) throw new Error('Profile not found'); + return ProfileSchema.parse(data); }, // ── Commands (REST) ────────────────────────────────────────────────────────── diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/services/role.service.test.ts b/src/apps/ums.web-app/src/infrastructure/authorization/services/role.service.test.ts index 4b3c1a07..6ec8a9b7 100644 --- a/src/apps/ums.web-app/src/infrastructure/authorization/services/role.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/authorization/services/role.service.test.ts @@ -1,30 +1,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { roleService } from './role.service'; import * as httpClientModule from '@infra/http/httpClient'; -import * as graphqlRoleQueriesModule from '@infra/authorization/queries/role.graphql'; vi.mock('@infra/http/httpClient', () => ({ httpClient: { + get: vi.fn(), post: vi.fn(), put: vi.fn(), }, })); -vi.mock('@infra/authorization/queries/role.graphql', () => ({ - graphqlRoleQueries: { - getRolesBySystemSuite: vi.fn(), - }, -})); - describe('roleService', () => { beforeEach(() => { + vi.mocked(httpClientModule.httpClient.get).mockClear(); vi.mocked(httpClientModule.httpClient.post).mockClear(); vi.mocked(httpClientModule.httpClient.put).mockClear(); - vi.mocked(graphqlRoleQueriesModule.graphqlRoleQueries.getRolesBySystemSuite).mockClear(); }); describe('getBySystemSuite', () => { - it('calls graphqlRoleQueries and parses response', async () => { + it('calls httpClient.get and parses response', async () => { const mockRoles = [ { roleId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', @@ -39,14 +33,15 @@ describe('roleService', () => { isActive: true, }, ]; - vi.mocked( - graphqlRoleQueriesModule.graphqlRoleQueries.getRolesBySystemSuite - ).mockResolvedValue({ - rolesBySystemSuite: mockRoles, + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: mockRoles, }); const result = await roleService.getBySystemSuite('3fa85f64-5717-4562-b3fc-2c963f66afa8'); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/system-suites/3fa85f64-5717-4562-b3fc-2c963f66afa8/roles' + ); expect(result).toHaveLength(1); expect(result[0].code).toBe('ADMIN'); }); diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/services/role.service.ts b/src/apps/ums.web-app/src/infrastructure/authorization/services/role.service.ts index 342d8180..2d752b3d 100644 --- a/src/apps/ums.web-app/src/infrastructure/authorization/services/role.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/authorization/services/role.service.ts @@ -1,5 +1,4 @@ import { httpClient } from '@infra/http/httpClient'; -import { graphqlRoleQueries } from '@infra/authorization/queries/role.graphql'; import { CreateRoleResponseSchema, RoleListSchema, @@ -11,8 +10,11 @@ import { export const roleService = { getBySystemSuite: async (systemSuiteId: string): Promise => { - const response = await graphqlRoleQueries.getRolesBySystemSuite(systemSuiteId); - return RoleListSchema.parse(response.rolesBySystemSuite); + if (!systemSuiteId.trim()) { + throw new Error('Invalid systemSuiteId parameter'); + } + const { data } = await httpClient.get(`/system-suites/${systemSuiteId}/roles`); + return RoleListSchema.parse(data); }, create: async ( diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.test.ts b/src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.test.ts index 610caa5f..9ad85b23 100644 --- a/src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.test.ts @@ -12,7 +12,6 @@ vi.mock('@infra/http/httpClient', () => ({ }, })); -// Removed GraphQL mock vi.mock('@app/utils/logger', () => ({ logger: { @@ -139,6 +138,7 @@ describe('systemSuiteService', () => { tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', code: 'NEW', name: 'New Suite', + description: 'A new suite', }); expect(result.systemSuiteId).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa6'); @@ -159,16 +159,15 @@ describe('systemSuiteService', () => { }); describe('setSystemSuiteStatus', () => { - it('calls status endpoint with params', async () => { - vi.mocked(httpClientModule.httpClient.post).mockResolvedValue({}); + it('calls status endpoint via PUT with the status in the body (G-139)', async () => { + vi.mocked(httpClientModule.httpClient.put).mockResolvedValue({}); await systemSuiteService.setSystemSuiteStatus('s1', 'Active'); - expect(httpClientModule.httpClient.post).toHaveBeenCalledWith( - '/system-suites/s1/status', - undefined, - { params: { status: 'Active' } } - ); + // El endpoint es `PUT /{id}/status` con el estado en el CUERPO (SetSystemSuiteStatusPayload). + expect(httpClientModule.httpClient.put).toHaveBeenCalledWith('/system-suites/s1/status', { + status: 'Active', + }); }); }); @@ -237,90 +236,117 @@ describe('systemSuiteService', () => { }); }); - describe('addMenu', () => { - it('calls POST menus endpoint', async () => { + describe('addNode', () => { + it('calls POST nodes endpoint with parentNodeId', async () => { vi.mocked(httpClientModule.httpClient.post).mockResolvedValue({}); - await systemSuiteService.addMenu('s1', 'mod1', { - code: 'MENU1', - label: 'Menu 1', + await systemSuiteService.addNode('s1', 'mod1', { + parentNodeId: 'root1', + kind: 'Option', + code: 'NODE1', + label: 'Node 1', sortOrder: 1, }); expect(httpClientModule.httpClient.post).toHaveBeenCalledWith( - '/system-suites/s1/modules/mod1/menus', - expect.objectContaining({ code: 'MENU1' }) + '/system-suites/s1/modules/mod1/nodes', + expect.objectContaining({ code: 'NODE1', kind: 'Option', parentNodeId: 'root1' }) ); }); - }); - describe('removeMenu', () => { - it('calls DELETE menu endpoint', async () => { - vi.mocked(httpClientModule.httpClient.delete).mockResolvedValue({}); + it('sends parentNodeId null for a root node', async () => { + vi.mocked(httpClientModule.httpClient.post).mockResolvedValue({}); - await systemSuiteService.removeMenu('s1', 'mod1', 'menu1'); + await systemSuiteService.addNode('s1', 'mod1', { + kind: 'Menu', + code: 'ROOT', + label: 'Root', + sortOrder: 1, + }); - expect(httpClientModule.httpClient.delete).toHaveBeenCalledWith( - '/system-suites/s1/modules/mod1/menus/menu1' + expect(httpClientModule.httpClient.post).toHaveBeenCalledWith( + '/system-suites/s1/modules/mod1/nodes', + expect.objectContaining({ parentNodeId: null }) ); }); }); - describe('addSubMenu', () => { - it('calls POST submenus endpoint', async () => { - vi.mocked(httpClientModule.httpClient.post).mockResolvedValue({}); + describe('updateNode', () => { + it('calls PUT node endpoint', async () => { + vi.mocked(httpClientModule.httpClient.put).mockResolvedValue({}); - await systemSuiteService.addSubMenu('s1', 'mod1', 'menu1', { - code: 'SUB1', - label: 'Sub 1', - sortOrder: 1, + await systemSuiteService.updateNode('s1', 'mod1', 'node1', { + label: 'Nuevo', + sortOrder: 2, }); - expect(httpClientModule.httpClient.post).toHaveBeenCalledWith( - '/system-suites/s1/modules/mod1/menus/menu1/submenus', - expect.objectContaining({ code: 'SUB1' }) + expect(httpClientModule.httpClient.put).toHaveBeenCalledWith( + '/system-suites/s1/modules/mod1/nodes/node1', + expect.objectContaining({ label: 'Nuevo', sortOrder: 2 }) ); }); }); - describe('removeSubMenu', () => { - it('calls DELETE submenu endpoint', async () => { + describe('removeNode', () => { + it('calls DELETE node endpoint', async () => { vi.mocked(httpClientModule.httpClient.delete).mockResolvedValue({}); - await systemSuiteService.removeSubMenu('s1', 'mod1', 'menu1', 'sub1'); + await systemSuiteService.removeNode('s1', 'mod1', 'node1'); expect(httpClientModule.httpClient.delete).toHaveBeenCalledWith( - '/system-suites/s1/modules/mod1/menus/menu1/submenus/sub1' + '/system-suites/s1/modules/mod1/nodes/node1' + ); + }); + }); + + describe('setNodeStatus', () => { + it('calls PUT node status endpoint', async () => { + vi.mocked(httpClientModule.httpClient.put).mockResolvedValue({}); + + await systemSuiteService.setNodeStatus('s1', 'mod1', 'node1', false); + + expect(httpClientModule.httpClient.put).toHaveBeenCalledWith( + '/system-suites/s1/modules/mod1/nodes/node1/status', + { active: false } ); }); }); - describe('addOption', () => { - it('calls POST options endpoint', async () => { + describe('linkNodeAction / unlinkNodeAction (N:M)', () => { + it('calls POST node actions endpoint', async () => { vi.mocked(httpClientModule.httpClient.post).mockResolvedValue({}); - await systemSuiteService.addOption('s1', 'mod1', 'menu1', 'sub1', { - code: 'OPT1', - label: 'Opt 1', - actionCode: 'ACT1', - sortOrder: 1, - }); + await systemSuiteService.linkNodeAction('s1', 'mod1', 'node1', 'VIEW'); expect(httpClientModule.httpClient.post).toHaveBeenCalledWith( - '/system-suites/s1/modules/mod1/menus/menu1/submenus/sub1/options', - expect.objectContaining({ code: 'OPT1' }) + '/system-suites/s1/modules/mod1/nodes/node1/actions', + { actionCode: 'VIEW' } ); }); - }); - describe('removeOption', () => { - it('calls DELETE option endpoint', async () => { + it('calls DELETE node action endpoint', async () => { vi.mocked(httpClientModule.httpClient.delete).mockResolvedValue({}); - await systemSuiteService.removeOption('s1', 'mod1', 'menu1', 'sub1', 'opt1'); + await systemSuiteService.unlinkNodeAction('s1', 'mod1', 'node1', 'VIEW'); expect(httpClientModule.httpClient.delete).toHaveBeenCalledWith( - '/system-suites/s1/modules/mod1/menus/menu1/submenus/sub1/options/opt1' + '/system-suites/s1/modules/mod1/nodes/node1/actions/VIEW' + ); + }); + }); + + describe('setNodeMetadata', () => { + it('calls PUT node metadata endpoint', async () => { + vi.mocked(httpClientModule.httpClient.put).mockResolvedValue({}); + + await systemSuiteService.setNodeMetadata('s1', 'mod1', 'node1', { + responsable: 'QA', + criticidad: 'Alta', + }); + + expect(httpClientModule.httpClient.put).toHaveBeenCalledWith( + '/system-suites/s1/modules/mod1/nodes/node1/metadata', + expect.objectContaining({ responsable: 'QA', criticidad: 'Alta' }) ); }); }); diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.ts b/src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.ts index 0dfb75e0..4d174f97 100644 --- a/src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.ts @@ -2,8 +2,7 @@ * system-suite.service.ts * * Infrastructure service for the Authorization / SystemSuite bounded context. - * Queries use GraphQL via graphqlClient. - * Commands/transactions use REST via httpClient. + * Queries and commands use REST via httpClient. * All responses are validated at runtime with Zod before returning. */ import { httpClient } from '@infra/http/httpClient'; @@ -19,7 +18,7 @@ import { } from '@domain/authorization/schemas/system-suite.schema'; export const systemSuiteService = { - // ── Queries (GraphQL) ───────────────────────────────────────────────────── + // ── Queries (REST) ──────────────────────────────────────────────────────────────────────────────────────────────────────────── getAll: async (params?: { page?: number; @@ -90,9 +89,10 @@ export const systemSuiteService = { }, setSystemSuiteStatus: async (systemSuiteId: string, status: string): Promise => { - await httpClient.post(`/system-suites/${systemSuiteId}/status`, undefined, { - params: { status }, - }); + // El endpoint es `PUT /{id}/status` y liga el estado desde el CUERPO JSON + // (`SetSystemSuiteStatusPayload { Status }`), no desde query. Un POST con query param + // fallaba siempre (método/contrato incompatible) → el cambio de estado nunca surtía efecto. + await httpClient.put(`/system-suites/${systemSuiteId}/status`, { status }); }, // ── Module Lifecycle REST Commands ───────────────────────────────────────── @@ -130,120 +130,101 @@ export const systemSuiteService = { await httpClient.post(`/system-suites/${systemSuiteId}/modules/${moduleId}/deactivate`); }, - // ── Menu Lifecycle REST Commands ────────────────────────────────────────── + // ── Node Lifecycle REST Commands (árbol recursivo, ADR-0090) ────────────── - addMenu: async ( + addNode: async ( systemSuiteId: string, moduleId: string, - payload: { code: string; label: string; description?: string; sortOrder: number } + payload: { + parentNodeId?: string | null; + kind: string; + code: string; + label: string; + description?: string; + sortOrder: number; + } ): Promise => { - await httpClient.post(`/system-suites/${systemSuiteId}/modules/${moduleId}/menus`, { - ...payload, + await httpClient.post(`/system-suites/${systemSuiteId}/modules/${moduleId}/nodes`, { + parentNodeId: payload.parentNodeId ?? null, + kind: payload.kind, + code: payload.code, + label: payload.label, description: payload.description?.trim() ?? '', + sortOrder: payload.sortOrder, }); }, - updateMenu: async ( + updateNode: async ( systemSuiteId: string, moduleId: string, - menuId: string, + nodeId: string, payload: { label: string; description?: string; sortOrder: number } ): Promise => { - await httpClient.put(`/system-suites/${systemSuiteId}/modules/${moduleId}/menus/${menuId}`, { - ...payload, + await httpClient.put(`/system-suites/${systemSuiteId}/modules/${moduleId}/nodes/${nodeId}`, { + label: payload.label, description: payload.description?.trim() ?? '', + sortOrder: payload.sortOrder, }); }, - removeMenu: async (systemSuiteId: string, moduleId: string, menuId: string): Promise => { - await httpClient.delete(`/system-suites/${systemSuiteId}/modules/${moduleId}/menus/${menuId}`); + removeNode: async (systemSuiteId: string, moduleId: string, nodeId: string): Promise => { + await httpClient.delete(`/system-suites/${systemSuiteId}/modules/${moduleId}/nodes/${nodeId}`); }, - // ── SubMenu Lifecycle REST Commands ────────────────────────────────────── - - addSubMenu: async ( + setNodeStatus: async ( systemSuiteId: string, moduleId: string, - menuId: string, - payload: { code: string; label: string; description?: string; sortOrder: number } + nodeId: string, + active: boolean ): Promise => { - await httpClient.post( - `/system-suites/${systemSuiteId}/modules/${moduleId}/menus/${menuId}/submenus`, - { - ...payload, - description: payload.description?.trim() ?? '', - } + await httpClient.put( + `/system-suites/${systemSuiteId}/modules/${moduleId}/nodes/${nodeId}/status`, + { active } ); }, - updateSubMenu: async ( + linkNodeAction: async ( systemSuiteId: string, moduleId: string, - menuId: string, - subMenuId: string, - payload: { label: string; description?: string; sortOrder: number } + nodeId: string, + actionCode: string ): Promise => { - await httpClient.put( - `/system-suites/${systemSuiteId}/modules/${moduleId}/menus/${menuId}/submenus/${subMenuId}`, - { ...payload, description: payload.description?.trim() ?? '' } + await httpClient.post( + `/system-suites/${systemSuiteId}/modules/${moduleId}/nodes/${nodeId}/actions`, + { actionCode } ); }, - removeSubMenu: async ( + unlinkNodeAction: async ( systemSuiteId: string, moduleId: string, - menuId: string, - subMenuId: string + nodeId: string, + actionCode: string ): Promise => { await httpClient.delete( - `/system-suites/${systemSuiteId}/modules/${moduleId}/menus/${menuId}/submenus/${subMenuId}` + `/system-suites/${systemSuiteId}/modules/${moduleId}/nodes/${nodeId}/actions/${encodeURIComponent( + actionCode + )}` ); }, - // ── Option Lifecycle REST Commands ──────────────────────────────────────── - - addOption: async ( + setNodeMetadata: async ( systemSuiteId: string, moduleId: string, - menuId: string, - subMenuId: string, + nodeId: string, payload: { - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder: number; + responsable?: string | null; + criticidad?: string | null; + productoImpactado?: string | null; + componenteTecnico?: string | null; + dependencias?: string | null; + evidencias?: string | null; + trazabilidadSdlc?: string | null; } - ): Promise => { - await httpClient.post( - `/system-suites/${systemSuiteId}/modules/${moduleId}/menus/${menuId}/submenus/${subMenuId}/options`, - { ...payload, description: payload.description?.trim() ?? '' } - ); - }, - - updateOption: async ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string, - optionId: string, - payload: { label: string; description?: string; actionCode: string; sortOrder: number } ): Promise => { await httpClient.put( - `/system-suites/${systemSuiteId}/modules/${moduleId}/menus/${menuId}/submenus/${subMenuId}/options/${optionId}`, - { ...payload, description: payload.description?.trim() ?? '' } - ); - }, - - removeOption: async ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string, - optionId: string - ): Promise => { - await httpClient.delete( - `/system-suites/${systemSuiteId}/modules/${moduleId}/menus/${menuId}/submenus/${subMenuId}/options/${optionId}` + `/system-suites/${systemSuiteId}/modules/${moduleId}/nodes/${nodeId}/metadata`, + payload ); }, diff --git a/src/apps/ums.web-app/src/infrastructure/configuration/services/app-configuration.service.ts b/src/apps/ums.web-app/src/infrastructure/configuration/services/app-configuration.service.ts index a702caf7..854e8ee1 100644 --- a/src/apps/ums.web-app/src/infrastructure/configuration/services/app-configuration.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/configuration/services/app-configuration.service.ts @@ -2,8 +2,7 @@ * app-configuration.service.ts * * Infrastructure service for the Configuration / AppConfiguration bounded context. - * This service is REST-only; query transport negotiation is handled elsewhere - * for bounded contexts that still support GraphQL reads. + * Queries and commands use REST via httpClient. */ import { httpClient } from '@infra/http/httpClient'; import { logger } from '@app/utils/logger'; diff --git a/src/apps/ums.web-app/src/infrastructure/configuration/services/feature-flag.service.test.ts b/src/apps/ums.web-app/src/infrastructure/configuration/services/feature-flag.service.test.ts index bfa8c472..d79f2d85 100644 --- a/src/apps/ums.web-app/src/infrastructure/configuration/services/feature-flag.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/configuration/services/feature-flag.service.test.ts @@ -12,13 +12,6 @@ vi.mock('@infra/http/httpClient', () => ({ }, })); -vi.mock('@infra/configuration/queries/feature-flag.graphql', () => ({ - graphqlFeatureFlagQueries: { - getFeatureFlags: vi.fn(), - getFeatureFlagById: vi.fn(), - }, -})); - vi.mock('@app/utils/logger', () => ({ logger: { error: vi.fn(), diff --git a/src/apps/ums.web-app/src/infrastructure/configuration/services/parameter-catalog/parameter-catalog.service.ts b/src/apps/ums.web-app/src/infrastructure/configuration/services/parameter-catalog/parameter-catalog.service.ts index 827a291e..e9a04213 100644 --- a/src/apps/ums.web-app/src/infrastructure/configuration/services/parameter-catalog/parameter-catalog.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/configuration/services/parameter-catalog/parameter-catalog.service.ts @@ -42,11 +42,12 @@ export const parameterCatalogService = { payload: CreateParameterDefinitionPayload ): Promise => { try { - const { data } = await httpClient.post( - '/parameter-definitions', - payload - ); - return ParameterDefinitionSchema.parse(data); + // El endpoint responde 201 con SOLO el Guid (`.Produces`); no es la entidad completa, + // así que parsearla como ParameterDefinition lanzaba. Se relee con getById para devolver la + // entidad fresca que consumen el diálogo de alta y el caché. (mismo patrón que G-141) + const { data } = await httpClient.post('/parameter-definitions', payload); + const id = typeof data === 'string' ? data : (data as { id: string }).id; + return parameterCatalogService.getById(id); } catch (error) { logger.error('Failed to create parameter definition', error); throw error; @@ -58,11 +59,11 @@ export const parameterCatalogService = { payload: UpdateParameterDefinitionPayload ): Promise => { try { - const { data } = await httpClient.patch( - `/parameter-definitions/${id}`, - payload - ); - return ParameterDefinitionSchema.parse(data); + // El endpoint es `PUT /{id}` y devuelve 204 NoContent (sin cuerpo); un PATCH daba 405 y, + // aun corrigiendo el verbo, no hay cuerpo que parsear. Tras el PUT se relee con getById + // para devolver la entidad fresca que el hook (`useUpdateParameterDefinition`) cachea. (G-141) + await httpClient.put(`/parameter-definitions/${id}`, payload); + return parameterCatalogService.getById(id); } catch (error) { logger.error('Failed to update parameter definition', error); throw error; diff --git a/src/apps/ums.web-app/src/infrastructure/configuration/services/query-transport.service.test.ts b/src/apps/ums.web-app/src/infrastructure/configuration/services/query-transport.service.test.ts deleted file mode 100644 index 6e875f03..00000000 --- a/src/apps/ums.web-app/src/infrastructure/configuration/services/query-transport.service.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { queryTransportService } from './query-transport.service'; -import * as httpClientModule from '@infra/http/httpClient'; -import * as loggerModule from '@app/utils/logger'; - -vi.mock('@infra/http/httpClient', () => ({ - httpClient: { - get: vi.fn(), - }, -})); - -vi.mock('@app/utils/logger', () => ({ - logger: { - warn: vi.fn(), - }, -})); - -describe('queryTransportService', () => { - beforeEach(() => { - vi.mocked(httpClientModule.httpClient.get).mockClear(); - vi.mocked(loggerModule.logger.warn).mockClear(); - queryTransportService.resetCache(); - }); - - afterEach(() => { - queryTransportService.resetCache(); - }); - - describe('getQueryTransport', () => { - it('returns rest when backend resolves to REST', async () => { - vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ - data: { code: 'FRONTEND_CONFIG_TRANSPORT', effectiveValue: 'rest' }, - }); - - const result = await queryTransportService.getQueryTransport(); - - expect(result).toBe('rest'); - }); - - it('returns graphql when backend resolves to graphql', async () => { - vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ - data: { code: 'FRONTEND_CONFIG_TRANSPORT', effectiveValue: 'graphql' }, - }); - - const result = await queryTransportService.getQueryTransport(); - - expect(result).toBe('graphql'); - }); - - it('treats graphql value case-insensitively', async () => { - vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ - data: { code: 'FRONTEND_CONFIG_TRANSPORT', effectiveValue: 'GraphQL' }, - }); - - const result = await queryTransportService.getQueryTransport(); - - expect(result).toBe('graphql'); - }); - - it('falls back to rest when backend request fails', async () => { - vi.mocked(httpClientModule.httpClient.get).mockRejectedValue(new Error('Network error')); - - const result = await queryTransportService.getQueryTransport(); - - expect(result).toBe('rest'); - expect(loggerModule.logger.warn).toHaveBeenCalled(); - }); - - it('falls back to rest for unknown transport values', async () => { - vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ - data: { code: 'FRONTEND_CONFIG_TRANSPORT', effectiveValue: 'kafka' }, - }); - - const result = await queryTransportService.getQueryTransport(); - - expect(result).toBe('rest'); - }); - - it('caches the resolved transport and does not re-fetch within TTL', async () => { - vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ - data: { code: 'FRONTEND_CONFIG_TRANSPORT', effectiveValue: 'graphql' }, - }); - - await queryTransportService.getQueryTransport(); - await queryTransportService.getQueryTransport(); - - expect(httpClientModule.httpClient.get).toHaveBeenCalledTimes(1); - }); - - it('deduplicates concurrent requests into a single backend call', async () => { - vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ - data: { code: 'FRONTEND_CONFIG_TRANSPORT', effectiveValue: 'graphql' }, - }); - - const [first, second] = await Promise.all([ - queryTransportService.getQueryTransport(), - queryTransportService.getQueryTransport(), - ]); - - expect(first).toBe('graphql'); - expect(second).toBe('graphql'); - expect(httpClientModule.httpClient.get).toHaveBeenCalledTimes(1); - }); - - it('re-fetches after resetCache', async () => { - vi.mocked(httpClientModule.httpClient.get) - .mockResolvedValueOnce({ - data: { code: 'FRONTEND_CONFIG_TRANSPORT', effectiveValue: 'graphql' }, - }) - .mockResolvedValueOnce({ - data: { code: 'FRONTEND_CONFIG_TRANSPORT', effectiveValue: 'rest' }, - }); - - const first = await queryTransportService.getQueryTransport(); - queryTransportService.resetCache(); - const second = await queryTransportService.getQueryTransport(); - - expect(first).toBe('graphql'); - expect(second).toBe('rest'); - expect(httpClientModule.httpClient.get).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/configuration/services/query-transport.service.ts b/src/apps/ums.web-app/src/infrastructure/configuration/services/query-transport.service.ts deleted file mode 100644 index 28fd3d9f..00000000 --- a/src/apps/ums.web-app/src/infrastructure/configuration/services/query-transport.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { logger } from '@app/utils/logger'; -import { httpClient } from '@infra/http/httpClient'; - -export type QueryTransport = 'rest' | 'graphql'; - -const FRONTEND_CONFIG_TRANSPORT = 'FRONTEND_CONFIG_TRANSPORT'; -const DEFAULT_QUERY_TRANSPORT: QueryTransport = 'rest'; -const CACHE_TTL_MS = 5 * 60 * 1000; - -interface ResolvedParameterResponse { - code: string; - effectiveValue: string; -} - -let cachedTransport: { value: QueryTransport; expiresAt: number } | null = null; -let pendingTransport: Promise | null = null; - -function normalizeTransport(value: string | undefined): QueryTransport { - return value?.trim().toLowerCase() === 'graphql' ? 'graphql' : DEFAULT_QUERY_TRANSPORT; -} - -async function fetchConfiguredTransport(): Promise { - try { - const { data } = await httpClient.get( - `/parameter-definitions/resolved/${FRONTEND_CONFIG_TRANSPORT}` - ); - return normalizeTransport(data.effectiveValue); - } catch (error) { - logger.warn('Unable to resolve frontend query transport; using REST fallback.', error); - return DEFAULT_QUERY_TRANSPORT; - } -} - -export const queryTransportService = { - async getQueryTransport(): Promise { - const now = Date.now(); - if (cachedTransport && cachedTransport.expiresAt > now) { - return cachedTransport.value; - } - - pendingTransport ??= fetchConfiguredTransport().then(value => { - cachedTransport = { value, expiresAt: Date.now() + CACHE_TTL_MS }; - pendingTransport = null; - return value; - }); - - return pendingTransport; - }, - - resetCache(): void { - cachedTransport = null; - pendingTransport = null; - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/http/httpClient.ts b/src/apps/ums.web-app/src/infrastructure/http/httpClient.ts index 1b70d8b3..d89b2c73 100644 --- a/src/apps/ums.web-app/src/infrastructure/http/httpClient.ts +++ b/src/apps/ums.web-app/src/infrastructure/http/httpClient.ts @@ -98,7 +98,7 @@ function createHttpClient(): AxiosInstance { }) ); - case 429: { + case 429: const retryAfter = error.response?.headers?.['retry-after']; const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : RETRY_DELAY * 5; @@ -111,7 +111,6 @@ function createHttpClient(): AxiosInstance { }, }) ); - } case 500: case 502: @@ -126,7 +125,7 @@ function createHttpClient(): AxiosInstance { }) ); - default: { + default: const data = error.response?.data; const errorMessage = data && typeof data === 'object' @@ -148,7 +147,6 @@ function createHttpClient(): AxiosInstance { }, }) ); - } } } ); diff --git a/src/apps/ums.web-app/src/infrastructure/http/request-context.test.ts b/src/apps/ums.web-app/src/infrastructure/http/request-context.test.ts index 6a8e70d8..80c605d8 100644 --- a/src/apps/ums.web-app/src/infrastructure/http/request-context.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/http/request-context.test.ts @@ -17,7 +17,7 @@ describe('request-context', () => { }); it('exports DEFAULT_TENANT_ID', () => { - expect(DEFAULT_TENANT_ID).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa6'); + expect(DEFAULT_TENANT_ID).toBe('5f4e3d2c-1b0a-9f8e-7d6c-543210987654'); }); it('returns empty context by default', () => { diff --git a/src/apps/ums.web-app/src/infrastructure/http/request-context.ts b/src/apps/ums.web-app/src/infrastructure/http/request-context.ts index 0cd6c694..fe89eb8b 100644 --- a/src/apps/ums.web-app/src/infrastructure/http/request-context.ts +++ b/src/apps/ums.web-app/src/infrastructure/http/request-context.ts @@ -1,8 +1,8 @@ /** - * request-context.ts — Shared request context for HTTP and GraphQL clients + * request-context.ts — Shared request context for the HTTP client * - * M-7: Eliminates duplication between httpClient and graphqlClient. - * L-6: BASE_URL and GRAPHQL_ENDPOINT configurable via env vars. + * M-7: Centralizes request context for the httpClient. + * L-6: BASE_URL configurable via env vars. */ export interface RequestContext { @@ -23,4 +23,6 @@ export const getRequestContext = (): RequestContext => provider(); export const BASE_URL = import.meta.env.VITE_API_BASE_PATH || '/api/v1'; -export const DEFAULT_TENANT_ID = '3fa85f64-5717-4562-b3fc-2c963f66afa6'; +// ADR-0071 / FS-26: el tenant por defecto en dev es BEYONDNET, el Tenant Raíz / Admin Root. +// Con una sesión iniciada se usa `user.tenantId`; este valor es solo el fallback dev. +export const DEFAULT_TENANT_ID = '5f4e3d2c-1b0a-9f8e-7d6c-543210987654'; diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/auth.service.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/auth.service.ts index ad32c615..badbf570 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/auth.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/auth.service.ts @@ -5,9 +5,22 @@ export interface ForgotPasswordPayload { email: string; } +/** + * El servidor ya no devuelve contraseña alguna en este flujo (G-188): el secreto de + * restablecimiento sale solo por el buzón del titular. Un único campo, siempre con el mismo + * texto, exista o no la cuenta. + */ export interface ForgotPasswordResponse { message: string; - simulatedTemporaryPassword: string | null; +} + +export interface ResetPasswordPayload { + token: string; + newPassword: string; +} + +export interface ResetPasswordResponse { + message: string; } export interface SignupUserPayload { @@ -43,6 +56,14 @@ const authService = { return data as ForgotPasswordResponse; }, + resetPassword: async (payload: ResetPasswordPayload): Promise => { + const { data } = await publicClient.post('/auth/reset-password', { + token: payload.token, + newPassword: payload.newPassword, + }); + return data as ResetPasswordResponse; + }, + signupUser: async (payload: SignupUserPayload): Promise => { const { data } = await publicClient.post('/auth/user-signup', { tenantCode: payload.tenantCode, diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/delegation.service.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/delegation.service.test.ts index d82a73f6..65bb1f5a 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/delegation.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/delegation.service.test.ts @@ -1,54 +1,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { delegationService } from './delegation.service'; import * as httpClientModule from '@infra/http/httpClient'; -import * as graphqlDelegationQueriesModule from '@infra/identity/queries/delegation.graphql'; vi.mock('@infra/http/httpClient', () => ({ httpClient: { + get: vi.fn(), post: vi.fn(), }, })); -vi.mock('@infra/identity/queries/delegation.graphql', () => ({ - graphqlDelegationQueries: { - getDelegationById: vi.fn(), - getDelegationsByDelegatedAdmin: vi.fn(), - getDelegationsByDelegatingAdmin: vi.fn(), - }, -})); - describe('delegationService', () => { beforeEach(() => { + vi.mocked(httpClientModule.httpClient.get).mockClear(); vi.mocked(httpClientModule.httpClient.post).mockClear(); - vi.mocked( - graphqlDelegationQueriesModule.graphqlDelegationQueries.getDelegationById - ).mockClear(); - vi.mocked( - graphqlDelegationQueriesModule.graphqlDelegationQueries.getDelegationsByDelegatedAdmin - ).mockClear(); - vi.mocked( - graphqlDelegationQueriesModule.graphqlDelegationQueries.getDelegationsByDelegatingAdmin - ).mockClear(); }); describe('getDelegationById', () => { - it('throws when delegation not found', async () => { - vi.mocked( - graphqlDelegationQueriesModule.graphqlDelegationQueries.getDelegationById - ).mockResolvedValue({ - delegationById: null, - }); - - await expect( - delegationService.getDelegationById('3fa85f64-5717-4562-b3fc-2c963f66afa6') - ).rejects.toThrow('Delegation not found'); + it('throws when delegationId is empty', async () => { + await expect(delegationService.getDelegationById('')).rejects.toThrow( + 'delegationId must be a non-empty string' + ); }); it('returns parsed delegation when found', async () => { - vi.mocked( - graphqlDelegationQueriesModule.graphqlDelegationQueries.getDelegationById - ).mockResolvedValue({ - delegationById: { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { delegationId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', delegatingAdminId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', @@ -72,6 +48,9 @@ describe('delegationService', () => { '3fa85f64-5717-4562-b3fc-2c963f66afa6' ); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/delegations/3fa85f64-5717-4562-b3fc-2c963f66afa6' + ); expect(result.delegationId).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa6'); expect(result.status).toBe('Active'); }); @@ -79,10 +58,8 @@ describe('delegationService', () => { describe('getDelegationsByDelegatedAdmin', () => { it('returns parsed delegations', async () => { - vi.mocked( - graphqlDelegationQueriesModule.graphqlDelegationQueries.getDelegationsByDelegatedAdmin - ).mockResolvedValue({ - delegationsByDelegatedAdmin: [], + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: [], }); const result = await delegationService.getDelegationsByDelegatedAdmin( @@ -90,16 +67,17 @@ describe('delegationService', () => { '3fa85f64-5717-4562-b3fc-2c963f66afa7' ); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/delegations/by-delegated-admin/3fa85f64-5717-4562-b3fc-2c963f66afa6?tenantId=3fa85f64-5717-4562-b3fc-2c963f66afa7' + ); expect(result).toEqual([]); }); }); describe('getDelegationsByDelegatingAdmin', () => { it('returns parsed delegations', async () => { - vi.mocked( - graphqlDelegationQueriesModule.graphqlDelegationQueries.getDelegationsByDelegatingAdmin - ).mockResolvedValue({ - delegationsByDelegatingAdmin: [], + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: [], }); const result = await delegationService.getDelegationsByDelegatingAdmin( @@ -107,6 +85,9 @@ describe('delegationService', () => { '3fa85f64-5717-4562-b3fc-2c963f66afa7' ); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/delegations/by-delegating-admin/3fa85f64-5717-4562-b3fc-2c963f66afa6?tenantId=3fa85f64-5717-4562-b3fc-2c963f66afa7' + ); expect(result).toEqual([]); }); }); diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/delegation.service.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/delegation.service.ts index bb62fe86..2ebf15b3 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/delegation.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/delegation.service.ts @@ -2,12 +2,11 @@ * delegation.service.ts * * Infrastructure service for the Identity / UserManagementDelegation bounded context. - * Queries use GraphQL via graphqlDelegationQueries. + * Queries use REST via httpClient. * Commands/transactions use REST via httpClient. * All responses are validated at runtime with Zod before returning. */ import { httpClient } from '@infra/http/httpClient'; -import { graphqlDelegationQueries } from '@infra/identity/queries/delegation.graphql'; import { logger } from '@app/utils/logger'; import { DelegationSchema, @@ -19,29 +18,33 @@ import { } from '@domain/identity/schemas/delegation.schema'; export const delegationService = { - // ── Queries (GraphQL) ───────────────────────────────────────────────────── + // ── Queries (REST) ──────────────────────────────────────────────────────── getDelegationById: async (delegationId: string): Promise => { - const response = await graphqlDelegationQueries.getDelegationById(delegationId); - if (!response.delegationById) throw new Error('Delegation not found'); - return DelegationSchema.parse(response.delegationById); + if (!delegationId || delegationId.trim() === '') { + throw new Error('delegationId must be a non-empty string'); + } + const { data } = await httpClient.get(`/delegations/${delegationId}`); + return DelegationSchema.parse(data); }, getDelegationsByDelegatedAdmin: async ( delegatedAdminId: string, tenantId: string ): Promise => { - const response = await graphqlDelegationQueries.getDelegationsByDelegatedAdmin( - delegatedAdminId, - tenantId + if (!delegatedAdminId || !tenantId) { + throw new Error('delegatedAdminId and tenantId are required'); + } + const { data } = await httpClient.get( + `/delegations/by-delegated-admin/${delegatedAdminId}?tenantId=${tenantId}` ); - const result = DelegationListSchema.safeParse(response.delegationsByDelegatedAdmin); + const result = DelegationListSchema.safeParse(data); if (!result.success) { logger.error( - 'Invalid GraphQL response shape for delegationsByDelegatedAdmin query', + 'Invalid REST response shape for delegationsByDelegatedAdmin query', result.error ); - throw new Error('Invalid GraphQL response shape for delegationsByDelegatedAdmin query'); + throw new Error('Invalid REST response shape for delegationsByDelegatedAdmin query'); } return result.data; }, @@ -50,17 +53,19 @@ export const delegationService = { delegatingAdminId: string, tenantId: string ): Promise => { - const response = await graphqlDelegationQueries.getDelegationsByDelegatingAdmin( - delegatingAdminId, - tenantId + if (!delegatingAdminId || !tenantId) { + throw new Error('delegatingAdminId and tenantId are required'); + } + const { data } = await httpClient.get( + `/delegations/by-delegating-admin/${delegatingAdminId}?tenantId=${tenantId}` ); - const result = DelegationListSchema.safeParse(response.delegationsByDelegatingAdmin); + const result = DelegationListSchema.safeParse(data); if (!result.success) { logger.error( - 'Invalid GraphQL response shape for delegationsByDelegatingAdmin query', + 'Invalid REST response shape for delegationsByDelegatingAdmin query', result.error ); - throw new Error('Invalid GraphQL response shape for delegationsByDelegatingAdmin query'); + throw new Error('Invalid REST response shape for delegationsByDelegatingAdmin query'); } return result.data; }, @@ -80,6 +85,18 @@ export const delegationService = { await httpClient.post(`/delegations/${delegationId}/revoke`, null, { params: { reason } }); }, + submitDelegationForApproval: async (delegationId: string): Promise => { + await httpClient.post(`/delegations/${delegationId}/submit-for-approval`); + }, + + approveDelegation: async (delegationId: string): Promise => { + await httpClient.post(`/delegations/${delegationId}/approve`); + }, + + rejectDelegation: async (delegationId: string, reason: string): Promise => { + await httpClient.post(`/delegations/${delegationId}/reject`, null, { params: { reason } }); + }, + expireDelegation: async (delegationId: string): Promise => { await httpClient.post(`/delegations/${delegationId}/expire`); }, diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/idp.service.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/idp.service.test.ts index 93e4b6cf..357455b9 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/idp.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/idp.service.test.ts @@ -1,30 +1,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { idpService } from './idp.service'; import * as httpClientModule from '@infra/http/httpClient'; -import * as graphqlIdpQueriesModule from '@infra/identity/queries/idp.graphql'; vi.mock('@infra/http/httpClient', () => ({ httpClient: { + get: vi.fn(), post: vi.fn(), delete: vi.fn(), }, })); -vi.mock('@infra/identity/queries/idp.graphql', () => ({ - graphqlIdpQueries: { - getIdentityProviders: vi.fn(), - }, -})); - describe('idpService', () => { beforeEach(() => { + vi.mocked(httpClientModule.httpClient.get).mockClear(); vi.mocked(httpClientModule.httpClient.post).mockClear(); vi.mocked(httpClientModule.httpClient.delete).mockClear(); - vi.mocked(graphqlIdpQueriesModule.graphqlIdpQueries.getIdentityProviders).mockClear(); }); describe('getByIdentityProviders', () => { - it('calls graphqlIdpQueries and parses response', async () => { + it('calls httpClient.get and parses response', async () => { const mockIdps = [ { identityProviderId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', @@ -35,17 +29,26 @@ describe('idpService', () => { isActive: true, }, ]; - vi.mocked(graphqlIdpQueriesModule.graphqlIdpQueries.getIdentityProviders).mockResolvedValue({ - tenantIdentityProviders: mockIdps, + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: mockIdps, }); const result = await idpService.getByIdentityProviders( '3fa85f64-5717-4562-b3fc-2c963f66afa7' ); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/tenants/3fa85f64-5717-4562-b3fc-2c963f66afa7/identity-providers' + ); expect(result).toHaveLength(1); expect(result[0].name).toBe('Azure AD'); }); + + it('throws on empty tenantId', async () => { + await expect(idpService.getByIdentityProviders('')).rejects.toThrow( + 'Invalid tenantId parameter' + ); + }); }); describe('registerIdentityProvider', () => { diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/idp.service.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/idp.service.ts index 72618edc..8d8a0398 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/idp.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/idp.service.ts @@ -2,12 +2,11 @@ * idp.service.ts * * Infrastructure service for Identity Provider bounded context. - * Queries use GraphQL via graphqlClient. + * Queries use REST via httpClient. * Commands/transactions use REST via httpClient. * All responses are validated at runtime with Zod before returning. */ import { httpClient } from '@infra/http/httpClient'; -import { graphqlIdpQueries } from '@infra/identity/queries/idp.graphql'; import { IdentityProviderListSchema, type IdentityProvider, @@ -15,11 +14,16 @@ import { } from '@domain/identity/schemas/identity-provider.schema'; export const idpService = { - // ── Queries (GraphQL) ───────────────────────────────────────────────────── + // ── Queries (REST) ──────────────────────────────────────────────────────── getByIdentityProviders: async (tenantId: string): Promise => { - const response = await graphqlIdpQueries.getIdentityProviders(tenantId); - return IdentityProviderListSchema.parse(response.tenantIdentityProviders); + if (!tenantId || tenantId.trim() === '') { + throw new Error('Invalid tenantId parameter'); + } + const { data } = await httpClient.get( + `/tenants/${tenantId}/identity-providers` + ); + return IdentityProviderListSchema.parse(data); }, // ── Commands (REST) ─────────────────────────────────────────────────────── diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/tenant.service.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/tenant.service.test.ts index 43fed3ea..cf085e83 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/tenant.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/tenant.service.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { tenantService } from './tenant.service'; import * as httpClientModule from '@infra/http/httpClient'; -import * as queryTransportModule from '@infra/configuration/services/query-transport.service'; -import * as graphqlQueriesModule from '@infra/identity/queries/tenant.graphql'; import * as loggerModule from '@app/utils/logger'; import { useAuthStore } from '@app/stores/auth.store'; @@ -15,21 +13,6 @@ vi.mock('@infra/http/httpClient', () => ({ }, })); -vi.mock('@infra/identity/queries/tenant.graphql', () => ({ - graphqlQueries: { - getTenants: vi.fn(), - getTenantById: vi.fn(), - getTenantBranches: vi.fn(), - }, -})); - -vi.mock('@infra/configuration/services/query-transport.service', () => ({ - queryTransportService: { - getQueryTransport: vi.fn(), - resetCache: vi.fn(), - }, -})); - vi.mock('@app/utils/logger', () => ({ logger: { error: vi.fn(), @@ -49,12 +32,6 @@ describe('tenantService', () => { vi.mocked(httpClientModule.httpClient.post).mockClear(); vi.mocked(httpClientModule.httpClient.put).mockClear(); vi.mocked(httpClientModule.httpClient.delete).mockClear(); - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenants).mockClear(); - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenantById).mockClear(); - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenantBranches).mockClear(); - vi.mocked(queryTransportModule.queryTransportService.getQueryTransport).mockResolvedValue( - 'graphql' - ); vi.mocked(loggerModule.logger.error).mockClear(); vi.mocked(useAuthStore.getState).mockReturnValue({ @@ -64,34 +41,20 @@ describe('tenantService', () => { }); describe('getAll', () => { - it('calls graphql with default params', async () => { - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenants).mockResolvedValue({ - tenants: { - items: [], - page: 1, - pageSize: 20, - totalItems: 0, - totalPages: 0, - }, + it('calls REST with default params', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { items: [], page: 1, pageSize: 20, totalItems: 0, totalPages: 0 }, }); const result = await tenantService.getAll(); expect(result.items).toHaveLength(0); - expect(graphqlQueriesModule.graphqlQueries.getTenants).toHaveBeenCalledWith({ - page: 1, - pageSize: 20, - search: undefined, - criteria: undefined, - status: undefined, - sortBy: undefined, - sortOrder: undefined, - }); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith('/tenants?page=1&pageSize=20'); }); it('passes custom params', async () => { - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenants).mockResolvedValue({ - tenants: { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { items: [ { tenantId: '12345678-1234-1234-1234-123456789012', @@ -113,43 +76,25 @@ describe('tenantService', () => { expect(result.items).toHaveLength(1); expect(result.page).toBe(2); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/tenants?page=2&pageSize=10&search=test' + ); }); - it('throws on invalid response', async () => { - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenants).mockResolvedValue({ - tenants: { invalid: 'shape' }, - }); - - await expect(tenantService.getAll()).rejects.toThrow('Invalid GraphQL response shape'); - expect(loggerModule.logger.error).toHaveBeenCalled(); - }); - - it('uses REST when configured transport is rest', async () => { - vi.mocked(queryTransportModule.queryTransportService.getQueryTransport).mockResolvedValue( - 'rest' - ); + it('throws on invalid response shape', async () => { vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ - data: { - items: [], - page: 1, - pageSize: 20, - totalItems: 0, - totalPages: 0, - }, + data: { invalid: 'shape' }, }); - const result = await tenantService.getAll(); - - expect(result.items).toHaveLength(0); - expect(httpClientModule.httpClient.get).toHaveBeenCalledWith('/tenants?page=1&pageSize=20'); - expect(graphqlQueriesModule.graphqlQueries.getTenants).not.toHaveBeenCalled(); + await expect(tenantService.getAll()).rejects.toThrow('Invalid REST response shape'); + expect(loggerModule.logger.error).toHaveBeenCalled(); }); }); describe('getById', () => { it('returns parsed tenant', async () => { - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenantById).mockResolvedValue({ - tenantById: { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', code: 'T1', name: 'Tenant 1', @@ -162,21 +107,22 @@ describe('tenantService', () => { const result = await tenantService.getById('3fa85f64-5717-4562-b3fc-2c963f66afa6'); expect(result.tenantId).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa6'); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/tenants/3fa85f64-5717-4562-b3fc-2c963f66afa6' + ); }); - it('throws when tenant not found', async () => { - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenantById).mockResolvedValue({ - tenantById: null, - }); + it('throws when the response cannot be parsed', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ data: null }); - await expect(tenantService.getById('nonexistent')).rejects.toThrow('Tenant not found'); + await expect(tenantService.getById('nonexistent')).rejects.toThrow(); }); }); describe('getBranches', () => { it('returns parsed branches', async () => { - vi.mocked(graphqlQueriesModule.graphqlQueries.getTenantBranches).mockResolvedValue({ - tenantBranches: [ + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: [ { branchId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', code: 'B1', @@ -190,6 +136,64 @@ describe('tenantService', () => { expect(result).toHaveLength(1); expect(result[0].branchId).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa6'); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/tenants/3fa85f64-5717-4562-b3fc-2c963f66afa7/branches' + ); + }); + + // ADR-0164: por defecto el backend ya no devuelve las cerradas; hay que pedirlas. + it('pide las cerradas solo cuando se solicitan', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ data: [] }); + + await tenantService.getBranches('3fa85f64-5717-4562-b3fc-2c963f66afa7', true); + + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/tenants/3fa85f64-5717-4562-b3fc-2c963f66afa7/branches?includeClosed=true' + ); + }); + + it('normaliza a false la sucursal que llega sin isClosed', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: [ + { + branchId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + code: 'B1', + name: 'Branch 1', + isActive: true, + }, + ], + }); + + const result = await tenantService.getBranches('3fa85f64-5717-4562-b3fc-2c963f66afa7'); + + expect(result[0].isClosed).toBe(false); + expect(result[0].closedAtUtc).toBeNull(); + }); + }); + + describe('getBranchLifecycle', () => { + it('llama al endpoint de bitácora y valida los asientos', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: [ + { + entryId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + episode: 'Closed', + occurredAtUtc: '2026-07-31T15:04:05Z', + actorId: 'admin@beyondnet.com.pe', + nameSnapshot: 'Sucursal Callao', + geofencingSnapshot: null, + reason: 'Traslado de operaciones al terminal norte', + }, + ], + }); + + const result = await tenantService.getBranchLifecycle('t1', 'b1'); + + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/tenants/t1/branches/b1/bitacora' + ); + expect(result[0].episode).toBe('Closed'); + expect(result[0].reason).toBe('Traslado de operaciones al terminal norte'); }); }); @@ -253,11 +257,30 @@ describe('tenantService', () => { }); }); - describe('removeBranch', () => { + // ADR-0164: el DELETE conserva ruta y contrato, pero ya no borra: CIERRA definitivamente. + describe('closeBranch', () => { it('calls DELETE branch endpoint', async () => { vi.mocked(httpClientModule.httpClient.delete).mockResolvedValue({}); - await tenantService.removeBranch('t1', 'b1'); + await tenantService.closeBranch('t1', 'b1'); + + expect(httpClientModule.httpClient.delete).toHaveBeenCalledWith('/tenants/t1/branches/b1'); + }); + + it('adjunta el motivo del cierre cuando se indica', async () => { + vi.mocked(httpClientModule.httpClient.delete).mockResolvedValue({}); + + await tenantService.closeBranch('t1', 'b1', 'Cese de operaciones en Callao'); + + expect(httpClientModule.httpClient.delete).toHaveBeenCalledWith( + '/tenants/t1/branches/b1?reason=Cese%20de%20operaciones%20en%20Callao' + ); + }); + + it('omite el motivo si viene vacío', async () => { + vi.mocked(httpClientModule.httpClient.delete).mockResolvedValue({}); + + await tenantService.closeBranch('t1', 'b1', ' '); expect(httpClientModule.httpClient.delete).toHaveBeenCalledWith('/tenants/t1/branches/b1'); }); diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/tenant.service.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/tenant.service.ts index 9c499be7..ce3e1890 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/tenant.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/tenant.service.ts @@ -2,13 +2,10 @@ * tenant.service.ts * * Infrastructure service for the Identity / Tenant bounded context. - * Queries use the transport configured by FRONTEND_CONFIG_TRANSPORT. - * Commands/transactions use REST via httpClient. + * Queries and commands use REST via httpClient. * All responses are validated at runtime with Zod before returning. */ import { httpClient } from '@infra/http/httpClient'; -import { queryTransportService } from '@infra/configuration/services/query-transport.service'; -import { graphqlQueries } from '@infra/identity/queries/tenant.graphql'; import { logger } from '@app/utils/logger'; import { TenantPageSchema, @@ -21,8 +18,10 @@ import { } from '@domain/identity/schemas/tenant.schema'; import { BranchListSchema, + BranchLifecycleSchema, AddBranchResponseSchema, type Branch, + type BranchLifecycleEntry, type AddBranchPayload, type AddBranchResponse, } from '@domain/identity/schemas/branch.schema'; @@ -63,22 +62,10 @@ async function getTenantsViaRest( return pageResult.data; } -async function getTenantsViaGraphql( - params: Required> & TenantListParams -): Promise { - const response = await graphqlQueries.getTenants(params); - const pageResult = TenantPageSchema.safeParse(response.tenants); - if (!pageResult.success) { - logger.error('Invalid GraphQL response shape for tenants query', pageResult.error); - throw new Error('Invalid GraphQL response shape for tenants query'); - } - return pageResult.data; -} - import { RequirePermission } from '@app/authorization/decorators/require-permission.decorator'; export class TenantService { - // ── Queries (parameterized REST/GraphQL transport) ──────────────────────── + // ── Queries (REST) ──────────────────────────────────────────────────────── @RequirePermission('TENANT', 'VIEW') async getAll(params?: TenantListParams): Promise { @@ -92,37 +79,35 @@ export class TenantService { sortOrder: params?.sortOrder, }; - const transport = await queryTransportService.getQueryTransport(); - return transport === 'graphql' - ? getTenantsViaGraphql(normalizedParams) - : getTenantsViaRest(normalizedParams); + return getTenantsViaRest(normalizedParams); } @RequirePermission('TENANT', 'VIEW') async getById(tenantId: string): Promise { - const transport = await queryTransportService.getQueryTransport(); - if (transport === 'graphql') { - const response = await graphqlQueries.getTenantById(tenantId); - if (!response.tenantById) throw new Error('Tenant not found'); - return TenantSchema.parse(response.tenantById); - } - const { data } = await httpClient.get(`/tenants/${tenantId}`); return TenantSchema.parse(data); } + /** + * ADR-0164: el listado excluye por defecto las sucursales CERRADAS. Solo se piden cuando alguien + * quiere consultar el histórico, y entonces llegan marcadas con `isClosed`. + */ @RequirePermission('TENANT', 'VIEW') - async getBranches(tenantId: string): Promise { - const transport = await queryTransportService.getQueryTransport(); - if (transport === 'graphql') { - const response = await graphqlQueries.getTenantBranches(tenantId); - return BranchListSchema.parse(response.tenantBranches); - } - - const { data } = await httpClient.get(`/tenants/${tenantId}/branches`); + async getBranches(tenantId: string, includeClosed = false): Promise { + const query = includeClosed ? '?includeClosed=true' : ''; + const { data } = await httpClient.get(`/tenants/${tenantId}/branches${query}`); return BranchListSchema.parse(data); } + /** Bitácora de la sucursal: apertura, desactivaciones, reactivaciones y cierre definitivo. */ + @RequirePermission('TENANT', 'VIEW') + async getBranchLifecycle(tenantId: string, branchId: string): Promise { + const { data } = await httpClient.get( + `/tenants/${tenantId}/branches/${branchId}/bitacora` + ); + return BranchLifecycleSchema.parse(data); + } + // ── Commands (REST) ─────────────────────────────────────────────────────── @RequirePermission('TENANT', 'CREATE') @@ -141,6 +126,14 @@ export class TenantService { await httpClient.post(`/tenants/${tenantId}/set-management-owner`, { value }); } + @RequirePermission('TENANT', 'MANAGE') + async updateTenant( + tenantId: string, + payload: { name: string; type: string; companyReference?: string | null } + ): Promise { + await httpClient.put(`/tenants/${tenantId}`, payload); + } + @RequirePermission('TENANT', 'MANAGE') async suspendTenant(tenantId: string): Promise { await httpClient.post(`/tenants/${tenantId}/suspend`); @@ -153,8 +146,23 @@ export class TenantService { } @RequirePermission('TENANT', 'MANAGE') - async removeBranch(tenantId: string, branchId: string): Promise { - await httpClient.delete(`/tenants/${tenantId}/branches/${branchId}`); + async updateBranch( + tenantId: string, + branchId: string, + payload: { name: string; geofencingMetadata?: string | null } + ): Promise { + await httpClient.put(`/tenants/${tenantId}/branches/${branchId}`, payload); + } + + /** + * Cierra DEFINITIVAMENTE la sucursal (ADR-0164). El verbo HTTP sigue siendo DELETE y la ruta no + * cambió, pero detrás ya no hay borrado: la fila permanece, el código queda ocupado y no se puede + * reactivar. El nombre del método dice lo que pasa, no lo que pasaba. + */ + @RequirePermission('TENANT', 'MANAGE') + async closeBranch(tenantId: string, branchId: string, reason?: string): Promise { + const query = reason?.trim() ? `?reason=${encodeURIComponent(reason.trim())}` : ''; + await httpClient.delete(`/tenants/${tenantId}/branches/${branchId}${query}`); } @RequirePermission('TENANT', 'MANAGE') diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/user-account.service.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/user-account.service.test.ts index 836b7e36..1e5d9e94 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/user-account.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/user-account.service.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { userAccountService } from './user-account.service'; import * as httpClientModule from '@infra/http/httpClient'; -import * as graphqlQueriesModule from '@infra/identity/queries/user-account.graphql'; vi.mock('@infra/http/httpClient', () => ({ httpClient: { @@ -10,19 +9,10 @@ vi.mock('@infra/http/httpClient', () => ({ }, })); -vi.mock('@infra/identity/queries/user-account.graphql', () => ({ - graphqlQueries: { - getUserAccounts: vi.fn(), - getUserAccountById: vi.fn(), - }, -})); - describe('userAccountService', () => { beforeEach(() => { vi.mocked(httpClientModule.httpClient.get).mockClear(); vi.mocked(httpClientModule.httpClient.post).mockClear(); - vi.mocked(graphqlQueriesModule.graphqlQueries.getUserAccounts).mockClear(); - vi.mocked(graphqlQueriesModule.graphqlQueries.getUserAccountById).mockClear(); }); describe('getAll', () => { @@ -57,10 +47,7 @@ describe('userAccountService', () => { await userAccountService.getAll(); expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( - '/user-accounts', - expect.objectContaining({ - params: expect.objectContaining({ page: 1, pageSize: 20 }), - }) + '/user-accounts?page=1&pageSize=20' ); }); @@ -78,10 +65,7 @@ describe('userAccountService', () => { await userAccountService.getAll({ page: 2, pageSize: 10 }); expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( - '/user-accounts', - expect.objectContaining({ - params: expect.objectContaining({ page: 2, pageSize: 10 }), - }) + '/user-accounts?page=2&pageSize=10' ); }); }); diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/user-account.service.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/user-account.service.ts index ec8e3c9a..758c99ed 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/user-account.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/user-account.service.ts @@ -32,22 +32,39 @@ export interface UserAccountQueryParams { tenantId?: string; } +function buildUserAccountQueryString( + params: Required> & UserAccountQueryParams +): string { + const searchParams = new URLSearchParams(); + searchParams.set('page', String(params.page)); + searchParams.set('pageSize', String(params.pageSize)); + if (params.search !== undefined) searchParams.set('search', params.search); + if (params.criteria !== undefined) searchParams.set('criteria', params.criteria); + if (params.status !== undefined) searchParams.set('status', params.status); + if (params.sortBy !== undefined) searchParams.set('sortBy', params.sortBy); + if (params.sortOrder !== undefined) searchParams.set('sortOrder', params.sortOrder); + if (params.tenantId !== undefined) searchParams.set('tenantId', params.tenantId); + return searchParams.toString(); +} + export const userAccountService = { // ── Queries (REST) ──────────────────────────────────────────────────────── getAll: async (params?: UserAccountQueryParams): Promise => { - const { data } = await httpClient.get('/user-accounts', { - params: { - page: params?.page ?? 1, - pageSize: params?.pageSize ?? 20, - search: params?.search, - criteria: params?.criteria, - status: params?.status, - sortBy: params?.sortBy, - sortOrder: params?.sortOrder, - tenantId: params?.tenantId, - }, - }); + const normalizedParams = { + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 20, + search: params?.search, + criteria: params?.criteria, + status: params?.status, + sortBy: params?.sortBy, + sortOrder: params?.sortOrder, + tenantId: params?.tenantId, + }; + + const { data } = await httpClient.get( + `/user-accounts?${buildUserAccountQueryString(normalizedParams)}` + ); const pageResult = UserAccountPageSchema.safeParse(data); if (!pageResult.success) { @@ -58,7 +75,7 @@ export const userAccountService = { }, getById: async (userAccountId: string): Promise => { - const { data } = await httpClient.get(`/user-accounts/${userAccountId}`); + const { data } = await httpClient.get(`/user-accounts/${userAccountId}`); return UserAccountSchema.parse(data); }, diff --git a/src/apps/ums.web-app/src/main.tsx b/src/apps/ums.web-app/src/main.tsx index a3227620..554c1905 100644 --- a/src/apps/ums.web-app/src/main.tsx +++ b/src/apps/ums.web-app/src/main.tsx @@ -17,15 +17,22 @@ const queryClient = new QueryClient({ }, }); -const devContextProvider = () => { - if (!import.meta.env.DEV) return {}; - const { devUserId, devLanguage } = useDevToolsStore.getState(); +const requestContextProvider = () => { const { user } = useAuthStore.getState(); - useI18nStore.getState().setLanguage(devLanguage); - return { userId: devUserId, language: devLanguage, tenantId: user?.tenantId }; + + if (import.meta.env.DEV) { + const { devUserId, devLanguage } = useDevToolsStore.getState(); + useI18nStore.getState().setLanguage(devLanguage); + return { userId: devUserId, language: devLanguage, tenantId: user?.tenantId }; + } + + // Producción (G-026): el contexto de tenant proviene de la sesión autenticada + // (`user.tenantId`), no de un tenant por defecto. La identidad del usuario la + // resuelve el backend desde el JWT; no se inyecta un X-User-Id de desarrollo. + return { language: useI18nStore.getState().language, tenantId: user?.tenantId }; }; -configureRequestContext(devContextProvider); +configureRequestContext(requestContextProvider); function LocaleSync() { const lang = useI18nStore(s => s.language); diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateDetailPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateDetailPanel.tsx index 5fb61eeb..cde02de3 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateDetailPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateDetailPanel.tsx @@ -26,8 +26,9 @@ import { import type { PermissionTemplateDetail, ExclusiveArcTarget, + PermissionEffect, } from '@domain/authorization/models/permission-template.model'; -import { itemEffect } from '@domain/authorization/models/permission-template.model'; +import { itemEffect, primaryItem } from '@domain/authorization/models/permission-template.model'; import { useGetSystemSuite } from '@app/authorization/hooks/use-system-suite'; import { DetailPanelShell, type DetailTab } from '@shared/components/DetailPanelShell'; import { StatusBadge } from '@shared/components/StatusBadge'; @@ -37,9 +38,7 @@ import { usePublishPermissionTemplate, useDeprecatePermissionTemplate, useDeletePermissionTemplate, - useSetTemplateItemEffect, - useRemoveTemplateItem, - useAddTemplateItem, + useApplyTemplateItemEffect, } from '@app/authorization/hooks/use-permission-template'; import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; import type { PermissionTemplateItem } from '@domain/authorization/models/permission-template.model'; @@ -350,12 +349,12 @@ const ModuleNodeDetailPanel: React.FC = ({ isDraft, allItems, }) => { - const setEffect = useSetTemplateItemEffect(templateId); - const removeItem = useRemoveTemplateItem(templateId); - const addItem = useAddTemplateItem(templateId); + const { applyEffect, isPending } = useApplyTemplateItemEffect(templateId); const addNotification = useNotificationStore(s => s.addNotification); - const selfItem = node.items[0]; + // Manda la fila vigente; la retirada solo se conserva para poder reactivarla (ADR-0164). + const selfItem = primaryItem(node.items); + const selfEffect: PermissionEffect = selfItem ? itemEffect(selfItem) : 'Neutral'; const computeNodeEffectiveState = (): 'Allow' | 'Deny' | 'Neutral' => { const selfEffects = node.items.map(itemEffect); @@ -390,31 +389,25 @@ const ModuleNodeDetailPanel: React.FC = ({ return 'Option'; }; - const handleApplyEffect = async (effect: 'Allow' | 'Deny' | 'Neutral') => { + const resolveActionId = (): string => { + const byCode = node.actionCode + ? suite?.actions.find(a => a.code === node.actionCode)?.id + : undefined; + return byCode ?? suite?.actions[0]?.id ?? '00000000-0000-0000-0000-000000000000'; + }; + + const handleApplyEffect = async (effect: PermissionEffect) => { if (!isDraft) return; - if (effect === 'Neutral') { - if (selfItem) await removeItem.mutateAsync(selfItem.itemId); - return; - } - if (selfItem) { - await setEffect.mutateAsync({ itemId: selfItem.itemId, effect }); - } else { - let actionId = ''; - if (node.actionCode) { - const action = suite?.actions.find(a => a.code === node.actionCode); - if (action) actionId = action.id; - } - if (!actionId && suite && suite.actions.length > 0) { - actionId = suite.actions[0].id; - } - await addItem.mutateAsync({ + // El adaptador elige el verbo: alta, efecto o reactivación del ítem retirado (ADR-0164). + await applyEffect({ + effect, + item: selfItem, + target: { targetType: mapTypeToTarget(node.type), targetId: node.id, - actionId: actionId || '00000000-0000-0000-0000-000000000000', - isAllowed: effect === 'Allow', - isDenied: effect === 'Deny', - }); - } + actionId: resolveActionId(), + }, + }); if (effect === 'Allow' && suite) { const ascendants = getAscendantsWithTypes(suite, node.id); @@ -422,22 +415,19 @@ const ModuleNodeDetailPanel: React.FC = ({ let changedParents = false; const elementsToProcess = [...ascendants, ...siblingViews]; for (const asc of elementsToProcess) { - const parentItem = allItems.find(i => i.targetId === asc.id); - if (parentItem) { - if (itemEffect(parentItem) !== 'Allow') { - await setEffect.mutateAsync({ itemId: parentItem.itemId, effect: 'Allow' }); - changedParents = true; - } - } else { - await addItem.mutateAsync({ + // Los ascendientes pasan por el mismo adaptador: si alguno está retirado hay que + // reactivarlo, porque volver a darlo de alta chocaría con su clave (409). + const parentItem = primaryItem(allItems.filter(i => i.targetId === asc.id)); + const changed = await applyEffect({ + effect: 'Allow', + item: parentItem, + target: { targetType: asc.type, targetId: asc.id, actionId: '00000000-0000-0000-0000-000000000000', - isAllowed: true, - isDenied: false, - }); - changedParents = true; - } + }, + }); + changedParents = changedParents || changed; } if (changedParents) { addNotification({ @@ -493,10 +483,10 @@ const ModuleNodeDetailPanel: React.FC = ({
diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/DomainResourcesPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/DomainResourcesPanel.tsx index d3a5f607..9b2dc93a 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/DomainResourcesPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/DomainResourcesPanel.tsx @@ -18,7 +18,7 @@ import { } from 'lucide-react'; import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; import type { PermissionTemplateItem } from '@domain/authorization/models/permission-template.model'; -import { itemEffect } from '@domain/authorization/models/permission-template.model'; +import { itemEffect, isRetiredItem } from '@domain/authorization/models/permission-template.model'; import { PermissionSectionToolbar } from '@shared/components/PermissionSectionToolbar'; import { CodeBadge } from '@shared/components/CodeBadge'; @@ -247,6 +247,8 @@ const DomainResourceRow: React.FC<{ }> = ({ node, isExpanded, isSelected, hasChildren, onToggle, onSelect }) => { const state = computeNodeState(node); const stateInfo = STATE_ICON[state]; + // Las filas retiradas no se cuentan: sobreviven en la tabla pero no conceden nada (ADR-0164). + const directRuleCount = node.items.filter(item => !isRetiredItem(item)).length; const icon = node.type === 'CrudOperation' @@ -291,9 +293,9 @@ const DomainResourceRow: React.FC<{ - {node.items.length > 0 && ( + {directRuleCount > 0 && ( - {node.items.length} + {directRuleCount} )} diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/ModulePermissionsPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/ModulePermissionsPanel.tsx index a420e72b..44f3cacb 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/ModulePermissionsPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/ModulePermissionsPanel.tsx @@ -11,8 +11,9 @@ import { MinusCircle, } from 'lucide-react'; import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; import type { PermissionTemplateItem } from '@domain/authorization/models/permission-template.model'; -import { itemEffect } from '@domain/authorization/models/permission-template.model'; +import { itemEffect, isRetiredItem } from '@domain/authorization/models/permission-template.model'; import { PermissionSectionToolbar } from '@shared/components/PermissionSectionToolbar'; import { CodeBadge } from '@shared/components/CodeBadge'; @@ -51,49 +52,32 @@ function buildModuleTree( {} as Record ); - return suite.modules.map(mod => { - const buildMenuTree = (menus: typeof mod.menus, level: number): ModulePermNode[] => - menus.map(menu => ({ - id: menu.id, - type: 'Menu' as const, - label: menu.label, - code: menu.code, - description: menu.description, + // Mapea recursivamente los nodos del árbol del SystemSuite (ADR-0090). + const buildNodeTree = (nodes: SystemSuiteNode[], level: number): ModulePermNode[] => + [...nodes] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(node => ({ + id: node.id, + type: node.kind as ModulePermNode['type'], + label: node.label, + code: node.code, + description: node.description, + actionCode: node.actionCodes[0], level, - items: itemsByTargetId[menu.id] || [], - children: menu.subMenus.map(sm => ({ - id: sm.id, - type: 'SubMenu' as const, - label: sm.label, - code: sm.code, - description: sm.description, - level: level + 1, - items: itemsByTargetId[sm.id] || [], - children: sm.options.map(opt => ({ - id: opt.id, - type: 'Option' as const, - label: opt.label, - code: opt.code, - description: opt.description, - actionCode: opt.actionCode, - level: level + 2, - items: itemsByTargetId[opt.id] || [], - children: [], - })), - })), + items: itemsByTargetId[node.id] || [], + children: buildNodeTree(node.children ?? [], level + 1), })); - return { - id: mod.id, - type: 'Module' as const, - label: mod.name, - code: mod.code, - description: mod.description, - level: 0, - items: itemsByTargetId[mod.id] || [], - children: buildMenuTree(mod.menus, 1), - }; - }); + return suite.modules.map(mod => ({ + id: mod.id, + type: 'Module' as const, + label: mod.name, + code: mod.code, + description: mod.description, + level: 0, + items: itemsByTargetId[mod.id] || [], + children: buildNodeTree(mod.nodes ?? [], 1), + })); } function flattenTree(nodes: ModulePermNode[]): ModulePermNode[] { @@ -172,7 +156,9 @@ const ModuleRow: React.FC<{ const state = computeNodeState(node); const stateInfo = STATE_ICON[state]; - const hasDirectPermission = node.items.length > 0; + // Solo cuentan las filas vigentes: una retirada no es una regla directa (ADR-0164). + const directRuleCount = node.items.filter(item => !isRetiredItem(item)).length; + const hasDirectPermission = directRuleCount > 0; const isInherited = !hasDirectPermission && state !== 'Neutral'; return ( @@ -214,9 +200,9 @@ const ModuleRow: React.FC<{ - {node.items.length > 0 && ( + {directRuleCount > 0 && ( - {node.items.length} + {directRuleCount} )} diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/NodeDetailPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/NodeDetailPanel.tsx index 7bf29d5a..7a90adec 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/NodeDetailPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/NodeDetailPanel.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { UITreeNodeData, computeEffectiveState } from './TreeNode'; import { Shield, CheckCircle2, XCircle, MinusCircle, Info } from 'lucide-react'; -import { - useSetTemplateItemEffect, - useRemoveTemplateItem, - useAddTemplateItem, -} from '@app/authorization/hooks/use-permission-template'; +import { useApplyTemplateItemEffect } from '@app/authorization/hooks/use-permission-template'; import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; -import type { ExclusiveArcTarget } from '@domain/authorization/models/permission-template.model'; +import type { + ExclusiveArcTarget, + PermissionEffect, +} from '@domain/authorization/models/permission-template.model'; +import { itemEffect, primaryItem } from '@domain/authorization/models/permission-template.model'; interface NodeDetailPanelProps { node: UITreeNodeData | null; @@ -22,9 +22,7 @@ export const NodeDetailPanel: React.FC = ({ templateId, isDraft, }) => { - const setEffect = useSetTemplateItemEffect(templateId); - const removeItem = useRemoveTemplateItem(templateId); - const addItem = useAddTemplateItem(templateId); + const { applyEffect, isPending } = useApplyTemplateItemEffect(templateId); if (!node || !suite) { return ( @@ -39,7 +37,10 @@ export const NodeDetailPanel: React.FC = ({ } const effectiveState = computeEffectiveState(node); - const selfItem = node.items[0]; // If there are multiple for some reason, we take the first. + // Si hay varias filas sobre el mismo nodo manda la vigente; la retirada solo sirve para reactivar. + const selfItem = primaryItem(node.items); + // Lo que la regla directa concede HOY: una fila retirada no concede nada, luego es Neutral. + const selfEffect: PermissionEffect = selfItem ? itemEffect(selfItem) : 'Neutral'; const StateIcon = effectiveState === 'Allow' @@ -76,39 +77,26 @@ export const NodeDetailPanel: React.FC = ({ return 'Option'; // Option for SubMenu and Option }; - const handleApplyEffect = async (effect: 'Allow' | 'Deny' | 'Neutral') => { - if (!isDraft) return; + const resolveActionId = (): string => { + // Si el nodo es una opción trae su actionCode; si no, el backend exige una acción cualquiera. + const byCode = node.actionCode + ? suite.actions.find(a => a.code === node.actionCode)?.id + : undefined; + return byCode ?? suite.actions[0]?.id ?? '00000000-0000-0000-0000-000000000000'; + }; - if (effect === 'Neutral') { - if (selfItem) { - await removeItem.mutateAsync(selfItem.itemId); - } - return; - } - - if (selfItem) { - await setEffect.mutateAsync({ itemId: selfItem.itemId, effect }); - } else { - // Find an actionId. If it's an option, it has an actionCode. We can find the action in the suite. - let actionId = ''; - if (node.actionCode) { - const action = suite.actions.find(a => a.code === node.actionCode); - if (action) actionId = action.id; - } - - // Fallback to first available action if not found (required by backend) - if (!actionId && suite.actions.length > 0) { - actionId = suite.actions[0].id; - } - - await addItem.mutateAsync({ + const handleApplyEffect = async (effect: PermissionEffect) => { + if (!isDraft) return; + // El adaptador decide el verbo: alta, efecto o reactivación del ítem retirado (ADR-0164). + await applyEffect({ + effect, + item: selfItem, + target: { targetType: mapTypeToExclusiveArcTarget(node.type), targetId: node.id, - actionId: actionId || '00000000-0000-0000-0000-000000000000', - isAllowed: effect === 'Allow', - isDenied: effect === 'Deny', - }); - } + actionId: resolveActionId(), + }, + }); }; return ( @@ -154,10 +142,10 @@ export const NodeDetailPanel: React.FC = ({
@@ -165,7 +158,7 @@ export const SystemActionsPanel: React.FC = ({
+ ) : undefined + } /> - {/* Collapse / Expand all toggle */} - {allModuleNodeIds.length > 0 && ( -
- -
- )} - + {/* Formulario inline de nuevo módulo — el trigger vive en el toolbar (onAdd) */} { @@ -327,12 +329,12 @@ export const SystemSuiteDetailPanel: React.FC = ({ if (!open) setModError(''); }} onSubmit={handleAddModule} - addLabel="+" + addLabel="Módulo" title="Nuevo Módulo Estructural" cancelLabel={t.cancelEdit} submitLabel="Guardar Módulo" isLoading={addModuleMutation.isPending} - triggerEmphasis="quiet" + triggerEmphasis="none" error={modError || undefined} > = ({ ); } - if (modulesViewMode === 'thumbnail') { - return ( -
- {filteredModules.map(module => { - const moduleNodeId = `module-${module.id}`; - const moduleExpanded = isExpanded(moduleNodeId); - return ( - toggleNode(moduleNodeId)} - onToggleNode={toggleNode} - onDeactivate={() => deactivateModuleMutation.mutate(module.id)} - onActivate={() => activateModuleMutation.mutate(module.id)} - onRemove={() => removeModuleMutation.mutate(module.id)} - isDeactivating={deactivateModuleMutation.isPending} - isActivating={activateModuleMutation.isPending} - isRemoving={removeModuleMutation.isPending} - /> - ); - })} -
- ); - } + const containerClass = + modulesViewMode === 'thumbnail' + ? 'grid grid-cols-1 sm:grid-cols-2 gap-3 animate-fadeIn' + : 'space-y-2 animate-fadeIn'; return ( -
+
{filteredModules.map(module => { const moduleNodeId = `module-${module.id}`; const moduleExpanded = isExpanded(moduleNodeId); return ( - toggleNode(moduleNodeId)} @@ -463,7 +443,7 @@ export const SystemSuiteDetailPanel: React.FC = ({ {/* ── Actions ── */} {activeTab === 'actions' && ( -
+
= ({ onSortOrderToggle={() => setActionsSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))} itemCount={activeSystemSuite.actions?.length ?? 0} itemLabel="Acción" + onAdd={() => { + setIsAddingAction(true); + setActError(''); + }} + addLabel="Nueva Acción del Sistema" /> = ({ if (!open) setActError(''); }} onSubmit={handleRegisterAction} - addLabel="+" + addLabel="Acción" title="Nueva Acción del Sistema" cancelLabel={t.cancelEdit} submitLabel="Guardar Acción" isLoading={registerActionMutation.isPending} - triggerEmphasis="quiet" + triggerEmphasis="none" error={actError || undefined} > +
setSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))} itemCount={domainResources.length} itemLabel="Recurso" + onAdd={() => { + setIsAddingResource(true); + setResError(''); + }} + addLabel="Nuevo Recurso de Dominio" /> -
+
setCriteriaType(e.target.value)} @@ -374,6 +374,7 @@ export const SystemSuiteFeatureFlagsPanel: React.FC setSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))} itemCount={flags.length} itemLabel="flag" + itemLabelPlural="flags" /> {filteredFlags.length === 0 ? ( diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.test.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.test.tsx new file mode 100644 index 00000000..a689fe57 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { SystemSuiteForm } from './SystemSuiteForm'; + +// Contrato G-107: la descripción es requerida (el backend exige +// `Description.NotEmpty()`). Estos tests fijan que el formulario alinea la UI al +// contrato: indicador visual de requerido, mensaje de error y bloqueo del envío. + +const mutateAsync = vi.fn(); + +vi.mock('@app/authorization/hooks/use-system-suite', () => ({ + useCreateSystemSuite: () => ({ mutateAsync, isPending: false }), +})); + +vi.mock('@app/shared/hooks/use-effective-tenant', () => ({ + useEffectiveTenant: () => '5f4e3d2c-1b2a-3c4d-5e6f-7a8b9c0d1e2f', +})); + +const renderForm = () => render(); + +const fill = (placeholder: string, value: string) => { + fireEvent.change(screen.getByPlaceholderText(placeholder), { target: { value } }); +}; + +describe('SystemSuiteForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + mutateAsync.mockResolvedValue({ systemSuiteId: 'new-id' }); + }); + + it('marca la descripción como campo requerido (asterisco visual)', () => { + renderForm(); + const descLabel = screen.getByText('Descripción'); + expect(descLabel).toHaveTextContent('*'); + }); + + it('no envía y muestra el error requerido cuando la descripción está vacía', async () => { + renderForm(); + + fill('SUITE_CRM', 'CRM'); + fill('CRM System Suite', 'CRM System'); + // Descripción se deja vacía a propósito. + + fireEvent.click(screen.getByRole('button', { name: /Registrar Suite/i })); + + expect(await screen.findByText('Descripción requerida')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('no envía cuando la descripción es solo espacios en blanco', async () => { + renderForm(); + + fill('SUITE_CRM', 'CRM'); + fill('CRM System Suite', 'CRM System'); + fill('Customer relationship management module', ' '); + + fireEvent.click(screen.getByRole('button', { name: /Registrar Suite/i })); + + expect(await screen.findByText('Descripción requerida')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('envía el payload con la descripción cuando el formulario es válido', async () => { + renderForm(); + + fill('SUITE_CRM', 'CRM'); + fill('CRM System Suite', 'CRM System'); + fill('Customer relationship management module', 'Gestión CRM'); + + fireEvent.click(screen.getByRole('button', { name: /Registrar Suite/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: '5f4e3d2c-1b2a-3c4d-5e6f-7a8b9c0d1e2f', + code: 'CRM', + name: 'CRM System', + description: 'Gestión CRM', + }) + ); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.tsx index 929be0c6..17a96bbc 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.tsx @@ -44,7 +44,9 @@ export const SystemSuiteForm: React.FC = ({ tenantId: effectiveTenantId, code, name, - description: description || undefined, + // Enviamos la cadena (recortada) — no `undefined` — para que la validación + // requerida de descripción se dispare antes del POST y no en un 400 (G-107). + description: description.trim(), }; const validData = validate(payload); if (!validData) return; @@ -99,10 +101,11 @@ export const SystemSuiteForm: React.FC = ({ /> - + setDescription(e.target.value)} + error={!!errors.description} placeholder="Customer relationship management module" /> diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteListPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteListPanel.tsx index 476ad5d0..fa69ea7f 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteListPanel.tsx @@ -155,7 +155,7 @@ export const SystemSuiteListPanel: React.FC = ({ totalItems={paginationState.totalItems} startIndex={paginationState.startIndex ?? 0} pageSize={paginationState.pageSize} - itemLabel={t.systemSuites ?? 'System Suites'} + itemLabel={t.systemSuites ?? 'suites'} onClear={queryState.handleResetQuery} searchTerm={queryState.appliedQuery.term} /> diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteProfileCard.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteProfileCard.tsx index 6700445b..f3b4325d 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteProfileCard.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteProfileCard.tsx @@ -81,13 +81,12 @@ export const SystemSuiteProfileCard: React.FC = ({ }; const handleToggleStatus = (newStatus: 'Active' | 'Maintenance' | 'Deprecated') => { - onSystemSuiteUpdate(systemSuite.systemSuiteId, { status: newStatus }); - addNotification({ - title: t.notifStatusChanged, - message: t.notifStatusSetTo(newStatus), - type: newStatus === 'Active' ? 'success' : 'warning', + // La mutación (useNotifiedMutation) es dueña de la notificación success/error e invalida + // el query. El estado local se refleja sólo en onSuccess: si el backend rechaza el cambio, + // no se deja el botón en un estado falso ni se dispara un aviso prematuro (bug corregido, G-129). + setStatusMutation.mutate(newStatus, { + onSuccess: () => onSystemSuiteUpdate(systemSuite.systemSuiteId, { status: newStatus }), }); - setStatusMutation.mutate(newStatus, { onError: () => {} }); }; const renderActions = () => { diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx index 9ac6f9a7..f4147d05 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx @@ -99,7 +99,7 @@ export const SystemSuiteRolesPanel: React.FC = ({ systemSuiteId }) => { }); return ( -
+
= ({ systemSuiteId }) => { onSortOrderToggle={() => setSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))} itemCount={roles.length} itemLabel="Rol" + onAdd={() => { + setAdding(true); + setError(''); + }} + addLabel={t.newRole} /> = ({ systemSuiteId }) => { cancelLabel={t.cancelEdit} submitLabel={t.saveRole} isLoading={createRole.isPending} - triggerEmphasis="quiet" + triggerEmphasis="none" error={error || undefined} > = ({ } return ( -
+
{role.value} {role.isActive ? t.active : t.inactive} diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/MenuRow.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/MenuRow.tsx deleted file mode 100644 index b7fc1f03..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/MenuRow.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import React, { useState } from 'react'; -import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Trash2 } from 'lucide-react'; -import { useInlineEdit } from '@app/hooks/use-inline-edit'; -import { - useAddSubMenu, - useRemoveSubMenu, - useUpdateMenu, -} from '@app/authorization/hooks/use-system-suite'; -import { M3TextField } from '@shared/components/M3TextField'; -import { InlineAddForm } from '@shared/components/InlineAddForm'; -import { IconButton } from '@shared/components/Tooltip'; -import { CodeBadge } from '@shared/components/CodeBadge'; -import { ErrorDisplay } from '@shared/components/data-display/ErrorDisplay'; -import { formatSystemCode } from '@app/utils/security'; -import { SubMenuRow } from './SubMenuRow'; -import { AddSubState, emptySub } from './types'; - -type MenuType = { - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - subMenus?: Array<{ - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - options?: Array<{ - id: string; - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder?: number; - }>; - }>; -}; - -interface MenuRowProps { - suiteId: string; - moduleId: string; - menu: MenuType; - isExpanded: boolean; - isSubExpanded: (id: string) => boolean; - onToggle: () => void; - onToggleSub: (id: string) => void; - onRemoveMenu: (id: string) => void; - isRemovingMenu: boolean; -} - -interface MenuDraft { - label: string; - description: string; - sortOrder: number; -} - -export const MenuRow: React.FC = ({ - suiteId, - moduleId, - menu, - isExpanded, - isSubExpanded, - onToggle, - onToggleSub, - onRemoveMenu, - isRemovingMenu, -}) => { - const [isAddingSub, setIsAddingSub] = useState(false); - const [sub, setSub] = useState(emptySub()); - const [editError, setEditError] = useState(''); - - const addSubMenuMutation = useAddSubMenu(suiteId, moduleId, menu.id); - const removeSubMenuMutation = useRemoveSubMenu(suiteId, moduleId, menu.id); - const updateMenuMutation = useUpdateMenu(suiteId, moduleId, menu.id); - - const edit = useInlineEdit(['label', 'description', 'sortOrder']); - - const handleAddSubMenu = async (e: React.FormEvent) => { - e.preventDefault(); - if (!sub.code.trim()) { - setSub(s => ({ ...s, error: 'Código requerido' })); - return; - } - if (!sub.label.trim()) { - setSub(s => ({ ...s, error: 'Etiqueta requerida' })); - return; - } - try { - await addSubMenuMutation.mutateAsync({ - code: formatSystemCode(sub.code), - label: sub.label.trim(), - description: sub.desc.trim(), - sortOrder: parseInt(sub.sort) || 1, - }); - setSub(emptySub()); - setIsAddingSub(false); - } catch { - /* handled by hook */ - } - }; - - const handleStartEditMenu = () => { - edit.openEdit(menu.id, { - label: menu.label, - description: menu.description ?? '', - sortOrder: menu.sortOrder ?? 1, - }); - setEditError(''); - }; - - const handleUpdateMenu = async (e: React.FormEvent) => { - e.preventDefault(); - const label = edit.draft.label?.trim() ?? ''; - if (!label) { - setEditError('Etiqueta requerida'); - return; - } - try { - await updateMenuMutation.mutateAsync({ - label, - description: edit.draft.description?.trim() ?? '', - sortOrder: Number(edit.draft.sortOrder) || 1, - }); - edit.cancelEdit(); - setEditError(''); - } catch { - /* handled by hook */ - } - }; - - const handleCancelEditMenu = () => { - edit.cancelEdit(); - setEditError(''); - }; - - return ( -
- {edit.isEditing(menu.id) ? ( -
-
- edit.setField('label', e.target.value)} - /> - edit.setField('sortOrder', parseInt(e.target.value) || 1)} - /> -
- edit.setField('description', e.target.value)} - /> - -
- - -
- - ) : ( -
-
- {isExpanded ? ( - - ) : ( - - )} - {isExpanded ? ( - - ) : ( - - )} -
- {menu.label} - -
-
-
- {isExpanded && ( - - )} - - - - onRemoveMenu(menu.id)} - disabled={isRemovingMenu} - className="hover:text-m3-error hover:bg-m3-error/10" - > - - -
-
- )} - -
- {isAddingSub && ( - { - setIsAddingSub(open); - if (!open) setSub(emptySub()); - }} - onSubmit={handleAddSubMenu} - addLabel="Submenú" - title="Nuevo Submenú" - cancelLabel="Cancelar" - submitLabel="Guardar Submenú" - isLoading={addSubMenuMutation.isPending} - error={sub.error || undefined} - > - setSub(s => ({ ...s, code: e.target.value }))} - placeholder="e.g. USERS" - /> - setSub(s => ({ ...s, label: e.target.value }))} - placeholder="e.g. Gestión de Usuarios" - /> - setSub(s => ({ ...s, desc: e.target.value }))} - placeholder="Opcional" - /> - setSub(s => ({ ...s, sort: e.target.value }))} - placeholder="1" - /> - - )} - - {!menu.subMenus || menu.subMenus.length === 0 ? ( -

No hay submenús configurados.

- ) : ( -
- {menu.subMenus.map(subMenu => ( - onToggleSub(subMenu.id)} - onRemoveSubMenu={id => removeSubMenuMutation.mutate(id)} - isRemovingSubMenu={removeSubMenuMutation.isPending} - /> - ))} -
- )} -
-
- ); -}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/ModuleCard.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/ModuleCard.tsx deleted file mode 100644 index 5b60bd65..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/ModuleCard.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { useState } from 'react'; -import { ChevronDown, ChevronRight, Layers, EyeOff, ShieldCheck, Trash2 } from 'lucide-react'; -import { - useAddMenu, - useRemoveMenu, - useActivateModule, - useDeactivateModule, -} from '@app/authorization/hooks/use-system-suite'; -import { InlineAddForm } from '@shared/components/InlineAddForm'; -import { IconButton } from '@shared/components/Tooltip'; -import { CodeBadge } from '@shared/components/CodeBadge'; -import { StatusBadge } from '@shared/components/StatusBadge'; -import { M3TextField } from '@shared/components/M3TextField'; -import { formatSystemCode } from '@app/utils/security'; -import { MenuRow } from './MenuRow'; -import { AddMenuState, emptyMenu } from './types'; - -type ModuleType = { - id: string; - code: string; - name: string; - description?: string; - status: string; - sortOrder?: number; - menus?: Array<{ - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - subMenus?: Array<{ - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - options?: Array<{ - id: string; - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder?: number; - }>; - }>; - }>; -}; - -interface ModuleCardProps { - suiteId: string; - module: ModuleType; - isExpanded: boolean; - isNodeExpanded: (id: string) => boolean; - onToggle: () => void; - onToggleNode: (id: string) => void; - onDeactivate: () => void; - onActivate: () => void; - onRemove: () => void; - isDeactivating: boolean; - isActivating: boolean; - isRemoving: boolean; -} - -export const ModuleCard: React.FC = ({ - suiteId, - module, - isExpanded, - isNodeExpanded, - onToggle, - onToggleNode, - onDeactivate, - onActivate, - onRemove, - isDeactivating, - isActivating, - isRemoving, -}) => { - const [isAddingMenu, setIsAddingMenu] = useState(false); - const [menu, setMenu] = useState(emptyMenu()); - - const addMenuMutation = useAddMenu(suiteId, module.id); - const removeMenuMutation = useRemoveMenu(suiteId, module.id); - - const handleAddMenu = async (e: React.FormEvent) => { - e.preventDefault(); - if (!menu.code.trim()) { - setMenu(s => ({ ...s, error: 'Código requerido' })); - return; - } - if (!menu.label.trim()) { - setMenu(s => ({ ...s, error: 'Etiqueta requerida' })); - return; - } - try { - await addMenuMutation.mutateAsync({ - code: formatSystemCode(menu.code), - label: menu.label.trim(), - description: menu.desc.trim(), - sortOrder: parseInt(menu.sort) || 1, - }); - setMenu(emptyMenu()); - setIsAddingMenu(false); - } catch { - /* handled by hook */ - } - }; - - return ( -
-
-
- {isExpanded ? ( - - ) : ( - - )} - -
- {module.name} - -
-
-
- - Ord: {module.sortOrder} -
- {module.status === 'Active' ? ( - { - e.stopPropagation(); - onDeactivate(); - }} - disabled={isDeactivating} - > - - - ) : ( - { - e.stopPropagation(); - onActivate(); - }} - disabled={isActivating} - > - - - )} - { - e.stopPropagation(); - onRemove(); - }} - disabled={isRemoving} - className="hover:text-m3-error hover:bg-m3-error/10" - > - - -
-
-
- -
- {module.description && ( -

- {module.description} -

- )} - - { - setIsAddingMenu(open); - if (!open) setMenu(emptyMenu()); - }} - onSubmit={handleAddMenu} - addLabel="+" - title="Nuevo Menú" - cancelLabel="Cancelar" - submitLabel="Guardar Menú" - isLoading={addMenuMutation.isPending} - error={menu.error || undefined} - > - setMenu(s => ({ ...s, code: e.target.value }))} - placeholder="e.g. ADMIN" - /> - setMenu(s => ({ ...s, label: e.target.value }))} - placeholder="e.g. Administración" - /> - setMenu(s => ({ ...s, desc: e.target.value }))} - placeholder="Opcional" - /> - setMenu(s => ({ ...s, sort: e.target.value }))} - placeholder="1" - /> - - - {!module.menus || module.menus.length === 0 ? ( -

- No hay menús configurados. -

- ) : ( -
- {module.menus.map(menuItem => ( - onToggleNode(menuItem.id)} - onToggleSub={onToggleNode} - onRemoveMenu={id => removeMenuMutation.mutate(id)} - isRemovingMenu={removeMenuMutation.isPending} - /> - ))} -
- )} -
-
- ); -}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeActionsPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeActionsPanel.tsx new file mode 100644 index 00000000..b535eb24 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeActionsPanel.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { Key, Plus, X } from 'lucide-react'; +import { M3Select } from '@shared/components/M3Select'; +import { IconButton } from '@shared/components/Tooltip'; + +export interface SuiteActionOption { + code: string; + name: string; +} + +interface NodeActionsPanelProps { + actionCodes: string[]; + availableActions: SuiteActionOption[]; + isLinking: boolean; + isUnlinking: boolean; + onLink: (actionCode: string) => void; + onUnlink: (actionCode: string) => void; +} + +/** + * Panel del vínculo N:M funcionalidad↔nodo (ADR-0090). + * Muestra las funcionalidades vinculadas como chips y permite añadir + * desde el registro de acciones de la suite (multi-select acumulativo). + */ +export const NodeActionsPanel: React.FC = ({ + actionCodes, + availableActions, + isLinking, + isUnlinking, + onLink, + onUnlink, +}) => { + const [selected, setSelected] = useState(''); + + const unlinked = availableActions.filter(a => !actionCodes.includes(a.code)); + + const nameOf = (code: string) => availableActions.find(a => a.code === code)?.name ?? code; + + const handleLink = () => { + if (!selected) return; + onLink(selected); + setSelected(''); + }; + + return ( +
+
+ Funcionalidades vinculadas (N:M) +
+ + {actionCodes.length === 0 ? ( +

Sin funcionalidades vinculadas.

+ ) : ( +
+ {actionCodes.map(code => ( + + {code} + + + ))} +
+ )} + + {unlinked.length > 0 && ( +
+
+ setSelected(e.target.value)} + className="mb-0" + > + + {unlinked.map(a => ( + + ))} + +
+ + + +
+ )} +
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeMetadataDialog.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeMetadataDialog.tsx new file mode 100644 index 00000000..74650a7a --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeMetadataDialog.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useState } from 'react'; +import { ClipboardList } from 'lucide-react'; +import { M3FormDialog } from '@shared/components/M3FormDialog'; +import { M3TextField } from '@shared/components/M3TextField'; +import { M3Button } from '@shared/components/M3Button'; +import type { + SystemSuiteNode, + SystemSuiteNodeMetadata, +} from '@domain/authorization/schemas/system-suite.schema'; + +interface NodeMetadataDialogProps { + open: boolean; + node: SystemSuiteNode; + isSaving: boolean; + onClose: () => void; + onSave: (metadata: SystemSuiteNodeMetadata) => void; +} + +type MetaState = { + responsable: string; + criticidad: string; + productoImpactado: string; + componenteTecnico: string; + dependencias: string; + evidencias: string; + trazabilidadSdlc: string; +}; + +const fromNode = (m: SystemSuiteNodeMetadata | null | undefined): MetaState => ({ + responsable: m?.responsable ?? '', + criticidad: m?.criticidad ?? '', + productoImpactado: m?.productoImpactado ?? '', + componenteTecnico: m?.componenteTecnico ?? '', + dependencias: m?.dependencias ?? '', + evidencias: m?.evidencias ?? '', + trazabilidadSdlc: m?.trazabilidadSdlc ?? '', +}); + +/** + * Editor de metadatos de gobernanza SDLC por nodo (ADR-0090). + * Todos los campos son opcionales; se envía el conjunto completo (reemplazo). + */ +export const NodeMetadataDialog: React.FC = ({ + open, + node, + isSaving, + onClose, + onSave, +}) => { + const [state, setState] = useState(fromNode(node.metadata)); + + useEffect(() => { + if (open) setState(fromNode(node.metadata)); + }, [open, node.metadata]); + + const set = (key: keyof MetaState) => (e: React.ChangeEvent) => + setState(s => ({ ...s, [key]: e.target.value })); + + const handleSave = () => { + const trimmed = (v: string) => (v.trim() === '' ? null : v.trim()); + onSave({ + responsable: trimmed(state.responsable), + criticidad: trimmed(state.criticidad), + productoImpactado: trimmed(state.productoImpactado), + componenteTecnico: trimmed(state.componenteTecnico), + dependencias: trimmed(state.dependencias), + evidencias: trimmed(state.evidencias), + trazabilidadSdlc: trimmed(state.trazabilidadSdlc), + }); + }; + + return ( + } + maxWidth="max-w-xl" + footer={ + <> + + Cancelar + + + {isSaving ? 'Guardando…' : 'Guardar Metadatos'} + + + } + > +
+ + + + + + +
+ +
+
+
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/OptionRow.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/OptionRow.tsx deleted file mode 100644 index 1d5e7cb8..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/OptionRow.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React, { useState } from 'react'; -import { Pencil, Trash2, KeyRound } from 'lucide-react'; -import { useInlineEdit } from '@app/hooks/use-inline-edit'; -import { useUpdateOption, useRemoveOption } from '@app/authorization/hooks/use-system-suite'; -import { M3TextField } from '@shared/components/M3TextField'; -import { IconButton } from '@shared/components/Tooltip'; -import { CodeBadge } from '@shared/components/CodeBadge'; -import { ErrorDisplay } from '@shared/components/data-display/ErrorDisplay'; -import { formatSystemCode } from '@app/utils/security'; - -type OptionType = { - id: string; - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder?: number; -}; - -interface OptionRowProps { - suiteId: string; - moduleId: string; - menuId: string; - subMenuId: string; - option: OptionType; -} - -interface OptionDraft { - label: string; - description: string; - actionCode: string; - sortOrder: number; -} - -export const OptionRow: React.FC = ({ - suiteId, - moduleId, - menuId, - subMenuId, - option, -}) => { - const [editError, setEditError] = useState(''); - const updateOptionMutation = useUpdateOption(suiteId, moduleId, menuId, subMenuId, option.id); - const removeOptionMutation = useRemoveOption(suiteId, moduleId, menuId, subMenuId, option.id); - - const edit = useInlineEdit(['label', 'description', 'actionCode', 'sortOrder']); - - const handleStartEdit = () => { - edit.openEdit(option.id, { - label: option.label, - description: option.description ?? '', - actionCode: option.actionCode, - sortOrder: option.sortOrder ?? 1, - }); - setEditError(''); - }; - - const handleUpdate = async (e: React.FormEvent) => { - e.preventDefault(); - const label = edit.draft.label?.trim() ?? ''; - const actionCode = edit.draft.actionCode?.trim() ?? ''; - if (!label) { - setEditError('Etiqueta requerida'); - return; - } - if (!actionCode) { - setEditError('Código de acción requerido'); - return; - } - try { - await updateOptionMutation.mutateAsync({ - label, - description: edit.draft.description?.trim() ?? '', - actionCode: formatSystemCode(actionCode), - sortOrder: Number(edit.draft.sortOrder) || 1, - }); - edit.cancelEdit(); - setEditError(''); - } catch { - /* handled by hook */ - } - }; - - if (edit.isEditing(option.id)) { - return ( -
-
- edit.setField('label', e.target.value)} - /> - edit.setField('actionCode', e.target.value)} - /> -
- edit.setField('description', e.target.value)} - /> - edit.setField('sortOrder', parseInt(e.target.value) || 1)} - /> - -
- - -
- - ); - } - - return ( -
-
-
- {option.label} - -
- {option.description && ( -

- {option.description} -

- )} -
-
- - - {option.actionCode} - - - - - removeOptionMutation.mutate(option.id)} - disabled={removeOptionMutation.isPending} - className="opacity-0 group-hover/opt:opacity-100 transition-opacity hover:text-m3-error hover:bg-m3-error/10" - > - - -
-
- ); -}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SubMenuRow.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SubMenuRow.tsx deleted file mode 100644 index 9e7a86f6..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SubMenuRow.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import React, { useState } from 'react'; -import { ChevronDown, ChevronRight, FolderOpen, Pencil, Trash2 } from 'lucide-react'; -import { useInlineEdit } from '@app/hooks/use-inline-edit'; -import { - useAddOption, - useRemoveOption, - useUpdateSubMenu, -} from '@app/authorization/hooks/use-system-suite'; -import { M3TextField } from '@shared/components/M3TextField'; -import { InlineAddForm } from '@shared/components/InlineAddForm'; -import { IconButton } from '@shared/components/Tooltip'; -import { CodeBadge } from '@shared/components/CodeBadge'; -import { ErrorDisplay } from '@shared/components/data-display/ErrorDisplay'; -import { formatSystemCode } from '@app/utils/security'; -import { OptionRow } from './OptionRow'; -import { AddOptState, emptyOpt } from './types'; - -type SubMenuType = { - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - options?: Array<{ - id: string; - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder?: number; - }>; -}; - -interface SubMenuRowProps { - suiteId: string; - moduleId: string; - menuId: string; - subMenu: SubMenuType; - isExpanded: boolean; - onToggle: () => void; - onRemoveSubMenu: (id: string) => void; - isRemovingSubMenu: boolean; -} - -interface SubMenuDraft { - label: string; - description: string; - sortOrder: number; -} - -export const SubMenuRow: React.FC = ({ - suiteId, - moduleId, - menuId, - subMenu, - isExpanded, - onToggle, - onRemoveSubMenu, - isRemovingSubMenu, -}) => { - const [isAddingOpt, setIsAddingOpt] = useState(false); - const [opt, setOpt] = useState(emptyOpt()); - const [editError, setEditError] = useState(''); - - const addOptionMutation = useAddOption(suiteId, moduleId, menuId, subMenu.id); - const removeOptionMutation = useRemoveOption(suiteId, moduleId, menuId, subMenu.id); - const updateSubMenuMutation = useUpdateSubMenu(suiteId, moduleId, menuId, subMenu.id); - - const edit = useInlineEdit(['label', 'description', 'sortOrder']); - - const handleAddOption = async (e: React.FormEvent) => { - e.preventDefault(); - if (!opt.code.trim()) { - setOpt(s => ({ ...s, error: 'Código requerido' })); - return; - } - if (!opt.label.trim()) { - setOpt(s => ({ ...s, error: 'Etiqueta requerida' })); - return; - } - if (!opt.actionCode.trim()) { - setOpt(s => ({ ...s, error: 'Código de acción requerido' })); - return; - } - try { - await addOptionMutation.mutateAsync({ - code: formatSystemCode(opt.code), - label: opt.label.trim(), - description: opt.desc.trim(), - actionCode: formatSystemCode(opt.actionCode), - sortOrder: parseInt(opt.sort) || 1, - }); - setOpt(emptyOpt()); - setIsAddingOpt(false); - } catch { - /* handled by hook */ - } - }; - - const handleStartEditSub = () => { - edit.openEdit(subMenu.id, { - label: subMenu.label, - description: subMenu.description ?? '', - sortOrder: subMenu.sortOrder ?? 1, - }); - setEditError(''); - }; - - const handleUpdateSubMenu = async (e: React.FormEvent) => { - e.preventDefault(); - const label = edit.draft.label?.trim() ?? ''; - if (!label) { - setEditError('Etiqueta requerida'); - return; - } - try { - await updateSubMenuMutation.mutateAsync({ - label, - description: edit.draft.description?.trim() ?? '', - sortOrder: Number(edit.draft.sortOrder) || 1, - }); - edit.cancelEdit(); - setEditError(''); - } catch { - /* handled by hook */ - } - }; - - const handleCancelEditSub = () => { - edit.cancelEdit(); - setEditError(''); - }; - - return ( -
- {edit.isEditing(subMenu.id) ? ( -
-
- edit.setField('label', e.target.value)} - /> - edit.setField('sortOrder', parseInt(e.target.value) || 1)} - /> -
- edit.setField('description', e.target.value)} - /> - -
- - -
- - ) : ( -
-
- {isExpanded ? ( - - ) : ( - - )} - -
- - {subMenu.label} - - -
-
-
- {isExpanded && ( - - )} - - - - onRemoveSubMenu(subMenu.id)} - disabled={isRemovingSubMenu} - className="hover:text-m3-error hover:bg-m3-error/10" - > - - -
-
- )} - -
- {isAddingOpt && ( - { - setIsAddingOpt(open); - if (!open) setOpt(emptyOpt()); - }} - onSubmit={handleAddOption} - addLabel="Opción" - title="Nueva Opción" - cancelLabel="Cancelar" - submitLabel="Guardar Opción" - isLoading={addOptionMutation.isPending} - error={opt.error || undefined} - > - setOpt(s => ({ ...s, code: e.target.value }))} - placeholder="e.g. VIEW" - /> - setOpt(s => ({ ...s, label: e.target.value }))} - placeholder="e.g. Ver Usuarios" - /> - setOpt(s => ({ ...s, desc: e.target.value }))} - placeholder="Opcional" - /> - setOpt(s => ({ ...s, actionCode: e.target.value }))} - placeholder="e.g. USER_VIEW" - /> - setOpt(s => ({ ...s, sort: e.target.value }))} - placeholder="1" - /> - - )} - - {!subMenu.options || subMenu.options.length === 0 ? ( -

No hay opciones configuradas.

- ) : ( -
- {subMenu.options.map(option => ( - - ))} -
- )} -
-
- ); -}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.test.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.test.tsx new file mode 100644 index 00000000..4352ef12 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SuiteNode, type NodeHandlers } from './SuiteNode'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; + +// UX G-107: el alta de nodo hijo también se apoya en `AddNode`, que el backend +// rechaza sobre un módulo inactivo. `NodeHandlers.canAdd` propaga el estado del +// módulo para deshabilitar la acción y guiar a activar primero. + +const node: SystemSuiteNode = { + id: 'n1', + parentNodeId: null, + kind: 'Menu', + code: 'MENU', + label: 'Menú 1', + description: '', + status: 'Active', + sortOrder: 1, + actionCodes: [], + metadata: null, + children: [], +}; + +const makeHandlers = (canAdd: boolean): NodeHandlers => ({ + onAddChild: vi.fn(), + onUpdate: vi.fn(), + onRemove: vi.fn(), + onSetStatus: vi.fn(), + onLinkAction: vi.fn(), + onUnlinkAction: vi.fn(), + onSetMetadata: vi.fn(), + canAdd, + pending: { + add: false, + update: false, + remove: false, + status: false, + link: false, + unlink: false, + metadata: false, + }, +}); + +const renderNode = (canAdd: boolean) => + render( + true} + onToggleNode={vi.fn()} + /> + ); + +describe('SuiteNode — alta de nodo hijo según estado del módulo', () => { + it('deshabilita el alta de nodo hijo cuando el módulo está inactivo', () => { + renderNode(false); + expect( + screen.getByRole('button', { name: 'Activa el módulo para agregar nodos' }) + ).toBeDisabled(); + }); + + it('permite agregar un nodo hijo cuando el módulo está activo', () => { + renderNode(true); + const addChild = screen.getByRole('button', { name: 'Agregar nodo hijo' }); + expect(addChild).toBeEnabled(); + + fireEvent.click(addChild); + expect(screen.getByText('Nuevo Nodo Hijo')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.tsx new file mode 100644 index 00000000..360f9b79 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.tsx @@ -0,0 +1,425 @@ +import React, { useState } from 'react'; +import { + ChevronDown, + ChevronRight, + FolderTree, + Folder, + FileText, + EyeOff, + ShieldCheck, + Trash2, + Plus, + Pencil, + ClipboardList, + Key, +} from 'lucide-react'; +import { IconButton } from '@shared/components/Tooltip'; +import { CodeBadge } from '@shared/components/CodeBadge'; +import { StatusBadge } from '@shared/components/StatusBadge'; +import { M3TextField } from '@shared/components/M3TextField'; +import { M3Select } from '@shared/components/M3Select'; +import { InlineAddForm } from '@shared/components/InlineAddForm'; +import { formatSystemCode } from '@app/utils/security'; +import type { + SystemSuiteNode, + SystemSuiteNodeMetadata, +} from '@domain/authorization/schemas/system-suite.schema'; +import { NodeMetadataDialog } from './NodeMetadataDialog'; +import { NodeActionsPanel, type SuiteActionOption } from './NodeActionsPanel'; + +// ── Handlers compartidos por todo el subárbol (instanciados a nivel de módulo) ── +export interface NodeHandlers { + onAddChild: ( + parentNodeId: string, + payload: { kind: string; code: string; label: string; description: string; sortOrder: number } + ) => void; + onUpdate: ( + nodeId: string, + payload: { label: string; description: string; sortOrder: number } + ) => void; + onRemove: (nodeId: string) => void; + onSetStatus: (nodeId: string, active: boolean) => void; + onLinkAction: (nodeId: string, actionCode: string) => void; + onUnlinkAction: (nodeId: string, actionCode: string) => void; + onSetMetadata: (nodeId: string, metadata: SystemSuiteNodeMetadata) => void; + /** + * Si es `false`, el módulo contenedor está inactivo: el backend rechaza `AddNode` + * con 400 sobre un módulo inactivo, así que la UI deshabilita el alta de nodos + * y guía a activar el módulo primero (G-107). + */ + canAdd: boolean; + pending: { + add: boolean; + update: boolean; + remove: boolean; + status: boolean; + link: boolean; + unlink: boolean; + metadata: boolean; + }; +} + +interface SuiteNodeProps { + node: SystemSuiteNode; + level: number; + availableActions: SuiteActionOption[]; + handlers: NodeHandlers; + isNodeExpanded: (id: string) => boolean; + onToggleNode: (id: string) => void; +} + +const KIND_LABEL: Record = { + Menu: 'Menú', + SubMenu: 'Submenú', + Option: 'Opción', +}; + +const KIND_STYLE: Record = { + Menu: 'bg-blue-500/10 text-blue-500', + SubMenu: 'bg-sky-500/10 text-sky-500', + Option: 'bg-slate-500/10 text-slate-500', +}; + +const kindIcon = (kind: string) => { + if (kind === 'Menu') return ; + if (kind === 'SubMenu') return ; + return ; +}; + +const emptyChild = () => ({ + kind: 'Option', + code: '', + label: '', + desc: '', + sort: '1', + error: '', +}); + +export const SuiteNode: React.FC = ({ + node, + level, + availableActions, + handlers, + isNodeExpanded, + onToggleNode, +}) => { + const nodeKey = `node-${node.id}`; + const expanded = isNodeExpanded(nodeKey); + const hasChildren = node.children.length > 0; + const isActive = node.status === 'Active'; + + const [isAddingChild, setIsAddingChild] = useState(false); + const [child, setChild] = useState(emptyChild()); + + const [isEditing, setIsEditing] = useState(false); + const [editState, setEditState] = useState({ label: '', desc: '', sort: '1', error: '' }); + + const [showMetadata, setShowMetadata] = useState(false); + const [showActions, setShowActions] = useState(false); + + const beginEdit = () => { + setEditState({ + label: node.label, + desc: node.description ?? '', + sort: String(node.sortOrder ?? 1), + error: '', + }); + setIsEditing(true); + }; + + const submitEdit = (e: React.FormEvent) => { + e.preventDefault(); + if (!editState.label.trim()) { + setEditState(s => ({ ...s, error: 'Etiqueta requerida' })); + return; + } + handlers.onUpdate(node.id, { + label: editState.label.trim(), + description: editState.desc.trim(), + sortOrder: parseInt(editState.sort) || 1, + }); + setIsEditing(false); + }; + + const submitChild = (e: React.FormEvent) => { + e.preventDefault(); + if (!child.code.trim()) { + setChild(s => ({ ...s, error: 'Código requerido' })); + return; + } + if (!child.label.trim()) { + setChild(s => ({ ...s, error: 'Etiqueta requerida' })); + return; + } + handlers.onAddChild(node.id, { + kind: child.kind, + code: formatSystemCode(child.code), + label: child.label.trim(), + description: child.desc.trim(), + sortOrder: parseInt(child.sort) || 1, + }); + setChild(emptyChild()); + setIsAddingChild(false); + }; + + const isLeaf = node.kind === 'Option'; + + return ( +
0 ? 8 : 0 }} + > + {/* ── Fila del nodo ── */} +
+ + + {kindIcon(node.kind)} + + + {KIND_LABEL[node.kind] ?? node.kind} + + + + {node.label} + + + + {isLeaf && node.actionCodes.length > 0 && ( + + + {node.actionCodes.length} + + )} + {node.metadata && ( + + + + )} + + + + + Ord: {node.sortOrder} + +
+ { + setIsAddingChild(true); + if (!expanded && hasChildren) onToggleNode(nodeKey); + }} + className="hover:text-m3-primary hover:bg-m3-primary/10 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-m3-secondary" + > + + + {isLeaf && ( + setShowActions(v => !v)} + className={showActions ? 'text-m3-primary bg-m3-primary/10' : ''} + > + + + )} + setShowMetadata(true)} + className="hover:text-emerald-500 hover:bg-emerald-500/10" + > + + + + + + {isActive ? ( + handlers.onSetStatus(node.id, false)} + disabled={handlers.pending.status} + > + + + ) : ( + handlers.onSetStatus(node.id, true)} + disabled={handlers.pending.status} + > + + + )} + handlers.onRemove(node.id)} + disabled={handlers.pending.remove} + className="hover:text-m3-error hover:bg-m3-error/10" + > + + +
+
+ + {/* ── N:M panel (hoja) ── */} + {isLeaf && showActions && ( +
+ handlers.onLinkAction(node.id, code)} + onUnlink={code => handlers.onUnlinkAction(node.id, code)} + /> +
+ )} + + {/* ── Edición inline ── */} + {isEditing && ( +
+ setIsEditing(open)} + onSubmit={submitEdit} + addLabel="Nodo" + title={`Editar ${KIND_LABEL[node.kind] ?? 'Nodo'}`} + triggerEmphasis="none" + cancelLabel="Cancelar" + submitLabel="Guardar Cambios" + isLoading={handlers.pending.update} + error={editState.error || undefined} + > + setEditState(s => ({ ...s, label: e.target.value }))} + /> + setEditState(s => ({ ...s, desc: e.target.value }))} + /> + setEditState(s => ({ ...s, sort: e.target.value }))} + /> + +
+ )} + + {/* ── Alta de nodo hijo ── */} + {isAddingChild && ( +
+ { + setIsAddingChild(open); + if (!open) setChild(emptyChild()); + }} + onSubmit={submitChild} + addLabel="Nodo" + title="Nuevo Nodo Hijo" + triggerEmphasis="none" + cancelLabel="Cancelar" + submitLabel="Guardar Nodo" + isLoading={handlers.pending.add} + error={child.error || undefined} + > + setChild(s => ({ ...s, kind: e.target.value }))} + > + + + + + setChild(s => ({ ...s, code: e.target.value }))} + placeholder="e.g. CONSULTAR" + /> + setChild(s => ({ ...s, label: e.target.value }))} + placeholder="e.g. Consultar" + /> + setChild(s => ({ ...s, desc: e.target.value }))} + placeholder="Opcional" + /> + setChild(s => ({ ...s, sort: e.target.value }))} + placeholder="1" + /> + +
+ )} + + {/* ── Hijos (recursivo) ── */} + {expanded && hasChildren && ( +
+ {node.children.map(childNode => ( + + ))} +
+ )} + + {/* ── Dialogo de metadatos ── */} + {showMetadata && ( + setShowMetadata(false)} + onSave={metadata => { + handlers.onSetMetadata(node.id, metadata); + setShowMetadata(false); + }} + /> + )} +
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.test.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.test.tsx new file mode 100644 index 00000000..edda9a92 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SuiteNodeTree } from './SuiteNodeTree'; + +// UX G-107: un módulo nace `Inactive` y el backend rechaza `AddNode` con 400 sobre +// un módulo inactivo. La UI debe deshabilitar el alta de nodos y guiar a activar el +// módulo primero, en lugar de fallar con un 400 críptico. + +const mutation = () => ({ mutate: vi.fn(), isPending: false }); + +vi.mock('@app/authorization/hooks/use-system-suite', () => ({ + useAddNode: () => mutation(), + useUpdateNode: () => mutation(), + useRemoveNode: () => mutation(), + useSetNodeStatus: () => mutation(), + useLinkNodeAction: () => mutation(), + useUnlinkNodeAction: () => mutation(), + useSetNodeMetadata: () => mutation(), +})); + +const baseProps = { + suiteId: 'suite-1', + availableActions: [], + isExpanded: true, + isNodeExpanded: () => true, + onToggle: vi.fn(), + onToggleNode: vi.fn(), + onDeactivate: vi.fn(), + onActivate: vi.fn(), + onRemove: vi.fn(), + isDeactivating: false, + isActivating: false, + isRemoving: false, +}; + +const makeModule = (status: string) => ({ + id: 'm1', + code: 'MOD', + name: 'Módulo 1', + description: '', + status, + sortOrder: 1, + nodes: [], +}); + +describe('SuiteNodeTree — alta de nodo raíz según estado del módulo', () => { + it('deshabilita el alta y guía a activar cuando el módulo está inactivo', () => { + render(); + + const guide = screen.getByText('Activa el módulo para agregar nodos'); + expect(guide).toBeDisabled(); + + // No debe existir ninguna acción habilitada de «Agregar nodo raíz». + expect(screen.queryByText('Agregar nodo raíz')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Agregar nodo raíz' })).toBeNull(); + }); + + it('permite agregar un nodo raíz cuando el módulo está activo', () => { + render(); + + const addBtn = screen.getByText('Agregar nodo raíz'); + expect(addBtn).toBeEnabled(); + + fireEvent.click(addBtn); + + // Se abre el formulario inline de alta de nodo raíz. + expect(screen.getByText('Nuevo Nodo Raíz')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.tsx new file mode 100644 index 00000000..10d05c08 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.tsx @@ -0,0 +1,336 @@ +import React, { useMemo, useState } from 'react'; +import { ChevronDown, ChevronRight, Layers, EyeOff, ShieldCheck, Trash2, Plus } from 'lucide-react'; +import { + useAddNode, + useUpdateNode, + useRemoveNode, + useSetNodeStatus, + useLinkNodeAction, + useUnlinkNodeAction, + useSetNodeMetadata, +} from '@app/authorization/hooks/use-system-suite'; +import { InlineAddForm } from '@shared/components/InlineAddForm'; +import { IconButton } from '@shared/components/Tooltip'; +import { CodeBadge } from '@shared/components/CodeBadge'; +import { StatusBadge } from '@shared/components/StatusBadge'; +import { M3TextField } from '@shared/components/M3TextField'; +import { M3Select } from '@shared/components/M3Select'; +import { formatSystemCode } from '@app/utils/security'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; +import { SuiteNode, type NodeHandlers } from './SuiteNode'; +import type { SuiteActionOption } from './NodeActionsPanel'; + +interface ModuleWithNodes { + id: string; + code: string; + name: string; + description?: string; + status: string; + sortOrder?: number; + nodes?: SystemSuiteNode[]; +} + +interface SuiteNodeTreeProps { + suiteId: string; + module: ModuleWithNodes; + availableActions: SuiteActionOption[]; + isExpanded: boolean; + isNodeExpanded: (id: string) => boolean; + onToggle: () => void; + onToggleNode: (id: string) => void; + onDeactivate: () => void; + onActivate: () => void; + onRemove: () => void; + isDeactivating: boolean; + isActivating: boolean; + isRemoving: boolean; +} + +const emptyRoot = () => ({ kind: 'Menu', code: '', label: '', desc: '', sort: '1', error: '' }); + +/** + * Árbol de nodos recursivo de un módulo (ADR-0090). Reemplaza la cadena rígida + * ModuleCard→MenuRow→SubMenuRow→OptionRow: renderiza `module.nodes` con el + * componente recursivo y expone la escritura por nodeId. + */ +export const SuiteNodeTree: React.FC = ({ + suiteId, + module, + availableActions, + isExpanded, + isNodeExpanded, + onToggle, + onToggleNode, + onDeactivate, + onActivate, + onRemove, + isDeactivating, + isActivating, + isRemoving, +}) => { + const addNode = useAddNode(suiteId, module.id); + const updateNode = useUpdateNode(suiteId, module.id); + const removeNode = useRemoveNode(suiteId, module.id); + const setNodeStatus = useSetNodeStatus(suiteId, module.id); + const linkAction = useLinkNodeAction(suiteId, module.id); + const unlinkAction = useUnlinkNodeAction(suiteId, module.id); + const setMetadata = useSetNodeMetadata(suiteId, module.id); + + const [isAddingRoot, setIsAddingRoot] = useState(false); + const [root, setRoot] = useState(emptyRoot()); + + // Un módulo nace `Inactive`; el backend rechaza `AddNode` con 400 sobre un módulo + // inactivo. La UI deshabilita el alta de nodos y guía a activarlo primero (G-107). + const isModuleActive = module.status === 'Active'; + + const handlers: NodeHandlers = useMemo( + () => ({ + onAddChild: (parentNodeId, payload) => + addNode.mutate({ parentNodeId, ...payload }), + onUpdate: (nodeId, payload) => updateNode.mutate({ nodeId, ...payload }), + onRemove: nodeId => removeNode.mutate(nodeId), + onSetStatus: (nodeId, active) => setNodeStatus.mutate({ nodeId, active }), + onLinkAction: (nodeId, actionCode) => linkAction.mutate({ nodeId, actionCode }), + onUnlinkAction: (nodeId, actionCode) => unlinkAction.mutate({ nodeId, actionCode }), + onSetMetadata: (nodeId, metadata) => setMetadata.mutate({ nodeId, ...metadata }), + canAdd: isModuleActive, + pending: { + add: addNode.isPending, + update: updateNode.isPending, + remove: removeNode.isPending, + status: setNodeStatus.isPending, + link: linkAction.isPending, + unlink: unlinkAction.isPending, + metadata: setMetadata.isPending, + }, + }), + [ + addNode, + updateNode, + removeNode, + setNodeStatus, + linkAction, + unlinkAction, + setMetadata, + isModuleActive, + ] + ); + + const nodes = useMemo( + () => [...(module.nodes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder), + [module.nodes] + ); + + const submitRoot = (e: React.FormEvent) => { + e.preventDefault(); + if (!root.code.trim()) { + setRoot(s => ({ ...s, error: 'Código requerido' })); + return; + } + if (!root.label.trim()) { + setRoot(s => ({ ...s, error: 'Etiqueta requerida' })); + return; + } + addNode.mutate( + { + parentNodeId: null, + kind: root.kind, + code: formatSystemCode(root.code), + label: root.label.trim(), + description: root.desc.trim(), + sortOrder: parseInt(root.sort) || 1, + }, + { + onSuccess: () => { + setRoot(emptyRoot()); + setIsAddingRoot(false); + }, + } + ); + }; + + // Empty-state del árbol: si el módulo está inactivo, el backend rechaza `AddNode` + // con 400 (G-107), así que se muestra un CTA deshabilitado que guía a activarlo. + const renderRootCta = () => { + if (isAddingRoot) return null; + if (!isModuleActive) { + return ( + + ); + } + return ( + + ); + }; + + return ( +
+ {/* ── Cabecera del módulo ── */} +
+
+ {isExpanded ? ( + + ) : ( + + )} + +
+ {module.name} + +
+
+
+ + Ord: {module.sortOrder} +
+ {isExpanded && ( + { + e.stopPropagation(); + setIsAddingRoot(true); + }} + className="hover:text-m3-primary hover:bg-m3-primary/10 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-m3-secondary" + > + + + )} + {module.status === 'Active' ? ( + { + e.stopPropagation(); + onDeactivate(); + }} + disabled={isDeactivating} + > + + + ) : ( + { + e.stopPropagation(); + onActivate(); + }} + disabled={isActivating} + > + + + )} + { + e.stopPropagation(); + onRemove(); + }} + disabled={isRemoving} + className="hover:text-m3-error hover:bg-m3-error/10" + > + + +
+
+
+ + {/* ── Cuerpo: árbol de nodos ── */} +
+ {module.description && ( +

+ {module.description} +

+ )} + + { + setIsAddingRoot(open); + if (!open) setRoot(emptyRoot()); + }} + onSubmit={submitRoot} + addLabel="Nodo raíz" + title="Nuevo Nodo Raíz" + triggerEmphasis="none" + cancelLabel="Cancelar" + submitLabel="Guardar Nodo" + isLoading={addNode.isPending} + error={root.error || undefined} + > + setRoot(s => ({ ...s, kind: e.target.value }))} + > + + + + + setRoot(s => ({ ...s, code: e.target.value }))} + placeholder="e.g. ADMIN" + /> + setRoot(s => ({ ...s, label: e.target.value }))} + placeholder="e.g. Administración" + /> + setRoot(s => ({ ...s, desc: e.target.value }))} + placeholder="Opcional" + /> + setRoot(s => ({ ...s, sort: e.target.value }))} + placeholder="1" + /> + + + {nodes.length === 0 ? ( + renderRootCta() + ) : ( +
+ {nodes.map(n => ( + + ))} +
+ )} +
+
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/index.ts b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/index.ts index 7c21c847..593a7846 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/index.ts +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/index.ts @@ -1,5 +1,4 @@ -export { ModuleCard } from './ModuleCard'; -export { MenuRow } from './MenuRow'; -export { SubMenuRow } from './SubMenuRow'; -export { OptionRow } from './OptionRow'; -export * from './types'; +export { SuiteNodeTree } from './SuiteNodeTree'; +export { SuiteNode } from './SuiteNode'; +export type { NodeHandlers } from './SuiteNode'; +export type { SuiteActionOption } from './NodeActionsPanel'; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/types.ts b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/types.ts deleted file mode 100644 index 0d90a46d..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -export interface AddMenuState { - code: string; - label: string; - desc: string; - sort: string; - error: string; -} - -export interface AddSubState { - code: string; - label: string; - desc: string; - sort: string; - error: string; -} - -export interface AddOptState { - code: string; - label: string; - desc: string; - actionCode: string; - sort: string; - error: string; -} - -export const emptyMenu = (): AddMenuState => ({ - code: '', - label: '', - desc: '', - sort: '1', - error: '', -}); -export const emptySub = (): AddSubState => ({ - code: '', - label: '', - desc: '', - sort: '1', - error: '', -}); -export const emptyOpt = (): AddOptState => ({ - code: '', - label: '', - desc: '', - actionCode: '', - sort: '1', - error: '', -}); diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx index 75d9b8a5..5d8b69ee 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx @@ -200,7 +200,7 @@ export function AppConfigurationListPanel({ totalItems={paginationState.totalItems} startIndex={paginationState.startIndex ?? 0} pageSize={paginationState.pageSize} - itemLabel="parameters" + itemLabel="configuraciones" onClear={queryState.handleResetQuery} searchTerm={queryState.appliedQuery.term} /> diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/ParameterDefinitionPickerDialog.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/ParameterDefinitionPickerDialog.tsx index d84e0944..f833328e 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/ParameterDefinitionPickerDialog.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/ParameterDefinitionPickerDialog.tsx @@ -67,7 +67,7 @@ export function ParameterDefinitionPickerDialog({ if (dataTypeFilter !== 'all') filter.dataTypeId = dataTypeFilter; if (statusFilter !== 'all') filter.isActive = statusFilter; - const result = await parameterCatalogService.getParameterDefinitions(filter as any); + const result = await parameterCatalogService.getAll(filter); const processed = result.items.map(p => ({ ...p, isLinked: existingCodes.includes(p.code), diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx index 3a2a2780..057c3ded 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx @@ -70,16 +70,11 @@ export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Eleme d.setSelectedId(results[0].appConfigurationId); } setIsPickerOpen(false); - } catch (err: unknown) { - const e = err as { - normalised?: { message?: string }; - response?: { data?: { detail?: string } }; - message?: string; - }; + } catch (err: any) { const errorMsg = - e?.normalised?.message || - e?.response?.data?.detail || - e?.message || + err?.normalised?.message || + err?.response?.data?.detail || + err?.message || t.failedToLinkParameter; addNotification({ title: t.error ?? 'Error', @@ -108,12 +103,8 @@ export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Eleme if (d.selectedId === pendingDeleteId) { d.setSelectedId(''); } - } catch (err: unknown) { - const e = err as { - normalised?: { message?: string }; - response?: { data?: { detail?: string } }; - }; - const errorMsg = e?.normalised?.message || e?.response?.data?.detail || t.deleteFailed; + } catch (err: any) { + const errorMsg = err?.normalised?.message || err?.response?.data?.detail || t.deleteFailed; addNotification({ title: t.error ?? 'Error', message: errorMsg, diff --git a/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagDetailPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagDetailPanel.tsx index 5abc1918..fde37918 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagDetailPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagDetailPanel.tsx @@ -62,7 +62,7 @@ const CriteriaRow: React.FC<{ isDraft: boolean; }> = ({ criteria, onRemove, isDraft }) => (
-
+
Tipo @@ -282,7 +282,7 @@ export const FeatureFlagDetailPanel: React.FC = ({ flag }) => {

-
+
= ({
onSearchValueChange?.(e.target.value)} onKeyDown={e => { @@ -190,7 +183,7 @@ export const ListToolbar: React.FC = ({ } }} placeholder="Buscar..." - className="h-6 flex-1 min-w-0 bg-transparent text-[12px] text-m3-on-surface + className="h-6 flex-1 min-w-[4rem] bg-transparent text-[12px] text-m3-on-surface placeholder:text-m3-secondary/40 focus:outline-none" /> {searchValue && onSearchClear && ( @@ -247,26 +240,23 @@ export const ListToolbar: React.FC = ({
)} - {/* Secondary actions */} - {secondaryActions && ( -
{secondaryActions}
- )} -
- )} + + )} - {/* Collapsed indicator */} - {!isExpanded && ( -
- + {/* Modos de vista + acciones (al expandir) + agregar (siempre) */} +
+ {isExpanded && onViewModeChange && ( + + )} + {isExpanded && secondaryActions} + {onAdd && }
- )} +
); }; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx index d26fa901..a3d7a034 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx @@ -123,6 +123,7 @@ export const M3DataView: React.FC = ({ containerRef: dvContainerRef, resizableRef: searchZoneRef, handleMouseDown: handleHSplitterMouseDown, + handleTouchStart: handleHSplitterTouchStart, handleKeyDown: handleHSplitterKeyDown, toggleCollapse: toggleHeader, } = useDragResize(); @@ -291,6 +292,7 @@ export const M3DataView: React.FC = ({ */}
= ({ aria-valuemax={100} aria-valuenow={topPx ?? undefined} className={[ - 'relative flex-shrink-0 h-1.5 w-full flex items-center justify-center group cursor-row-resize z-10 mt-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary', + // `touch-none` + zona táctil de 32px (`before`) sin engrosar la línea visible (densidad). + 'relative flex-shrink-0 h-1.5 w-full flex items-center justify-center group cursor-row-resize z-10 mt-1 touch-none focus:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary', + 'before:absolute before:inset-x-0 before:top-1/2 before:-translate-y-1/2 before:h-8 before:content-[""]', isDraggingH ? 'bg-m3-primary/20' : 'hover:bg-m3-primary/10 transition-colors duration-150', @@ -321,9 +325,12 @@ export const M3DataView: React.FC = ({ toggleHeader(); }} onMouseDown={e => e.stopPropagation()} + onTouchStart={e => e.stopPropagation()} className={[ 'absolute left-1/2 -translate-x-1/2 -translate-y-px', 'h-4 w-12 rounded-full flex items-center justify-center gap-0.5', + // Zona táctil de 32px de alto sin cambiar el tamaño visible de la píldora. + 'before:absolute before:inset-x-0 before:top-1/2 before:-translate-y-1/2 before:h-8 before:content-[""]', 'border shadow-sm transition-all duration-150 text-[10px] font-medium', isDraggingH ? 'bg-m3-primary text-white border-m3-primary' diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3Dialog.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3Dialog.test.tsx index 35b91f00..e9467961 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3Dialog.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3Dialog.test.tsx @@ -1,7 +1,9 @@ +import React, { useState } from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { M3Dialog } from './M3Dialog'; +import { M3TextField } from './M3TextField'; describe('M3Dialog', () => { const defaultProps = { @@ -76,4 +78,30 @@ describe('M3Dialog', () => { render(); expect(screen.getByRole('group', { name: 'Dialog actions' })).toBeInTheDocument(); }); + + // Regresión: un diálogo con campos tiene que dejar escribir. La trampa de foco se comía la + // primera tecla y devolvía el foco al primer botón; con la barra espaciadora, ese botón se + // pulsaba y el diálogo se cerraba solo. + it('mantiene el foco en el campo mientras se escribe', async () => { + const AnfitrionConCampo: React.FC = () => { + const [texto, setTexto] = useState(''); + return ( + + setTexto(e.target.value)} + /> + + ); + }; + + render(); + const campo = screen.getByLabelText('Motivo del cierre'); + + await userEvent.type(campo, 'Cese de operaciones'); + + expect(campo).toHaveValue('Cese de operaciones'); + expect(document.activeElement).toBe(campo); + }); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/PermissionSectionToolbar.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/PermissionSectionToolbar.test.tsx index 9845b96a..70093cc3 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/PermissionSectionToolbar.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/PermissionSectionToolbar.test.tsx @@ -10,6 +10,7 @@ describe('PermissionSectionToolbar', () => { onViewModeChange={() => {}} itemCount={3} itemLabel="permission" + itemLabelPlural="permissions" /> ); expect(screen.getByText('3 permissions')).toBeInTheDocument(); @@ -22,6 +23,7 @@ describe('PermissionSectionToolbar', () => { onViewModeChange={() => {}} itemCount={1} itemLabel="permission" + itemLabelPlural="permissions" /> ); expect(screen.getByText('1 permission')).toBeInTheDocument(); @@ -34,6 +36,7 @@ describe('PermissionSectionToolbar', () => { onViewModeChange={() => {}} itemCount={3} itemLabel="permission" + itemLabelPlural="permissions" filterOptions={[{ label: 'Allowed', value: 'allowed' }]} activeFilter="allowed" onFilterChange={() => {}} @@ -49,6 +52,7 @@ describe('PermissionSectionToolbar', () => { onViewModeChange={() => {}} itemCount={3} itemLabel="permission" + itemLabelPlural="permissions" sortOptions={[{ label: 'Name', value: 'name' }]} sortBy="name" onSortByChange={() => {}} @@ -64,6 +68,7 @@ describe('PermissionSectionToolbar', () => { onViewModeChange={() => {}} itemCount={3} itemLabel="permission" + itemLabelPlural="permissions" showExpandCollapse allExpanded={false} onToggleExpandAll={() => {}} @@ -79,6 +84,7 @@ describe('PermissionSectionToolbar', () => { onViewModeChange={() => {}} itemCount={3} itemLabel="permission" + itemLabelPlural="permissions" showExpandCollapse allExpanded onToggleExpandAll={() => {}} @@ -94,6 +100,7 @@ describe('PermissionSectionToolbar', () => { onViewModeChange={() => {}} itemCount={3} itemLabel="permission" + itemLabelPlural="permissions" /> ); expect(screen.getByText('3 permissions')).toBeInTheDocument(); @@ -106,6 +113,7 @@ describe('PermissionSectionToolbar', () => { onViewModeChange={() => {}} itemCount={3} itemLabel="permission" + itemLabelPlural="permissions" viewModeOptions={[{ value: 'custom', label: 'Custom' }]} /> ); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/PermissionSectionToolbar.tsx b/src/apps/ums.web-app/src/presentation/shared/components/PermissionSectionToolbar.tsx index 8e7dcb8c..2973e9fb 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/PermissionSectionToolbar.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/PermissionSectionToolbar.tsx @@ -9,6 +9,7 @@ import { } from 'lucide-react'; import { M3SegmentedButton } from './M3SegmentedButton'; import type { SegmentOption } from './M3SegmentedButton'; +import { formatItemCountLabel } from '@shared/utils/pluralize'; export interface PermissionSectionSortOption { label: string; @@ -33,6 +34,8 @@ interface PermissionSectionToolbarProps { onSortOrderToggle?: () => void; itemCount: number; itemLabel: string; + /** Plural explícito para préstamos/irregulares; si se omite se deriva del singular. */ + itemLabelPlural?: string; showExpandCollapse?: boolean; allExpanded?: boolean; onToggleExpandAll?: () => void; @@ -61,6 +64,7 @@ export const PermissionSectionToolbar: React.FC = onSortOrderToggle, itemCount, itemLabel, + itemLabelPlural, showExpandCollapse, allExpanded, onToggleExpandAll, @@ -73,8 +77,7 @@ export const PermissionSectionToolbar: React.FC =
- {itemCount} {itemLabel} - {itemCount !== 1 ? 's' : ''} + {formatItemCountLabel(itemCount, itemLabel, itemLabelPlural)} {filterOptions && onFilterChange && activeFilter !== undefined && ( diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.test.tsx new file mode 100644 index 00000000..b20c3eda --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ProfileSelector } from './ProfileSelector'; + +const switchProfile = vi.fn(); +let perfiles: unknown[] = []; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: () => ({ + user: { authorizationGraph: { profiles: perfiles } }, + switchProfile, + }), +})); + +const perfil = ( + id: string, + systemCode: string, + roleCode: string, + isCurrent: boolean, + branch: { code: string; value: string } | null = null +) => ({ + id, + system: { code: systemCode, value: `Sistema ${systemCode}` }, + role: { code: roleCode, value: `Rol ${roleCode}`, hierarchyLevel: 1 }, + branch, + scope: branch ? 'BranchScoped' : 'OrgWide', + isCurrent, +}); + +describe('ProfileSelector', () => { + beforeEach(() => { + switchProfile.mockReset().mockResolvedValue(true); + perfiles = []; + }); + + it('no se pinta cuando el usuario tiene un solo perfil', () => { + perfiles = [perfil('p1', 'SDLC', 'PMO', true)]; + const { container } = render(); + // Un desplegable de un elemento sugiere una elección que no existe. + expect(container).toBeEmptyDOMElement(); + }); + + it('no se pinta cuando el grafo no trae perfiles', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('muestra el perfil vigente y lista los disponibles', () => { + perfiles = [perfil('p1', 'SDLC', 'PMO', true), perfil('p2', 'TMS', 'OPERADOR', false)]; + render(); + + expect(screen.getByRole('button', { name: /cambiar de perfil/i })).toHaveTextContent('Rol PMO'); + + fireEvent.click(screen.getByRole('button', { name: /cambiar de perfil/i })); + expect(screen.getAllByRole('option')).toHaveLength(2); + expect(screen.getByRole('option', { selected: true })).toHaveTextContent('Rol PMO'); + }); + + it('distingue dos perfiles del mismo rol por su sucursal', () => { + perfiles = [ + perfil('p1', 'TMS', 'OPERADOR', true, { code: 'CALLAO', value: 'Callao' }), + perfil('p2', 'TMS', 'OPERADOR', false, { code: 'PAITA', value: 'Paita' }), + ]; + render(); + fireEvent.click(screen.getByRole('button', { name: /cambiar de perfil/i })); + + // Sin la sucursal serían indistinguibles y elegir mal significa operar sobre la equivocada. + // Se busca dentro de la lista: el botón que la abre también rotula la sucursal vigente. + const opciones = screen.getAllByRole('option').map(o => o.textContent ?? ''); + expect(opciones.some(t => t.includes('Callao'))).toBe(true); + expect(opciones.some(t => t.includes('Paita'))).toBe(true); + }); + + it('cambia de perfil al elegir otro', async () => { + perfiles = [perfil('p1', 'SDLC', 'PMO', true), perfil('p2', 'TMS', 'OPERADOR', false)]; + render(); + fireEvent.click(screen.getByRole('button', { name: /cambiar de perfil/i })); + + fireEvent.click(screen.getByRole('option', { name: /OPERADOR/i })); + + await waitFor(() => expect(switchProfile).toHaveBeenCalledWith('p2')); + }); + + it('no llama al servidor si se elige el perfil que ya está vigente', () => { + perfiles = [perfil('p1', 'SDLC', 'PMO', true), perfil('p2', 'TMS', 'OPERADOR', false)]; + render(); + fireEvent.click(screen.getByRole('button', { name: /cambiar de perfil/i })); + + fireEvent.click(screen.getByRole('option', { selected: true })); + + expect(switchProfile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.tsx new file mode 100644 index 00000000..3dea1d13 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Check, ChevronDown, IdCard } from 'lucide-react'; +import { useAuthStore } from '@app/stores/auth.store'; +import type { GraphProfileOption } from '@domain/authorization/schemas/authorization-graph.schema'; + +/** + * Selector del perfil vigente. + * + * Un perfil ata un usuario, un rol y —por el rol— un sistema, opcionalmente acotado a una + * sucursal. Un usuario con varios tenía que cerrar sesión y volver a entrar para cambiarse de + * sombrero, y ni siquiera sabía que existían: el grafo elegía uno y descartaba el resto. + * + * Dos decisiones de interfaz que conviene no revertir sin pensarlo: + * + * 1. **Con un solo perfil no se pinta nada.** Un desplegable de un elemento es ruido que sugiere + * una elección inexistente. El dato viaja igual, y es el componente quien decide. + * 2. **La sucursal se muestra siempre que exista.** Dos perfiles con el mismo rol en Callao y en + * Paita son indistinguibles sin ella, y elegir mal significa operar sobre la sucursal + * equivocada. + */ +export const ProfileSelector: React.FC<{ className?: string }> = ({ className }) => { + const { user, switchProfile } = useAuthStore(); + const [abierto, setAbierto] = useState(false); + const [cambiando, setCambiando] = useState(false); + const contenedor = useRef(null); + + const perfiles: GraphProfileOption[] = user?.authorizationGraph?.profiles ?? []; + const vigente = perfiles.find(p => p.isCurrent); + + useEffect(() => { + function alPulsarFuera(evento: MouseEvent) { + if (contenedor.current && !contenedor.current.contains(evento.target as Node)) { + setAbierto(false); + } + } + document.addEventListener('mousedown', alPulsarFuera); + return () => document.removeEventListener('mousedown', alPulsarFuera); + }, []); + + // Con un único perfil no hay nada que elegir. + if (perfiles.length < 2) return null; + + const rotulo = (p: GraphProfileOption) => + p.branch ? `${p.role.value} · ${p.system.code} · ${p.branch.value}` : `${p.role.value} · ${p.system.code}`; + + const cambiar = async (p: GraphProfileOption) => { + if (p.isCurrent || cambiando || !p.id) return; + + setCambiando(true); + const ok = await switchProfile(p.id); + setCambiando(false); + setAbierto(false); + + // El grafo nuevo llega entero: el árbol de navegación, los permisos y el rol cambian a la vez. + // Se recarga la vista en vez de reconciliar pantalla por pantalla, porque una pantalla abierta + // puede haber dejado de estar concedida y reconciliar eso a mano es una fuente de fugas. + if (ok) window.location.reload(); + }; + + return ( +
+ + + {abierto && ( +
    + {perfiles.map(p => ( +
  • + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.test.tsx new file mode 100644 index 00000000..73dbc124 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, renderHook } from '@testing-library/react'; +import { ProtectedRoute, useRequireAuth } from './ProtectedRoute'; + +const mocks = vi.hoisted(() => ({ + authState: { + isAuthenticated: false, + isLoading: false, + user: null as unknown, + checkSession: vi.fn(), + }, + location: { pathname: '/panel' }, +})); + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: () => mocks.authState, +})); + +vi.mock('react-router', () => ({ + useLocation: () => mocks.location, + Navigate: ({ to, state }: { to: string; state?: unknown }) => ( +
+ {JSON.stringify(state)} +
+ ), +})); + +vi.mock('@app/identity/services/auth.service', () => ({ authService: {} })); + +vi.mock('@shared/components/Spinner', () => ({ + Spinner: () =>
, +})); + +const Child = () =>
contenido
; + +describe('ProtectedRoute', () => { + beforeEach(() => { + mocks.authState.isAuthenticated = false; + mocks.authState.isLoading = false; + mocks.authState.user = null; + mocks.authState.checkSession = vi.fn().mockResolvedValue(true); + mocks.location = { pathname: '/panel' }; + }); + + it('muestra el spinner mientras el store está cargando', () => { + mocks.authState.isLoading = true; + render( + + + + ); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + expect(screen.getByText('Validando sesión...')).toBeInTheDocument(); + expect(screen.queryByTestId('child')).not.toBeInTheDocument(); + }); + + it('redirige a /login cuando no hay sesión autenticada', async () => { + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('navigate')).toBeInTheDocument()); + const nav = screen.getByTestId('navigate'); + expect(nav).toHaveAttribute('data-to', '/login'); + expect(nav.textContent).toContain('/panel'); + expect(nav.textContent).toContain('showSessionExpired'); + expect(screen.queryByTestId('child')).not.toBeInTheDocument(); + }); + + it('renderiza los hijos cuando la sesión es válida', async () => { + mocks.authState.isAuthenticated = true; + mocks.authState.user = { username: 'ana' }; + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('child')).toBeInTheDocument()); + expect(mocks.authState.checkSession).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); + }); + + it('no revalida sesión cuando falta el usuario aunque isAuthenticated sea verdadero', async () => { + mocks.authState.isAuthenticated = true; + mocks.authState.user = null; + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('navigate')).toBeInTheDocument()); + expect(mocks.authState.checkSession).not.toHaveBeenCalled(); + }); +}); + +describe('useRequireAuth', () => { + beforeEach(() => { + mocks.authState.isAuthenticated = false; + mocks.authState.isLoading = false; + mocks.location = { pathname: '/reportes' }; + }); + + it('devuelve autorización indeterminada mientras carga', () => { + mocks.authState.isLoading = true; + const { result } = renderHook(() => useRequireAuth()); + expect(result.current.isAuthorized).toBeNull(); + expect(result.current.redirectPath).toBeNull(); + }); + + it('devuelve no autorizado y ruta de redirección cuando no hay sesión', () => { + const { result } = renderHook(() => useRequireAuth()); + expect(result.current.isAuthorized).toBe(false); + expect(result.current.redirectPath).toBe('/login?redirect=%2Freportes'); + }); + + it('devuelve autorizado cuando hay sesión', () => { + mocks.authState.isAuthenticated = true; + const { result } = renderHook(() => useRequireAuth()); + expect(result.current.isAuthorized).toBe(true); + expect(result.current.redirectPath).toBeNull(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.tsx index 8abbf5b8..62e11945 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.tsx @@ -13,6 +13,7 @@ import React, { useEffect, useState } from 'react'; import { Navigate, useLocation } from 'react-router'; import { useAuthStore } from '@app/stores/auth.store'; +import { authService } from '@app/identity/services/auth.service'; import { Spinner } from '@shared/components/Spinner'; interface ProtectedRouteProps { diff --git a/src/apps/ums.web-app/src/presentation/shared/components/SectionHeader.tsx b/src/apps/ums.web-app/src/presentation/shared/components/SectionHeader.tsx index ab6176cc..81869792 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/SectionHeader.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/SectionHeader.tsx @@ -3,7 +3,7 @@ import React from 'react'; /** * SectionHeader — consistent section title bar with optional actions. * - * Used inside detail panels (BranchManager, IdpPanel, BrandingPanel) + * Used inside detail panels (BranchManager, IdpPanel) * to provide a uniform header with bottom border, title, subtitle, * and an action slot. */ diff --git a/src/apps/ums.web-app/src/presentation/shared/components/StatusBadge.tsx b/src/apps/ums.web-app/src/presentation/shared/components/StatusBadge.tsx index d39d23d1..94bde4e0 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/StatusBadge.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/StatusBadge.tsx @@ -42,6 +42,8 @@ export const StatusBadge: React.FC = React.memo( return ( {label ?? status} diff --git a/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.test.tsx new file mode 100644 index 00000000..74b1e405 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.test.tsx @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { SystemThemeApplier, hexAHslCanales } from './SystemThemeApplier'; +import { useI18nStore } from '@app/stores/i18n.store'; +import { useThemeStore } from '@app/stores/theme.store'; + +let settings: Record> = {}; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { settings } } }), +})); + +describe('hexAHslCanales', () => { + it('convierte al formato de los tokens (tripleta HSL sin envoltorio)', () => { + // Los tokens son `208 75% 23%`, no `#0f3e67`: Tailwind los compone con opacidad. + // Ese valor es, literalmente, el que `index.css` tiene escrito a mano para --m3-primary: + // la conversión reproduce el token del producto a partir del hexadecimal corporativo. + expect(hexAHslCanales('#0f3e67')).toBe('208 75% 23%'); + expect(hexAHslCanales('0f3e67')).toBe('208 75% 23%'); + }); + + it('admite la forma corta de tres dígitos', () => { + expect(hexAHslCanales('#fff')).toBe('0 0% 100%'); + }); + + it('devuelve undefined ante un valor inválido', () => { + // Un color mal configurado en la base debe dejar el tema por defecto, no romper la interfaz. + expect(hexAHslCanales('azul')).toBeUndefined(); + expect(hexAHslCanales('#12345')).toBeUndefined(); + expect(hexAHslCanales(undefined)).toBeUndefined(); + }); +}); + +describe('SystemThemeApplier', () => { + beforeEach(() => { + settings = {}; + document.documentElement.style.removeProperty('--m3-primary'); + document.title = 'UMS'; + useI18nStore.setState({ language: 'es', chosenByUser: false }); + useThemeStore.setState({ isDarkMode: false, chosenByUser: false }); + delete document.documentElement.dataset.density; + cleanup(); + }); + + it('no toca nada cuando el sistema no publica tema', () => { + render(); + expect(document.documentElement.style.getPropertyValue('--m3-primary')).toBe(''); + }); + + it('aplica el color primario del sistema', () => { + settings = { theme: { primary: '#0f3e67' } }; + render(); + expect(document.documentElement.style.getPropertyValue('--m3-primary')).toBe('208 75% 23%'); + }); + + it('revierte el tema al desmontarse', () => { + settings = { theme: { primary: '#0f3e67' } }; + const { unmount } = render(); + unmount(); + + // Al cambiar de sistema o cerrar sesión, el tema anterior no debe sobrevivir. + expect(document.documentElement.style.getPropertyValue('--m3-primary')).toBe(''); + }); + + it('usa el nombre comercial del sistema como título del documento', () => { + settings = { brand: { display_name: 'Tablero de Gobierno SDLC' } }; + const { unmount } = render(); + expect(document.title).toBe('Tablero de Gobierno SDLC'); + + unmount(); + expect(document.title).toBe('UMS'); + }); + + it('usa el icono del sistema como favicon', () => { + settings = { brand: { icon_url: '/branding/sdlc/icon.svg' } }; + const enlace = document.createElement('link'); + enlace.rel = 'icon'; + enlace.type = 'image/png'; + enlace.href = '/brand/logo-beyondnet.png'; + document.head.appendChild(enlace); + + const { unmount } = render(); + expect(enlace.getAttribute('href')).toBe('/branding/sdlc/icon.svg'); + // El type del producto (PNG) no describe un icono SVG: declararlo mal lo descarta. + expect(enlace.getAttribute('type')).toBeNull(); + + unmount(); + expect(enlace.getAttribute('href')).toBe('/brand/logo-beyondnet.png'); + expect(enlace.getAttribute('type')).toBe('image/png'); + enlace.remove(); + }); + + it('no toca el favicon si el sistema solo publica logotipo', () => { + // El logotipo es un lockup ancho: en 16px sería un borrón. Mejor el icono del producto. + settings = { brand: { logo_url: '/branding/sdlc/logo.svg' } }; + const enlace = document.createElement('link'); + enlace.rel = 'icon'; + enlace.href = '/brand/logo-beyondnet.png'; + document.head.appendChild(enlace); + + render(); + expect(enlace.getAttribute('href')).toBe('/brand/logo-beyondnet.png'); + enlace.remove(); + }); + + it('crea el enlace si el documento no lo declara, y lo retira al desmontarse', () => { + settings = { brand: { icon_url: '/branding/sdlc/icon.svg' } }; + + const { unmount } = render(); + expect(document.querySelector('link[rel="icon"]')?.getAttribute('href')).toBe( + '/branding/sdlc/icon.svg' + ); + + unmount(); + expect(document.querySelector('link[rel="icon"]')).toBeNull(); + }); + + it('ignora un color inválido en vez de dejar la interfaz a medias', () => { + settings = { theme: { primary: 'no-es-un-color' } }; + render(); + expect(document.documentElement.style.getPropertyValue('--m3-primary')).toBe(''); + }); + + it('aplica el idioma que publica el sistema', () => { + settings = { locale: { language: 'en-US' } }; + render(); + + expect(useI18nStore.getState().language).toBe('en'); + }); + + it('no revierte el idioma al desmontarse', () => { + // A diferencia del tema o el título: el idioma sobrevive al cierre de sesión, o la pantalla + // de acceso aparecería en otro idioma que el que el usuario acaba de estar usando. + settings = { locale: { language: 'en-US' } }; + const { unmount } = render(); + unmount(); + + expect(useI18nStore.getState().language).toBe('en'); + }); + + it('aplica el modo oscuro que publica el sistema', () => { + settings = { theme: { mode: 'dark' } }; + render(); + + expect(useThemeStore.getState().isDarkMode).toBe(true); + }); + + it('no pisa el modo que el usuario eligió', () => { + useThemeStore.getState().toggleDarkMode(); // el usuario se pasa a oscuro + settings = { theme: { mode: 'light' } }; + render(); + + expect(useThemeStore.getState().isDarkMode).toBe(true); + }); + + it('con modo `system` sigue al sistema operativo y reacciona a sus cambios', () => { + const escuchas: Array<(e: { matches: boolean }) => void> = []; + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ + matches: false, + addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => escuchas.push(cb), + removeEventListener: vi.fn(), + })) + ); + + settings = { theme: { mode: 'system' } }; + render(); + expect(useThemeStore.getState().isDarkMode).toBe(false); + + // Anochece en el sistema operativo: `system` no es un modo fijo, es delegar. + escuchas.forEach(cb => cb({ matches: true })); + expect(useThemeStore.getState().isDarkMode).toBe(true); + + vi.unstubAllGlobals(); + }); + + it('escribe la densidad en la raíz y la retira al desmontarse', () => { + settings = { ui: { density: 'compact' } }; + const { unmount } = render(); + + expect(document.documentElement.dataset.density).toBe('compact'); + + unmount(); + expect(document.documentElement.dataset.density).toBeUndefined(); + }); + + it('ignora una densidad que el CSS no define', () => { + // Un valor desconocido dejaría las filas sin espaciado en vez de descolocar la interfaz. + settings = { ui: { density: 'holgadisima' } }; + render(); + + expect(document.documentElement.dataset.density).toBeUndefined(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.tsx b/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.tsx new file mode 100644 index 00000000..22ce614e --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.tsx @@ -0,0 +1,182 @@ +import { useEffect } from 'react'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; +import { useI18nStore } from '@app/stores/i18n.store'; +import { useThemeStore } from '@app/stores/theme.store'; + +/** + * Aplica al documento el tema, el título, el favicon, el idioma, el modo claro/oscuro y la + * densidad que publica el sistema en el grafo (G-178). + * + * No pinta nada: es un efecto. Vive como componente para colgar del ciclo de vida de React y + * revertirse solo cuando el usuario cambia de sistema o cierra sesión — un `useEffect` suelto en + * un layout se quedaría sin limpiar. + * + * Los tokens del tema son tripletas HSL sin envoltorio (`208 75% 23%`), no hexadecimales: Tailwind + * las compone con opacidad (`hsl(var(--m3-primary) / 0.3)`). Por eso el color que publica el + * sistema se convierte antes de escribirlo; inyectar el hexadecimal tal cual dejaría la mitad de + * la interfaz sin color, y solo en los sitios que usan transparencia. + */ +/** Densidades que el CSS del producto traduce a espaciado. Cualquier otra se ignora. */ +const DENSIDADES = new Set(['compact', 'comfortable', 'spacious']); + +export const SystemThemeApplier: React.FC = () => { + const { brand, theme, locale, ui } = useSystemSettings(); + const applySystemLanguage = useI18nStore(s => s.applySystemLanguage); + const applyDefaultDarkMode = useThemeStore(s => s.applyDefaultDarkMode); + + useEffect(() => { + const raiz = document.documentElement; + const previos: Array<[string, string]> = []; + + const aplicar = (token: string, hex: string | undefined) => { + const hsl = hexAHslCanales(hex); + if (!hsl) return; + previos.push([token, raiz.style.getPropertyValue(token)]); + raiz.style.setProperty(token, hsl); + }; + + aplicar('--m3-primary', theme.primary); + aplicar('--m3-tertiary', theme.accent); + + return () => { + // Al cambiar de sistema —o de perfil— el tema anterior no debe sobrevivir. + for (const [token, valor] of previos) { + if (valor) raiz.style.setProperty(token, valor); + else raiz.style.removeProperty(token); + } + }; + }, [theme.primary, theme.accent]); + + useEffect(() => { + if (!brand.displayName) return; + + const anterior = document.title; + document.title = brand.displayName; + return () => { + document.title = anterior; + }; + }, [brand.displayName]); + + useEffect(() => { + // Solo el icono, nunca el logotipo: la pestaña lo pinta en un cuadrado de ~16px y un lockup + // ancho ahí es un borrón. Sin icono configurado se queda el del producto, que es correcto. + if (!brand.iconUrl) return; + + const creado = !document.querySelector('link[rel="icon"]'); + const enlace = + document.querySelector('link[rel="icon"]') ?? + document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' })); + + const hrefAnterior = enlace.getAttribute('href'); + const tipoAnterior = enlace.getAttribute('type'); + + enlace.setAttribute('href', brand.iconUrl); + // El `type` declarado describe el recurso del producto (PNG) y el del sistema puede ser otro + // (SVG). Declarar un tipo que no corresponde hace que el navegador descarte el icono; sin el + // atributo lo deduce del recurso. + enlace.removeAttribute('type'); + + return () => { + if (creado) { + enlace.remove(); + return; + } + // Al cambiar de sistema o cerrar sesión vuelve el icono del producto. + if (hrefAnterior) enlace.setAttribute('href', hrefAnterior); + if (tipoAnterior) enlace.setAttribute('type', tipoAnterior); + }; + }, [brand.iconUrl]); + + useEffect(() => { + // Defecto, no imposición: el store ignora esto si el usuario ya eligió idioma. Y no se revierte + // al desmontar —a diferencia del tema o el título— porque el idioma de la interfaz sobrevive al + // cierre de sesión: devolverlo al salir dejaría la pantalla de acceso en otro idioma. + applySystemLanguage(locale.language); + }, [locale.language, applySystemLanguage]); + + useEffect(() => { + if (!theme.mode) return; + + // `system` no es un modo: es delegar en el sistema operativo, y por eso hay que seguir + // escuchándolo. Fijarlo una vez dejaría la interfaz en claro toda la noche. + if (theme.mode !== 'system') { + applyDefaultDarkMode(theme.mode === 'dark'); + return; + } + + const consulta = window.matchMedia('(prefers-color-scheme: dark)'); + applyDefaultDarkMode(consulta.matches); + + const alCambiar = (e: MediaQueryListEvent) => applyDefaultDarkMode(e.matches); + consulta.addEventListener('change', alCambiar); + return () => consulta.removeEventListener('change', alCambiar); + }, [theme.mode, applyDefaultDarkMode]); + + useEffect(() => { + // La densidad viaja como atributo y el CSS la traduce a espaciado: un valor desconocido no + // debe descolocar la interfaz, así que solo se aceptan los tres que el producto define. + if (!ui.density || !DENSIDADES.has(ui.density)) return; + + const raiz = document.documentElement; + const anterior = raiz.dataset.density; + raiz.dataset.density = ui.density; + + return () => { + if (anterior) raiz.dataset.density = anterior; + else delete raiz.dataset.density; + }; + }, [ui.density]); + + return null; +}; + +/** + * Convierte `#0f3e67` en `208 75% 23%`, el formato de los tokens. + * + * Devuelve `undefined` ante cualquier cosa que no sea un hexadecimal de 3 o 6 dígitos: un valor + * mal configurado en la base no debe romper la interfaz, solo dejar el color por defecto. + */ +export function hexAHslCanales(hex: string | undefined): string | undefined { + if (!hex) return undefined; + + const limpio = hex.trim().replace(/^#/, ''); + const expandido = + limpio.length === 3 + ? limpio + .split('') + .map(c => c + c) + .join('') + : limpio; + + if (!/^[0-9a-fA-F]{6}$/.test(expandido)) return undefined; + + const r = parseInt(expandido.slice(0, 2), 16) / 255; + const g = parseInt(expandido.slice(2, 4), 16) / 255; + const b = parseInt(expandido.slice(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + const d = max - min; + + let h = 0; + let s = 0; + + if (d !== 0) { + s = d / (1 - Math.abs(2 * l - 1)); + switch (max) { + case r: + h = ((g - b) / d) % 6; + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + } + h *= 60; + if (h < 0) h += 360; + } + + return `${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; +} diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ToastQueue.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ToastQueue.tsx index 48767f53..08af2a25 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/ToastQueue.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/ToastQueue.tsx @@ -50,6 +50,8 @@ const ToastItem: React.FC = React.memo(({ toast, onDismiss }) => return (
= React.memo( return ( - if (collapsed) { - return ( - - ); - } +
+ )} +
+ ); + })} + + ); + + const footer = ( +
+

{t.portalFooter}

+

{t.archVersion}

+
+ ); - return ( + // ── Rail de escritorio (oculto en < lg) ── + const desktopAside = collapsed ? (
+ + ) : ( + ); + + // ── Drawer móvil (oculto en ≥ lg) ── + const mobileDrawer = ( +
+
+ +
+ ); + + // Render condicional por JS (no solo CSS) para no duplicar el árbol de + // navegación en el DOM: rail en escritorio, drawer en móvil. + return isMobile ? mobileDrawer : desktopAside; }; diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.test.tsx index 7fc0fbd9..7a1a1c23 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.test.tsx @@ -22,11 +22,31 @@ vi.mock('../components/ToastQueue', () => ({ })); vi.mock('../components/Tooltip', () => ({ Tooltip: ({ children }: any) => <>{children} })); +/** Marca publicada por el sistema en el grafo (G-178). Vacía = se ve el producto por defecto. */ +let brand: { + displayName?: string; + tagline?: string; + logoUrl?: string; + iconUrl?: string; +} = {}; + +vi.mock('@app/authorization/hooks/use-system-settings', () => ({ + useSystemSettings: () => ({ brand, theme: {}, ui: {}, locale: {}, raw: {} }), +})); + +const mockI18nStore = (parcial: { language: string; setLanguage: () => void }) => { + const state = { ...parcial, applySystemLanguage: vi.fn(), chosenByUser: false }; + vi.mocked(i18nStoreModule.useI18nStore).mockImplementation((selector?: any) => + selector ? selector(state) : state + ); +}; + describe('TopAppBar', () => { const mockOnToggleNav = vi.fn(); beforeEach(() => { vi.restoreAllMocks(); + brand = {}; vi.mocked(authStoreModule.useAuthStore).mockReturnValue({ user: { id: 'u-1', username: 'testuser' }, @@ -40,10 +60,9 @@ describe('TopAppBar', () => { vi.mocked(devToolsStoreModule.useDevToolsStore).mockReturnValue({} as any); - vi.mocked(i18nStoreModule.useI18nStore).mockReturnValue({ - language: 'en', - setLanguage: vi.fn(), - } as any); + // Con selector o sin él: la barra lee el store entero y SystemThemeApplier —que la barra + // monta— lee `applySystemLanguage` con selector. + mockI18nStore({ language: 'en', setLanguage: vi.fn() }); vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation((selector: any) => { const state = { @@ -72,7 +91,9 @@ describe('TopAppBar', () => { it('renders app name', () => { render(); - expect(screen.getByText('UMS')).toBeInTheDocument(); + // Nombre corto y largo son el mismo texto cuando el producto no publica uno corto: el + // encabezado lleva las dos formas y el ancho decide cuál se ve. + expect(screen.getAllByText('UMS')).toHaveLength(2); }); it('renders app subtitle', () => { @@ -135,10 +156,7 @@ describe('TopAppBar', () => { it('calls setLanguage when language button is clicked', () => { const setLanguage = vi.fn(); - vi.mocked(i18nStoreModule.useI18nStore).mockReturnValue({ - language: 'en', - setLanguage, - } as any); + mockI18nStore({ language: 'en', setLanguage }); render(); const langButton = screen.getByLabelText('Switch to Spanish'); @@ -198,4 +216,102 @@ describe('TopAppBar', () => { render(); expect(screen.queryByLabelText('Log out')).not.toBeInTheDocument(); }); + + describe('marca del sistema', () => { + it('sin marca publicada se ve la del producto', () => { + render(); + + const marcas = screen.getAllByAltText('BEYONDNET'); + expect(marcas).toHaveLength(2); // claro y oscuro + expect(screen.getAllByText('UMS')).not.toHaveLength(0); + }); + + it('usa el icono en móvil y el logotipo desde sm', () => { + brand = { + displayName: 'Tablero de Gobierno SDLC', + logoUrl: '/branding/sdlc/logo.svg', + iconUrl: '/branding/sdlc/icon.svg', + }; + + render(); + const [compacta, ancha] = screen.getAllByAltText('Tablero de Gobierno SDLC'); + + expect(compacta).toHaveAttribute('src', '/branding/sdlc/icon.svg'); + expect(compacta.className).toContain('sm:hidden'); + expect(ancha).toHaveAttribute('src', '/branding/sdlc/logo.svg'); + expect(ancha.className).toContain('hidden sm:block'); + + // La marca del sistema sustituye a la del producto, no se suma a ella. + expect(screen.queryByAltText('BEYONDNET')).not.toBeInTheDocument(); + }); + + it('si solo hay logotipo, también sirve de marca compacta', () => { + // Un sistema a medio configurar debe verse completo en ambos tamaños, no dejar un hueco. + brand = { displayName: 'WMS', logoUrl: '/branding/wms/logo.svg' }; + + render(); + const marcas = screen.getAllByAltText('WMS'); + + expect(marcas).toHaveLength(2); + marcas.forEach(m => expect(m).toHaveAttribute('src', '/branding/wms/logo.svg')); + }); + + it('el nombre y el descriptor del sistema sustituyen a los del producto', () => { + brand = { + displayName: 'Tablero de Gobierno SDLC', + tagline: 'Gobierno del ciclo de vida de los sistemas satélite', + }; + + render(); + + expect(screen.getAllByText('Tablero de Gobierno SDLC')).not.toHaveLength(0); + expect( + screen.getByText('Gobierno del ciclo de vida de los sistemas satélite') + ).toBeInTheDocument(); + expect(screen.queryByText('UMS')).not.toBeInTheDocument(); + expect(screen.queryByText('User Management')).not.toBeInTheDocument(); + }); + + it('un sistema con nombre y sin descriptor no hereda el subtítulo del producto', () => { + // Mezclar el nombre de un sistema con el subtítulo de otro confunde sobre dónde se está. + brand = { displayName: 'Tablero de Gobierno SDLC' }; + + render(); + + expect(screen.getAllByText('Tablero de Gobierno SDLC')).not.toHaveLength(0); + expect(screen.queryByText('User Management')).not.toBeInTheDocument(); + }); + + it('en móvil el encabezado usa el nombre corto', () => { + brand = { displayName: 'Tablero de Gobierno SDLC', shortName: 'SDLC' }; + + render(); + const corto = screen.getByText('SDLC'); + const largo = screen.getByText('Tablero de Gobierno SDLC'); + + // El ancho decide cuál se ve: el corto desaparece desde `sm`, el largo hasta `sm`. + expect(corto.className).toContain('sm:hidden'); + expect(largo.className).toContain('hidden sm:inline'); + }); + + it('sin nombre corto se recorta el largo, nunca se cae al del producto', () => { + // Caer a «UMS» anunciaría un sistema distinto del que el usuario tiene abierto. + brand = { displayName: 'Tablero de Gobierno SDLC' }; + + render(); + + expect(screen.getAllByText('Tablero de Gobierno SDLC')).toHaveLength(2); + expect(screen.queryByText('UMS')).not.toBeInTheDocument(); + }); + + it('si solo hay icono, también ocupa el sitio del logotipo', () => { + brand = { displayName: 'WMS', iconUrl: '/branding/wms/icon.svg' }; + + render(); + const marcas = screen.getAllByAltText('WMS'); + + expect(marcas).toHaveLength(2); + marcas.forEach(m => expect(m).toHaveAttribute('src', '/branding/wms/icon.svg')); + }); + }); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx index 013c7a53..8a94a0ef 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react'; -import { Database, Menu, Sun, Moon, Bell, Globe, User, LogOut } from 'lucide-react'; +import { Menu, Sun, Moon, Bell, Globe, LogOut } from 'lucide-react'; import { useAuthStore } from '@app/stores/auth.store'; +import { ProfileSelector } from '@presentation/shared/components/ProfileSelector'; +import { SystemThemeApplier } from '@presentation/shared/components/SystemThemeApplier'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; import { useThemeStore } from '@app/stores/theme.store'; import { useDevToolsStore } from '@app/stores/devTools.store'; import { useI18nStore } from '@app/stores/i18n.store'; @@ -13,6 +16,7 @@ import { ConnectedUserDrawer } from '../components/ConnectedUserDrawer'; export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav }) => { const { user, logout } = useAuthStore(); + const { brand } = useSystemSettings(); const { isDarkMode, toggleDarkMode } = useThemeStore(); useDevToolsStore(); const { language, setLanguage } = useI18nStore(); @@ -22,6 +26,8 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } const [isUserDrawerOpen, setIsUserDrawerOpen] = useState(false); const unreadCount = notifications.filter(n => !n.read).length; + const etiquetaNotificaciones = + unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'; const handleLanguageToggle = () => { setLanguage(language === 'en' ? 'es' : 'en'); @@ -36,43 +42,83 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } return user.username.substring(0, 2).toUpperCase(); }; - const getTooltipMessage = () => { - if (!user) return 'Not logged in'; - return `${user.username}\n${user.email}\nTenant: ${user.tenantCode || 'N/A'}`; - }; + /** Hay marca propia del sistema en cuanto publique cualquiera de los dos recursos. */ + const marcaSistema = brand.iconUrl ?? brand.logoUrl; + const nombreDeMarca = brand.displayName ?? t.appName; + // Nombre y descriptor viajan juntos: si el sistema publica el suyo, la barra deja de anunciar + // el producto. Mezclar el nombre de uno con el subtítulo del otro solo confunde sobre dónde está + // el usuario, que es justo lo que esta zona de la barra responde. + const descriptorDeMarca = brand.tagline ?? (brand.displayName ? undefined : t.appSubtitle); + // Para el ancho de un móvil. El nombre corto existe justo para esto; si el sistema no lo publica + // se recorta el largo antes que caer al nombre del producto, que sería anunciar otro sistema. + const nombreCorto = brand.shortName ?? nombreDeMarca; return ( <>
-
+
-
-
- -
-
-

- {t.appName} +
+ {/* La marca del SISTEMA sustituye a la del producto solo si está configurada + (G-178). Sin ajustes publicados se ve el producto por defecto, no un hueco. + + Icono y logotipo no son el mismo recurso y por eso el sistema publica los dos: + el icono es la marca compacta —cuadrada— y el logotipo el lockup ancho. En móvil + manda el icono, donde el lockup competía por el ancho con el título; desde `sm`, + el logotipo. Cada uno respalda al otro: un sistema que solo configure uno se ve + completo en ambos tamaños. */} + {marcaSistema ? ( + <> + {nombreDeMarca} + {nombreDeMarca} + + ) : ( + <> + BEYONDNET + BEYONDNET + + )} +
+ {/* Un solo encabezado con dos formas del mismo nombre: el corto cabe en un móvil, + donde el largo se recortaría a la mitad de una palabra. El descriptor se reserva + para cuando hay sitio; en móvil compite con el nombre y pierde. */} +

+ {nombreCorto} + {nombreDeMarca}

-

- {t.appSubtitle} -

+ {descriptorDeMarca && ( +

+ {descriptorDeMarca} +

+ )}

-
+
{user && (
@@ -80,7 +126,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } {t.devUser} {user.username}
-
+
{user.isInternalAdmin ? 'Admin Local' : user.tenantName || 'Tenant N/A'} @@ -106,7 +152,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } aria-label={language === 'en' ? 'Switch to Spanish' : 'Switch to English'} className="p-2.5 rounded-full hover:bg-m3-primary/10 text-m3-secondary hover:text-m3-primary transition-all flex items-center gap-1.5 border border-m3-outline/30" > - + {language.toUpperCase()} @@ -120,7 +166,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } {isDarkMode ? ( ) : ( - + )} @@ -128,7 +174,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } + {/* Selector de perfil: se pinta solo si el usuario tiene más de uno (G-177). */} + + {user &&
} {user && ( @@ -168,6 +217,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav }
+ diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.test.tsx index 2c83675f..c701cacc 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { NAV_ROUTES, pathToTab } from './navigation.config'; +import { NAV_ROUTES } from './navigation.config'; describe('navigation.config', () => { it('exports NAV_ROUTES with correct paths', () => { @@ -9,52 +9,29 @@ describe('navigation.config', () => { expect(NAV_ROUTES.systemSuites).toBe('/system-suites'); expect(NAV_ROUTES.permissionTemplates).toBe('/permission-templates'); expect(NAV_ROUTES.featureFlags).toBe('/feature-flags'); + expect(NAV_ROUTES.appConfigurations).toBe('/app-configurations'); + expect(NAV_ROUTES.parameterCatalog).toBe('/parameter-catalog'); expect(NAV_ROUTES.profiles).toBe('/profiles'); expect(NAV_ROUTES.profile).toBe('/profile'); expect(NAV_ROUTES.login).toBe('/login'); }); - it('pathToTab returns correct tab for tenants path', () => { - expect(pathToTab('/tenants')).toBe('tenants'); - expect(pathToTab('/tenants/123')).toBe('tenants'); - }); - - it('pathToTab returns correct tab for users path', () => { - expect(pathToTab('/users')).toBe('users'); - expect(pathToTab('/users/456/edit')).toBe('users'); - }); - - it('pathToTab returns correct tab for delegations path', () => { - expect(pathToTab('/delegations')).toBe('delegations'); - }); - - it('pathToTab returns correct tab for system-suites path', () => { - expect(pathToTab('/system-suites')).toBe('systemSuites'); - expect(pathToTab('/system-suites/abc')).toBe('systemSuites'); - }); - - it('pathToTab returns correct tab for permission-templates path', () => { - expect(pathToTab('/permission-templates')).toBe('permissionTemplates'); - }); - - it('pathToTab returns correct tab for feature-flags path', () => { - expect(pathToTab('/feature-flags')).toBe('featureFlags'); - }); - - it('pathToTab returns correct tab for profiles path', () => { - expect(pathToTab('/profiles')).toBe('profiles'); - }); - - it('pathToTab returns correct tab for profile path', () => { - expect(pathToTab('/profile')).toBe('profile'); - }); - - it('pathToTab returns correct tab for login path', () => { - expect(pathToTab('/login')).toBe('login'); - }); - - it('pathToTab returns default tenants for unknown path', () => { - expect(pathToTab('/unknown')).toBe('tenants'); - expect(pathToTab('/')).toBe('tenants'); + it('las rutas del portal coinciden con las que siembra UMS en el grafo', () => { + // El servidor publica estas mismas rutas en `MenuNodePresentation` (AuthorizationDevDataSeeder). + // Si divergen, el menú construido desde el grafo llevaría a pantallas que el router no conoce. + const sembradas = [ + '/tenants', + '/users', + '/delegations', + '/system-suites', + '/permission-templates', + '/profiles', + '/feature-flags', + '/app-configurations', + '/parameter-catalog', + ]; + + const declaradas = new Set(Object.values(NAV_ROUTES)); + expect(sembradas.filter(r => !declaradas.has(r))).toEqual([]); }); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.tsx index 45de9b82..a9f1dea8 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.tsx @@ -1,9 +1,13 @@ /** - * navigation.config.ts — Static navigation structure for the sidebar rail. + * Estructura estática de navegación — **respaldo**, no fuente de verdad (G-181). * - * Keeps MainLayout focused on rendering/behaviour by moving the - * route → label → icon mapping into a declarative data structure. - * Translations are resolved at render time via the `nameKey` field. + * Desde que el shell construye el menú desde el grafo de autorización + * (ver `use-shell-navigation`), esta declaración solo se usa cuando el grafo no trae navegación: + * sesión sin sistema resuelto o entorno de desarrollo sin sembrar. Ahí el router sigue siendo la + * barrera real de acceso. + * + * Sigue siendo la fuente de `NAV_ROUTES` (rutas del portal, que el prefetch y el respaldo usan) y + * de los enlaces externos de observabilidad, que no pertenecen al grafo. */ import React from 'react'; @@ -28,11 +32,37 @@ export interface NavItem { icon: React.ReactNode; } +/** + * Enlace externo en la barra (p. ej. Grafana). A diferencia de {@link NavItem} + * no navega por el router: abre `href` en una pestaña nueva. Se usa para exponer + * la observabilidad técnica (métricas, trazas, logs) junto a la zona de + * autorización sin acoplarla a una ruta de la SPA. + */ +export interface NavExternalItem { + id: string; + nameKey: string; + icon: React.ReactNode; + href: string; + external: true; +} + +export type NavMember = NavItem | NavExternalItem; + +export const isExternalItem = (m: NavMember): m is NavExternalItem => + (m as NavExternalItem).external === true; + +/** + * Base de Grafana. Servida bajo el mismo origen en `/grafana/` (el nginx del + * frontend hace proxy; ver nginx.conf.template). Configurable por despliegue con + * VITE_GRAFANA_URL. Sin barra final: los enlaces la añaden. + */ +const GRAFANA_URL = (import.meta.env.VITE_GRAFANA_URL as string | undefined) ?? '/grafana'; + export interface NavModule { key: string; nameKey: string; icon: React.ReactNode; - members: NavItem[]; + members: NavMember[]; } export const NAV_ROUTES: Record = { @@ -49,31 +79,16 @@ export const NAV_ROUTES: Record = { login: '/login', }; -export const pathToTab = (pathname: string): NavItemId => { - if (pathname.startsWith('/tenants')) return 'tenants'; - if (pathname.startsWith('/users')) return 'users'; - if (pathname.startsWith('/delegations')) return 'delegations'; - if (pathname.startsWith('/system-suites')) return 'systemSuites'; - if (pathname.startsWith('/permission-templates')) return 'permissionTemplates'; - if (pathname.startsWith('/feature-flags')) return 'featureFlags'; - if (pathname.startsWith('/app-configurations')) return 'appConfigurations'; - if (pathname.startsWith('/parameter-catalog')) return 'parameterCatalog'; - if (pathname.startsWith('/profiles')) return 'profiles'; - if (pathname.startsWith('/profile')) return 'profile'; - if (pathname.startsWith('/login')) return 'login'; - return 'tenants'; -}; - interface NavModulesFactoryDeps { ShieldCheck: React.ComponentType<{ className?: string }>; Building2: React.ComponentType<{ className?: string }>; Users: React.ComponentType<{ className?: string }>; - GitMerge: React.ComponentType<{ className?: string }>; Cpu: React.ComponentType<{ className?: string }>; Flag: React.ComponentType<{ className?: string }>; User: React.ComponentType<{ className?: string }>; - LogOut: React.ComponentType<{ className?: string }>; Settings: React.ComponentType<{ className?: string }>; + Activity: React.ComponentType<{ className?: string }>; + ScrollText: React.ComponentType<{ className?: string }>; primaryColorClass: string; indigoColorClass: string; t: Record; @@ -94,7 +109,7 @@ export const NAV_MODULES = (deps: NavModulesFactoryDeps): NavModule[] => [ nameKey: 'authorizationContext', icon: , members: [ - { id: 'systemSuites', nameKey: 'systemSuites', icon: }, + { id: 'systemSuites', nameKey: 'systemSuitesNav', icon: }, { id: 'permissionTemplates', nameKey: 'permissionTemplates', @@ -102,6 +117,22 @@ export const NAV_MODULES = (deps: NavModulesFactoryDeps): NavModule[] => [ }, { id: 'profiles', nameKey: 'profilesHeader', icon: }, { id: 'featureFlags', nameKey: 'featureFlags', icon: }, + // Observabilidad de la zona de autorización: datos técnicos (métricas, + // trazas y logs) en Grafana, bajo el mismo origen en /grafana/. + { + id: 'grafana', + nameKey: 'observabilityGrafana', + icon: , + href: `${GRAFANA_URL}/d/ums-overview`, + external: true, + }, + { + id: 'logs', + nameKey: 'observabilityLogs', + icon: , + href: `${GRAFANA_URL}/explore`, + external: true, + }, ], }, { diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.icons.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.icons.tsx new file mode 100644 index 00000000..8d4cdd0a --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.icons.tsx @@ -0,0 +1,84 @@ +/** + * Registro de iconos de navegación. + * + * El servidor publica en cada nodo del grafo un **identificador** de icono (`"building"`, + * `"layout-dashboard"`…), nunca un SVG ni una URL: si guardara el recurso, la base de datos + * quedaría atada a la biblioteca gráfica de un frontend concreto y cambiarla exigiría migrar datos. + * + * Este módulo es el otro extremo de esa convención: traduce el identificador al icono real. Es el + * único punto del cliente que conoce la biblioteca; añadir un icono nuevo al catálogo del servidor + * solo obliga a añadir una entrada aquí. + */ +import React from 'react'; +import { + Activity, + Bell, + Building2, + CalendarDays, + CalendarRange, + Cpu, + FileText, + Flag, + FolderKanban, + GitMerge, + History, + Inbox, + LayoutDashboard, + LayoutGrid, + ListChecks, + Package, + Receipt, + SatelliteDish, + ScrollText, + Settings, + ShieldCheck, + Truck, + User, + UserRound, + Users, +} from 'lucide-react'; + +type ComponenteIcono = React.ComponentType<{ className?: string }>; + +const REGISTRO: Record = { + activity: Activity, + bell: Bell, + building: Building2, + 'calendar-days': CalendarDays, + 'calendar-range': CalendarRange, + cpu: Cpu, + 'file-text': FileText, + flag: Flag, + 'folder-kanban': FolderKanban, + 'git-merge': GitMerge, + history: History, + inbox: Inbox, + 'layout-dashboard': LayoutDashboard, + 'layout-grid': LayoutGrid, + 'list-checks': ListChecks, + package: Package, + receipt: Receipt, + 'satellite-dish': SatelliteDish, + 'scroll-text': ScrollText, + settings: Settings, + 'shield-check': ShieldCheck, + truck: Truck, + user: User, + 'user-round': UserRound, + users: Users, +}; + +/** Icono de respaldo: un identificador desconocido no debe dejar la fila sin icono y desalineada. */ +const RESPALDO: ComponenteIcono = LayoutGrid; + +export const iconoConocido = (identificador: string | null | undefined): boolean => + identificador != null && identificador in REGISTRO; + +/** Resuelve el identificador publicado por el grafo al icono que pinta el shell. */ +export const iconoDeNodo = ( + identificador: string | null | undefined, + className = 'w-4 h-4' +): React.ReactNode => { + const Icono = (identificador ? REGISTRO[identificador] : undefined) ?? RESPALDO; + return ; +}; diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.test.tsx new file mode 100644 index 00000000..8be8c9ec --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useShellNavigation, claveActiva } from './use-shell-navigation'; +import type { ShellModule } from './use-shell-navigation'; + +let menuAccess: unknown; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { menuAccess } } }), +})); + +vi.mock('@app/i18n/use-i18n', () => ({ + useI18n: () => ({ + identityContext: 'Identidad', + tenant: 'Empresas', + observabilityGrafana: 'Grafana', + observabilityLogs: 'Logs', + }), +})); + +const nodo = ( + code: string, + kind: string, + extra: Partial<{ icon: string | null; route: string | null; children: unknown[] }> = {} +) => ({ + code, + value: `Etiqueta ${code}`, + kind, + sortOrder: 1, + icon: extra.icon ?? null, + route: extra.route ?? null, + actions: [], + children: extra.children ?? [], +}); + +describe('useShellNavigation', () => { + beforeEach(() => { + menuAccess = undefined; + }); + + it('sin grafo cae al respaldo estático', () => { + const { result } = renderHook(() => useShellNavigation()); + + // El respaldo son los módulos declarados en navigation.config, con sus etiquetas traducidas. + expect(result.current.map(m => m.key)).toEqual(['idm', 'auth', 'sys']); + expect(result.current[0].label).toBe('Identidad'); + expect(result.current[0].items[0].route).toBe('/tenants'); + expect(result.current[0].items[0].prefetchId).toBe('tenants'); + }); + + it('con grafo, el menú viene del grafo: etiquetas, iconos y rutas del servidor', () => { + menuAccess = [ + { + code: 'IDM', + value: 'Identidad y Accesos', + sortOrder: 1, + status: 'Active', + icon: 'shield-check', + nodes: [ + nodo('TENANTS', 'Menu', { + icon: 'building', + route: '/tenants', + children: [ + nodo('TENANTS_LIST', 'SubMenu', { children: [nodo('VIEW_TENANTS', 'Option')] }), + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useShellNavigation()); + + expect(result.current).toHaveLength(1); + expect(result.current[0].label).toBe('Identidad y Accesos'); + + // La pantalla es el menú con ruta; lo que cuelga de ella son permisos, no destinos. + expect(result.current[0].items.map(i => i.key)).toEqual(['TENANTS']); + expect(result.current[0].items[0].label).toBe('Etiqueta TENANTS'); + expect(result.current[0].items[0].route).toBe('/tenants'); + expect(result.current[0].items[0].prefetchId).toBe('tenants'); + }); + + it('desciende hasta la primera ruta cuando el menú solo agrupa', () => { + menuAccess = [ + { + code: 'PRD', + value: 'Iniciativas', + sortOrder: 1, + status: 'Active', + nodes: [ + nodo('DASHBOARDS', 'Menu', { + icon: 'layout-dashboard', + children: [ + nodo('DASH_EJEC', 'SubMenu', { + children: [nodo('PORT_DASHBOARD', 'Option', { route: '/portafolio' })], + }), + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useShellNavigation()); + const items = result.current[0].items; + + expect(items.map(i => i.key)).toEqual(['PORT_DASHBOARD']); + expect(items[0].route).toBe('/portafolio'); + // Ruta ajena al portal: no hay pantalla que precargar, y eso no debe romper el hover. + expect(items[0].prefetchId).toBeUndefined(); + }); + + it('conserva los enlaces externos de observabilidad en su módulo', () => { + menuAccess = [ + { + code: 'AUTH', + value: 'Autorización', + sortOrder: 1, + status: 'Active', + nodes: [nodo('SYSTEM_SUITES', 'Menu', { route: '/system-suites' })], + }, + ]; + + const { result } = renderHook(() => useShellNavigation()); + const items = result.current[0].items; + + // Grafana no es una ruta del grafo —aplica su propia autenticación—, pero vive en ese módulo. + expect(items.map(i => i.key)).toEqual(['SYSTEM_SUITES', 'grafana', 'logs']); + expect(items[1].href).toContain('/grafana'); + expect(items[1].route).toBeUndefined(); + }); + + it('descarta el módulo que no aporta ninguna fila navegable', () => { + menuAccess = [ + { + code: 'VACIO', + value: 'Sin pantallas', + sortOrder: 1, + status: 'Active', + nodes: [nodo('SOLO_AGRUPA', 'Menu')], + }, + ]; + + const { result } = renderHook(() => useShellNavigation()); + expect(result.current).toEqual([]); + }); +}); + +describe('claveActiva', () => { + const modulos = [ + { + key: 'AUTH', + label: 'Autorización', + icon: null, + items: [ + { key: 'SUITES', label: 'Sistemas', icon: null, route: '/system-suites' }, + { key: 'PERFIL', label: 'Perfil', icon: null, route: '/profile' }, + { key: 'PERFILES', label: 'Perfiles', icon: null, route: '/profiles' }, + { key: 'GRAFANA', label: 'Grafana', icon: null, href: '/grafana' }, + ], + }, + ] as unknown as ShellModule[]; + + it('marca la fila cuya ruta contiene a la actual', () => { + expect(claveActiva(modulos, '/system-suites')).toBe('SUITES'); + expect(claveActiva(modulos, '/system-suites/abc')).toBe('SUITES'); + }); + + it('no confunde rutas que comparten prefijo textual', () => { + // `/profiles` no es un hijo de `/profile`: el corte debe ser por segmento, no por caracteres. + expect(claveActiva(modulos, '/profiles')).toBe('PERFILES'); + expect(claveActiva(modulos, '/profile')).toBe('PERFIL'); + }); + + it('devuelve null fuera de la navegación', () => { + // Antes se devolvía «tenants» por defecto, y una ruta desconocida dejaba una fila marcada + // como activa sin estarlo. + expect(claveActiva(modulos, '/login')).toBeNull(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.tsx new file mode 100644 index 00000000..c1ece361 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.tsx @@ -0,0 +1,183 @@ +/** + * La navegación que pinta el shell, con el grafo como fuente de verdad (G-181). + * + * Hasta ahora el menú se declaraba en `navigation.config` y el grafo solo se usaba para **filtrar** + * esa lista, traduciendo a mano el id de cada pantalla a un código de menú. Eran dos fuentes: dar de + * alta una opción en UMS no la hacía aparecer, y renombrar un menú en la base no cambiaba la barra. + * + * Aquí la relación se invierte: el grafo **construye** el menú —módulos, etiquetas, iconos y rutas— + * y la configuración estática queda como respaldo para cuando no hay grafo (sesión sin sistema + * resuelto o entorno de desarrollo sin sembrar), donde el router sigue siendo la última barrera. + * + * No se vuelve a filtrar por permiso: el árbol llega podado desde el servidor y filtrarlo otra vez + * en el cliente solo abriría la puerta a que ambas reglas discrepen. + */ +import React, { useMemo } from 'react'; +import { useI18n } from '@app/i18n/use-i18n'; +import { + useGraphNavigation, + type NavigationItem, + type NavigationModule, +} from '@app/authorization/hooks/use-graph-navigation'; +import { + Activity, + Building2, + Cpu, + Flag, + ScrollText, + Settings, + ShieldCheck, + User, + Users, +} from 'lucide-react'; +import { NAV_MODULES, NAV_ROUTES, isExternalItem } from './navigation.config'; +import type { NavItemId, NavMember } from './navigation.config'; +import { iconoDeNodo } from './navigation.icons'; + +export interface ShellItem { + key: string; + label: string; + icon: React.ReactNode; + /** Ruta interna del router. Excluyente con {@link href}. */ + route?: string; + /** Enlace externo (se abre en pestaña nueva). Excluyente con {@link route}. */ + href?: string; + /** Pantalla conocida para el prefetch en hover; ausente si la ruta no es del portal. */ + prefetchId?: NavItemId; +} + +export interface ShellModule { + key: string; + label: string; + icon: React.ReactNode; + items: ShellItem[]; +} + +const RUTA_A_PANTALLA: Record = Object.fromEntries( + Object.entries(NAV_ROUTES).map(([id, ruta]) => [ruta, id as NavItemId]) +) as Record; + +/** + * Convierte el árbol de un módulo en las filas de la barra. + * + * Se detiene en el primer nodo con ruta: lo que cuelga de una pantalla son **permisos sobre ella** + * —ver, crear, exportar—, no destinos distintos, y pintarlos como enlaces llenaría el menú de rutas + * que no existen. Un nodo sin ruta solo agrupa, así que se desciende a sus hijos y su icono se + * hereda, para que una opción sin icono propio no rompa la columna. + */ +function filasDe(items: readonly NavigationItem[], iconoHeredado: string | null): ShellItem[] { + const salida: ShellItem[] = []; + + for (const item of items) { + const icono = item.icon ?? iconoHeredado; + + if (item.route) { + salida.push({ + key: item.code, + label: item.label, + icon: iconoDeNodo(icono), + route: item.route, + prefetchId: RUTA_A_PANTALLA[item.route], + }); + continue; + } + + salida.push(...filasDe(item.children, icono)); + } + + return salida; +} + +function desdeGrafo( + modulos: NavigationModule[], + externos: Map +): ShellModule[] { + return modulos + .map(modulo => ({ + key: modulo.code, + label: modulo.label, + // Desde el contrato 2.3.0 el módulo publica su icono (G-182). Sin él, respaldo neutro: + // resolverlo por código sería reintroducir la tabla estática que este cambio eliminó. + icon: iconoDeNodo(modulo.icon, 'w-5 h-5 text-m3-primary'), + // Los enlaces externos (observabilidad) no son rutas del grafo —Grafana aplica su propia + // autenticación—, así que se anexan al módulo al que pertenecen por convención de código. + items: [...filasDe(modulo.items, null), ...(externos.get(modulo.code) ?? [])], + })) + .filter(modulo => modulo.items.length > 0); +} + +const filaEstatica = (miembro: NavMember, t: Record): ShellItem => ({ + key: miembro.id, + label: t[miembro.nameKey] ?? miembro.nameKey, + icon: miembro.icon, + ...(isExternalItem(miembro) + ? { href: miembro.href } + : { route: NAV_ROUTES[miembro.id], prefetchId: miembro.id }), +}); + +export function useShellNavigation(): ShellModule[] { + const t = useI18n() as unknown as Record; + const grafo = useGraphNavigation(); + + const estaticos = useMemo( + () => + NAV_MODULES({ + ShieldCheck, + Building2, + Users, + Cpu, + Flag, + User, + Settings, + Activity, + ScrollText, + primaryColorClass: 'text-m3-primary', + indigoColorClass: 'text-indigo-400', + t, + }), + [t] + ); + + return useMemo(() => { + if (!grafo.length) { + return estaticos.map(modulo => ({ + key: modulo.key, + label: t[modulo.nameKey] ?? modulo.nameKey, + icon: modulo.icon, + items: modulo.members.map(miembro => filaEstatica(miembro, t)), + })); + } + + const externos = new Map(); + for (const modulo of estaticos) { + const enlaces = modulo.members.filter(isExternalItem).map(m => filaEstatica(m, t)); + if (enlaces.length) externos.set(modulo.key.toUpperCase(), enlaces); + } + + return desdeGrafo(grafo, externos); + }, [grafo, estaticos, t]); +} + +/** + * Resuelve qué fila marcar como activa a partir de la ruta actual. + * + * Gana el prefijo más largo: con `/system-suites/abc` y `/system-suites` declarados, la fila activa + * debe ser la más específica, no la primera que case. + */ +export function claveActiva(modulos: ShellModule[], pathname: string): string | null { + let clave: string | null = null; + let largo = -1; + + for (const modulo of modulos) { + for (const item of modulo.items) { + if (!item.route) continue; + const casa = pathname === item.route || pathname.startsWith(`${item.route}/`); + if (casa && item.route.length > largo) { + clave = item.key; + largo = item.route.length; + } + } + } + + return clave; +} diff --git a/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.test.ts b/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.test.ts new file mode 100644 index 00000000..6c973af5 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { pluralizeEs, formatItemCountLabel } from './pluralize'; + +describe('pluralizeEs', () => { + it('agrega -s a palabras terminadas en vocal', () => { + expect(pluralizeEs('módulo')).toBe('módulos'); + expect(pluralizeEs('recurso')).toBe('recursos'); + expect(pluralizeEs('cuenta')).toBe('cuentas'); + expect(pluralizeEs('suite')).toBe('suites'); + }); + + it('convierte -ión en -iones (sin tilde)', () => { + expect(pluralizeEs('acción')).toBe('acciones'); + expect(pluralizeEs('Acción')).toBe('Acciones'); + expect(pluralizeEs('configuración')).toBe('configuraciones'); + expect(pluralizeEs('delegación')).toBe('delegaciones'); + }); + + it('convierte -z en -ces', () => { + expect(pluralizeEs('voz')).toBe('voces'); + }); + + it('agrega -es a otras consonantes', () => { + expect(pluralizeEs('Rol')).toBe('Roles'); + expect(pluralizeEs('perfil')).toBe('perfiles'); + expect(pluralizeEs('sucursal')).toBe('sucursales'); + expect(pluralizeEs('proveedor')).toBe('proveedores'); + expect(pluralizeEs('solicitud')).toBe('solicitudes'); + }); +}); + +describe('formatItemCountLabel', () => { + it('usa el singular cuando el conteo es 1', () => { + expect(formatItemCountLabel(1, 'Acción')).toBe('1 Acción'); + expect(formatItemCountLabel(1, 'Rol')).toBe('1 Rol'); + }); + + it('deriva el plural en español cuando el conteo no es 1', () => { + expect(formatItemCountLabel(0, 'Acción')).toBe('0 Acciones'); + expect(formatItemCountLabel(7, 'Acción')).toBe('7 Acciones'); + expect(formatItemCountLabel(4, 'Rol')).toBe('4 Roles'); + }); + + it('respeta el plural explícito para préstamos/irregulares', () => { + expect(formatItemCountLabel(3, 'tenant', 'tenants')).toBe('3 tenants'); + expect(formatItemCountLabel(2, 'flag', 'flags')).toBe('2 flags'); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.ts b/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.ts new file mode 100644 index 00000000..66870d34 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.ts @@ -0,0 +1,32 @@ +/** + * pluralize.ts — pluralización en español para los contadores de las barras de + * herramientas (ListToolbar, ChildEntityToolbar, PermissionSectionToolbar). + * + * Reglas cubiertas: + * - Termina en vocal (incluidas acentuadas) → +s (módulo → módulos) + * - Termina en «-ión» → «-iones» (acción → acciones) + * - Termina en «z» → «-ces» (voz → voces) + * - Termina en otra consonante → +es (rol → roles, perfil → perfiles) + * + * Los préstamos que no siguen la regla (tenant → tenants, flag → flags) deben + * pasar su plural explícito; el helper lo respeta si se provee. + */ +export function pluralizeEs(word: string): string { + if (!word) return word; + if (/[aeiouáéíóú]$/i.test(word)) return `${word}s`; + if (/ión$/i.test(word)) return word.replace(/ión$/i, 'iones'); + if (/z$/i.test(word)) return word.replace(/z$/i, 'ces'); + return `${word}es`; +} + +/** + * Devuelve «N etiqueta» con la etiqueta en singular o plural según el conteo. + * @param count número de elementos. + * @param singular etiqueta en singular (p.ej. «Acción»). + * @param plural plural explícito para préstamos/irregulares; si se omite se + * deriva con {@link pluralizeEs}. + */ +export function formatItemCountLabel(count: number, singular: string, plural?: string): string { + const label = count === 1 ? singular : (plural ?? pluralizeEs(singular)); + return `${count} ${label}`; +} diff --git a/src/apps/ums.web-app/src/test/mocks/data/system-suites.mock.ts b/src/apps/ums.web-app/src/test/mocks/data/system-suites.mock.ts index e3b5c476..6eb2cda2 100644 --- a/src/apps/ums.web-app/src/test/mocks/data/system-suites.mock.ts +++ b/src/apps/ums.web-app/src/test/mocks/data/system-suites.mock.ts @@ -1,21 +1,63 @@ /** - * system-suites.mock.ts — Mock data matching AuthorizationDevDataSeeder exactly + * system-suites.mock.ts — Datos de prueba del árbol de nodos recursivo (ADR-0090) * - * IDs are generated dynamically to match the actual database seed values. - * Covers all CRUD scenarios: Active/Maintenance/Deprecated states, - * full module→menu→submenu→option hierarchies, domain resources. + * Refleja el modelo flexible Suite→Módulo→nodos (kind Menu/SubMenu/Option con + * profundidad variable y funcionalidad N:M por `actionCodes`). Los IDs son + * marcadores; el frontend depende de la respuesta REST, no de IDs fijos. + * Se usa principalmente para pruebas unitarias y Storybook. */ import type { SystemSuite, SystemSuiteDomainResource, } from '@domain/authorization/models/system-suite.model'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; // ── Constants matching CoreDevDataSeeder ──────────────────────────────────── const RANSA_TENANT_ID = '3fa85f64-5717-4562-b3fc-2c963f66afa6'; -// Note: IDs below are placeholders. The actual IDs are generated by the domain -// aggregates at seed time. The frontend should rely on the GraphQL response, -// not hardcoded IDs. These mocks are primarily for unit tests and Storybook. +// Helper para construir nodos hoja (Opción) con su vínculo N:M. +const optionNode = ( + id: string, + code: string, + label: string, + description: string, + actionCode: string, + sortOrder: number +): SystemSuiteNode => ({ + id, + parentNodeId: null, + kind: 'Option', + code, + label, + description, + status: 'Active', + sortOrder, + actionCodes: [actionCode], + metadata: null, + children: [], +}); + +const branchNode = ( + id: string, + kind: 'Menu' | 'SubMenu', + code: string, + label: string, + description: string, + sortOrder: number, + children: SystemSuiteNode[] +): SystemSuiteNode => ({ + id, + parentNodeId: null, + kind, + code, + label, + description, + status: 'Active', + sortOrder, + actionCodes: [], + metadata: null, + children, +}); // ── Suite 1: LOGISTICS_CORE ────────────────────────────────────────────────── const logisticsCoreModules = [ @@ -26,100 +68,23 @@ const logisticsCoreModules = [ description: 'Security, user management, and audit trailing modules', status: 'Active', sortOrder: 1, - menus: [ - { - id: 'users-menu-id', - code: 'USERS', - label: 'Users Administration', - description: 'Manage user accounts and details', - sortOrder: 1, - subMenus: [ - { - id: 'list-submenu-id', - code: 'LIST', - label: 'User Directory', - description: 'View and search all user accounts', - sortOrder: 1, - options: [ - { - id: 'view-users-opt-id', - code: 'VIEW_USERS', - label: 'View Users List', - description: 'Permission to view the users list', - actionCode: 'VIEW', - sortOrder: 1, - }, - { - id: 'edit-users-opt-id', - code: 'EDIT_USERS', - label: 'Edit User Profiles', - description: 'Permission to edit and modify user profiles', - actionCode: 'MANAGE', - sortOrder: 2, - }, - ], - }, - { - id: 'roles-submenu-id', - code: 'ROLES', - label: 'Roles & Permissions', - description: 'Manage access control roles and templates', - sortOrder: 2, - options: [ - { - id: 'view-roles-opt-id', - code: 'VIEW_ROLES', - label: 'View Security Roles', - description: 'Permission to view roles in system', - actionCode: 'VIEW', - sortOrder: 1, - }, - { - id: 'manage-roles-opt-id', - code: 'MANAGE_ROLES', - label: 'Configure Permissions', - description: 'Permission to edit access rights', - actionCode: 'MANAGE', - sortOrder: 2, - }, - ], - }, - ], - }, - { - id: 'audit-menu-id', - code: 'AUDIT', - label: 'Audit Trails', - description: 'System operations logging and analysis', - sortOrder: 2, - subMenus: [ - { - id: 'logs-submenu-id', - code: 'LOGS', - label: 'System Logs', - description: 'View system telemetry and user transactions', - sortOrder: 1, - options: [ - { - id: 'view-logs-opt-id', - code: 'VIEW_LOGS', - label: 'Search Audit Trail', - description: 'Permission to query audit logs', - actionCode: 'VIEW', - sortOrder: 1, - }, - { - id: 'purge-logs-opt-id', - code: 'PURGE_LOGS', - label: 'Purge Historical Data', - description: 'Permission to clear obsolete log records', - actionCode: 'APPROVE', - sortOrder: 2, - }, - ], - }, - ], - }, + nodes: [ + branchNode('users-menu-id', 'Menu', 'USERS', 'Users Administration', 'Manage user accounts', 1, [ + branchNode('list-submenu-id', 'SubMenu', 'LIST', 'User Directory', 'View and search users', 1, [ + optionNode('view-users-opt-id', 'VIEW_USERS', 'View Users List', 'View users list', 'VIEW', 1), + optionNode('edit-users-opt-id', 'EDIT_USERS', 'Edit User Profiles', 'Edit user profiles', 'MANAGE', 2), + ]), + branchNode('roles-submenu-id', 'SubMenu', 'ROLES', 'Roles & Permissions', 'Manage roles', 2, [ + optionNode('view-roles-opt-id', 'VIEW_ROLES', 'View Security Roles', 'View roles', 'VIEW', 1), + optionNode('manage-roles-opt-id', 'MANAGE_ROLES', 'Configure Permissions', 'Edit access rights', 'MANAGE', 2), + ]), + ]), + branchNode('audit-menu-id', 'Menu', 'AUDIT', 'Audit Trails', 'System operations logging', 2, [ + branchNode('logs-submenu-id', 'SubMenu', 'LOGS', 'System Logs', 'View telemetry', 1, [ + optionNode('view-logs-opt-id', 'VIEW_LOGS', 'Search Audit Trail', 'Query audit logs', 'VIEW', 1), + optionNode('purge-logs-opt-id', 'PURGE_LOGS', 'Purge Historical Data', 'Clear log records', 'APPROVE', 2), + ]), + ]), ], }, { @@ -129,58 +94,16 @@ const logisticsCoreModules = [ description: 'Global properties, settings and email setup', status: 'Active', sortOrder: 2, - menus: [ - { - id: 'settings-menu-id', - code: 'SETTINGS', - label: 'Global Setup', - description: 'Configure global system variables', - sortOrder: 1, - subMenus: [ - { - id: 'params-submenu-id', - code: 'PARAMS', - label: 'App Parameters', - description: 'Configure timeouts, thresholds and limits', - sortOrder: 1, - options: [ - { - id: 'view-params-opt-id', - code: 'VIEW_PARAMS', - label: 'View Parameters', - description: 'Permission to view system options', - actionCode: 'VIEW', - sortOrder: 1, - }, - { - id: 'edit-params-opt-id', - code: 'EDIT_PARAMS', - label: 'Update Global Config', - description: 'Permission to edit critical global values', - actionCode: 'MANAGE', - sortOrder: 2, - }, - ], - }, - { - id: 'smtp-submenu-id', - code: 'SMTP', - label: 'SMTP Server Setup', - description: 'Email gateway and server connection', - sortOrder: 2, - options: [ - { - id: 'test-smtp-opt-id', - code: 'TEST_SMTP', - label: 'Test SMTP Gateway', - description: 'Permission to trigger email delivery test', - actionCode: 'APPROVE', - sortOrder: 1, - }, - ], - }, - ], - }, + nodes: [ + branchNode('settings-menu-id', 'Menu', 'SETTINGS', 'Global Setup', 'Configure global variables', 1, [ + branchNode('params-submenu-id', 'SubMenu', 'PARAMS', 'App Parameters', 'Timeouts and limits', 1, [ + optionNode('view-params-opt-id', 'VIEW_PARAMS', 'View Parameters', 'View options', 'VIEW', 1), + optionNode('edit-params-opt-id', 'EDIT_PARAMS', 'Update Global Config', 'Edit global values', 'MANAGE', 2), + ]), + branchNode('smtp-submenu-id', 'SubMenu', 'SMTP', 'SMTP Server Setup', 'Email gateway', 2, [ + optionNode('test-smtp-opt-id', 'TEST_SMTP', 'Test SMTP Gateway', 'Trigger email test', 'APPROVE', 1), + ]), + ]), ], }, ]; @@ -244,75 +167,19 @@ const wmsModules = [ description: 'Inventory management and levels', status: 'Active', sortOrder: 1, - menus: [ - { - id: 'stock-menu-id', - code: 'STOCK', - label: 'Stock Administration', - description: 'Stock levels and status', - sortOrder: 1, - subMenus: [ - { - id: 'levels-submenu-id', - code: 'LEVELS', - label: 'Real-time Levels', - description: 'Current physical stock status', - sortOrder: 1, - options: [ - { - id: 'view-stock-opt-id', - code: 'VIEW_STOCK', - label: 'View Stock Levels', - description: 'Permission to view real-time inventory counts', - actionCode: 'INVENTORY_VIEW', - sortOrder: 1, - }, - { - id: 'adjust-stock-opt-id', - code: 'ADJUST_STOCK', - label: 'Adjust Inventory Counts', - description: 'Permission to perform physical inventory adjustments', - actionCode: 'INVENTORY_EDIT', - sortOrder: 2, - }, - ], - }, - ], - }, - { - id: 'ops-menu-id', - code: 'OPS', - label: 'Warehouse Operations', - description: 'Stock movements and transfers', - sortOrder: 2, - subMenus: [ - { - id: 'transfers-submenu-id', - code: 'TRANSFERS', - label: 'Warehouse Transfers', - description: 'Move stock between physical locations', - sortOrder: 1, - options: [ - { - id: 'initiate-transfer-opt-id', - code: 'INITIATE_TRANSFER', - label: 'Initiate Stock Transfer', - description: 'Permission to draft and start a transfer request', - actionCode: 'INVENTORY_EDIT', - sortOrder: 1, - }, - { - id: 'approve-transfer-opt-id', - code: 'APPROVE_TRANSFER', - label: 'Approve Location Transfer', - description: 'Permission to authorize inventory relocation', - actionCode: 'INVENTORY_EDIT', - sortOrder: 2, - }, - ], - }, - ], - }, + nodes: [ + branchNode('stock-menu-id', 'Menu', 'STOCK', 'Stock Administration', 'Stock levels and status', 1, [ + branchNode('levels-submenu-id', 'SubMenu', 'LEVELS', 'Real-time Levels', 'Current stock status', 1, [ + optionNode('view-stock-opt-id', 'VIEW_STOCK', 'View Stock Levels', 'View inventory counts', 'INVENTORY_VIEW', 1), + optionNode('adjust-stock-opt-id', 'ADJUST_STOCK', 'Adjust Inventory Counts', 'Physical adjustments', 'INVENTORY_EDIT', 2), + ]), + ]), + branchNode('ops-menu-id', 'Menu', 'OPS', 'Warehouse Operations', 'Stock movements', 2, [ + branchNode('transfers-submenu-id', 'SubMenu', 'TRANSFERS', 'Warehouse Transfers', 'Move stock', 1, [ + optionNode('initiate-transfer-opt-id', 'INITIATE_TRANSFER', 'Initiate Stock Transfer', 'Start a transfer', 'INVENTORY_EDIT', 1), + optionNode('approve-transfer-opt-id', 'APPROVE_TRANSFER', 'Approve Location Transfer', 'Authorize relocation', 'INVENTORY_EDIT', 2), + ]), + ]), ], }, ]; diff --git a/src/apps/ums.web-app/src/test/mocks/data/tenants.mock.ts b/src/apps/ums.web-app/src/test/mocks/data/tenants.mock.ts index e87dba77..99ef8ecc 100644 --- a/src/apps/ums.web-app/src/test/mocks/data/tenants.mock.ts +++ b/src/apps/ums.web-app/src/test/mocks/data/tenants.mock.ts @@ -54,19 +54,6 @@ export const mockTenants = [ isActive: true, }, ], - branding: { - logo: 'base64_ransa_logo_data', - logoFormat: 'Png', - primaryColor: '#006400', - backgroundStyle: 'SolidColor', - headlineText: 'Bienvenido a Ransa', - secondaryText: 'Ingresa tus credenciales', - primaryButtonLabel: 'Iniciar sesión', - footerText: '© 2026 Ransa Comercial', - customDomain: 'login.ransa.pe', - magicLinkFallbackEnabled: true, - dnsVerificationStatus: 'Pending', - }, }, { tenantId: 'c9b736b4-6a84-48f8-b34d-176bc5a6d542', @@ -102,19 +89,6 @@ export const mockTenants = [ isActive: true, }, ], - branding: { - logo: 'base64_neptunia_logo_data', - logoFormat: 'Png', - primaryColor: '#00008B', - backgroundStyle: 'Gradient', - headlineText: 'Portal Neptunia', - secondaryText: 'Accesos a operaciones portuarias', - primaryButtonLabel: 'Entrar', - footerText: '© 2026 Neptunia', - customDomain: 'acceso.neptunia.pe', - magicLinkFallbackEnabled: false, - dnsVerificationStatus: 'Pending', - }, }, { tenantId: 'a3f5b9d2-7c3d-4c8e-a9b0-123456789abc', @@ -134,7 +108,6 @@ export const mockTenants = [ }, ], identityProviders: [], - branding: null, }, { tenantId: '9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe', @@ -161,12 +134,11 @@ export const mockTenants = [ }, ], identityProviders: [], - branding: null, }, { tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - code: 'UNIMAR', - name: 'Unimar S.A. — Lima', + code: 'BEYONDNET', + name: 'BeyondNet S.A.C. — Lima', type: 'Supplier', status: 'Active', companyReference: '20101523381', @@ -181,14 +153,13 @@ export const mockTenants = [ }, { branchId: 'b1000001-0000-4000-8000-000000000002', - code: 'UNI_CALLAO_OP', + code: 'BN_CALLAO_OP', name: 'Operaciones Callao — Jr. Colón', isActive: true, geofencingMetadata: null, }, ], identityProviders: [], - branding: null, }, { tenantId: 'f3e2d1c0-b9a8-7f6e-5d4c-321098765432', @@ -215,6 +186,5 @@ export const mockTenants = [ }, ], identityProviders: [], - branding: null, }, ]; diff --git a/src/apps/ums.web-app/src/test/mocks/data/user-accounts.mock.ts b/src/apps/ums.web-app/src/test/mocks/data/user-accounts.mock.ts index ce43c52b..ca7ef022 100644 --- a/src/apps/ums.web-app/src/test/mocks/data/user-accounts.mock.ts +++ b/src/apps/ums.web-app/src/test/mocks/data/user-accounts.mock.ts @@ -1,7 +1,7 @@ /** * user-accounts.mock.ts — Mock data matching IdentityDevDataSeeder exactly * - * 20 users across 4 tenants (RANSA, NEPTUNIA, APM, UNIMAR). + * 20 users across 4 tenants (RANSA, NEPTUNIA, APM, BEYONDNET). * Covers all status scenarios: Active, Pending, Blocked. * Covers all categories: Internal, External, Partner. */ @@ -145,11 +145,11 @@ export const mockUserAccounts = { profileId: null, identityReference: 'DNI-44556677', }, - // ─ UNIMAR (5f4e3d2c-...) ────────────────────────────────────────────── + // ─ BEYONDNET (5f4e3d2c-...) ────────────────────────────────────────────── { userAccountId: '5f4e3d01-1b0a-9f8e-7d6c-543210987654', tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - email: 'gerente.operaciones@unimar.com.pe', + email: 'gerente.operaciones@beyondnet.com.pe', category: 'Internal', status: 'Active', profileId: null, @@ -158,7 +158,7 @@ export const mockUserAccounts = { { userAccountId: '5f4e3d02-1b0a-9f8e-7d6c-543210987654', tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - email: 'analista.inventario@unimar.com.pe', + email: 'analista.inventario@beyondnet.com.pe', category: 'Internal', status: 'Active', profileId: null, @@ -167,7 +167,7 @@ export const mockUserAccounts = { { userAccountId: '5f4e3d03-1b0a-9f8e-7d6c-543210987654', tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - email: 'coordinador.flota@unimar.com.pe', + email: 'coordinador.flota@beyondnet.com.pe', category: 'External', status: 'Pending', profileId: null, @@ -176,7 +176,7 @@ export const mockUserAccounts = { { userAccountId: '5f4e3d04-1b0a-9f8e-7d6c-543210987654', tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - email: 'ex.empleado@unimar.com.pe', + email: 'ex.empleado@beyondnet.com.pe', category: 'External', status: 'Blocked', profileId: null, diff --git a/src/apps/ums.web-app/src/test/mocks/handlers.ts b/src/apps/ums.web-app/src/test/mocks/handlers.ts index ffdea53c..312f940a 100644 --- a/src/apps/ums.web-app/src/test/mocks/handlers.ts +++ b/src/apps/ums.web-app/src/test/mocks/handlers.ts @@ -1,8 +1,4 @@ -import { http, graphql, HttpResponse } from 'msw'; -import { mockTenants } from './data/tenants.mock'; -import { mockUserAccounts } from './data/user-accounts.mock'; -import { mockDelegations } from './data/delegations.mock'; -import { mockSystemSuites } from './data/system-suites.mock'; +import { http, HttpResponse } from 'msw'; export const handlers = [ // Example REST interception @@ -15,153 +11,4 @@ export const handlers = [ status: 'Active', }); }), - - // Example GraphQL interception - graphql.query('Tenants', () => { - return HttpResponse.json({ - data: { - getTenants: { - items: mockTenants, - totalItems: mockTenants.length, - totalPages: 1, - page: 1, - pageSize: 20, - }, - }, - }); - }), - - graphql.query('UserAccounts', () => { - return HttpResponse.json({ - data: { - getUserAccounts: { - items: mockUserAccounts.items, - totalItems: mockUserAccounts.totalCount, - totalPages: 1, - page: 1, - pageSize: 20, - }, - }, - }); - }), - - graphql.query('DelegationsByDelegatedAdmin', () => { - return HttpResponse.json({ - data: { - getDelegationsByDelegatedAdmin: mockDelegations, - }, - }); - }), - - graphql.query('DelegationsByDelegatingAdmin', () => { - return HttpResponse.json({ - data: { - getDelegationsByDelegatingAdmin: mockDelegations, - }, - }); - }), - - graphql.query('Tenant', ({ variables }) => { - const { tenantId } = variables; - const tenant = mockTenants.find(t => t.tenantId === tenantId) || mockTenants[0]; - return HttpResponse.json({ - data: { - getTenantById: tenant, - }, - }); - }), - - graphql.query('TenantBranches', ({ variables }) => { - const { tenantId } = variables; - const tenant = mockTenants.find(t => t.tenantId === tenantId) || mockTenants[0]; - return HttpResponse.json({ - data: { - getTenantBranches: tenant.branches || [], - }, - }); - }), - - graphql.query('UserAccount', ({ variables }) => { - const { userAccountId } = variables; - const account = - mockUserAccounts.items.find(u => u.userAccountId === userAccountId) || - mockUserAccounts.items[0]; - return HttpResponse.json({ - data: { - getUserAccountById: account, - }, - }); - }), - - graphql.query('DelegationById', ({ variables }) => { - const { delegationId } = variables; - const delegation = - mockDelegations.find(d => d.delegationId === delegationId) || mockDelegations[0]; - return HttpResponse.json({ - data: { - getDelegationById: delegation, - }, - }); - }), - - graphql.query('IdentityProviders', ({ variables }) => { - const { tenantId } = variables; - const tenant = mockTenants.find(t => t.tenantId === tenantId) || mockTenants[0]; - return HttpResponse.json({ - data: { - getTenantIdentityProviders: tenant.identityProviders || [], - }, - }); - }), - - graphql.query('Branding', ({ variables }) => { - const { tenantId } = variables; - const tenant = mockTenants.find(t => t.tenantId === tenantId) || mockTenants[0]; - return HttpResponse.json({ - data: { - getTenantBranding: tenant.branding || null, - }, - }); - }), - - // ── Authorization / SystemSuite ──────────────────────────────────────────── - graphql.query('SystemSuites', ({ variables }) => { - const { page = 1, pageSize = 20, status = 'all' } = variables || {}; - let items = mockSystemSuites; - if (status && status !== 'all') { - items = items.filter(s => s.status === status); - } - const totalItems = items.length; - const totalPages = Math.ceil(totalItems / pageSize); - const pagedItems = items.slice((page - 1) * pageSize, page * pageSize); - return HttpResponse.json({ - data: { - getSystemSuites: { - items: pagedItems, - page, - pageSize, - totalItems, - totalPages, - }, - }, - }); - }), - - graphql.query('SystemSuite', ({ variables }) => { - const { systemSuiteId } = variables; - const suite = - mockSystemSuites.find(s => s.systemSuiteId === systemSuiteId) || mockSystemSuites[0]; - return HttpResponse.json({ - data: { - getSystemSuiteById: suite, - }, - }); - }), - - // Catch-all for unmocked GraphQL queries to prevent 500 errors in Dev mode - http.post('*/graphql', () => { - return HttpResponse.json({ - data: {}, - }); - }), ]; diff --git a/src/apps/ums.web-app/tailwind.config.js b/src/apps/ums.web-app/tailwind.config.js index 48ac0742..199030c3 100644 --- a/src/apps/ums.web-app/tailwind.config.js +++ b/src/apps/ums.web-app/tailwind.config.js @@ -5,18 +5,33 @@ export default { theme: { extend: { colors: { + // BEYONDNET corporate navy ramp (anchored on brand #0f3e67 / footer #042139) brand: { - 50: '#eef2ff', - 100: '#e0e7ff', - 200: '#c7d2fe', - 300: '#a5b4fc', - 400: '#818cf8', - 500: '#6366f1', - 600: '#4f46e5', - 700: '#4338ca', - 800: '#3730a3', - 900: '#312e81', - 950: '#1e1b4b', + 50: '#f0f5fa', + 100: '#dbe7f2', + 200: '#b8cfe4', + 300: '#8db0d1', + 400: '#5d8bb8', + 500: '#3a6a9c', + 600: '#1f5080', + 700: '#0f3e67', + 800: '#0c3253', + 900: '#0a2843', + 950: '#042139', + }, + // BEYONDNET green accent (#41a62a) as a standalone Tailwind color + accent: { + 50: '#eff8ec', + 100: '#d6eecd', + 200: '#b0dfa0', + 300: '#83cc6d', + 400: '#5cb844', + 500: '#41a62a', + 600: '#33851f', + 700: '#29661b', + 800: '#22511a', + 900: '#1d4318', + 950: '#0c2609', }, dark: { 50: '#f8fafc', diff --git a/src/apps/ums.web-app/tests/app-configuration-state.spec.ts b/src/apps/ums.web-app/tests/app-configuration-state.spec.ts new file mode 100644 index 00000000..cdc5cf33 --- /dev/null +++ b/src/apps/ums.web-app/tests/app-configuration-state.spec.ts @@ -0,0 +1,214 @@ +import { test, expect, type Page, type APIResponse } from '@playwright/test'; + +/** + * Configuraciones de Aplicación (AppConfiguration) · ciclo de vida — Matriz de Cobertura de UI, + * sección C (ver `reference/qa/ui-coverage-matrix.md`). + * + * Certifica el fix [G-143]: existe `DELETE /app-configurations/{id}` (agregado hoja, sin guarda de + * dependencias → 204). Pantalla `AppConfigurationDashboardScreen`. Ahora que [G-144] arregló el + * picker (`ParameterDefinitionPickerDialog` usa `parameterCatalogService.getAll(...)`), el alta por + * UI carga parámetros y el clic del usuario SÍ llega al backend. + * + * Casos: + * 1. HAPPY · crear por UI (certifica G-144 + G-143) · «Agregar» → picker de parámetros (ya poblado + * por G-144) → seleccionar una Definición Global&Tenant con valor por defecto y SIN + * AppConfiguration Global previa (`MFA_REQUIRED_FOR_ADMIN`) → «Add» → **POST 201** (la config + * Global se crea en el backend). Luego se localiza esa config Global por búsqueda y se elimina + * por UI → **DELETE 204**, y desaparece de la lista. + * 2. LIFECYCLE · Draft → Publicar → Archivar sobre una config fixture, certificando que los + * controles del detalle reflejan el estado REAL (badge `data-status`) sin flip optimista; se + * limpia con Eliminar al final (idempotente). + * + * ── ⚠ Bug #3 en el ALTA por UI (NO es G-143/144/145; ver reporte) ─────────────────── + * El clic «Add» dispara `POST /app-configurations` que responde **201** con `{ appConfigurationId }` + * (backend `CreateAppConfigurationResponse` sólo trae el id). Pero el servicio del frontend + * (`app-configuration.service.ts` · `createAppConfiguration`) valida la respuesta con + * `CreateAppConfigurationResponseSchema` (`app-configuration.schema.ts`), que exige además `code` + * (string) → el parseo LANZA (`invalid_type` en `code`). Consecuencia: la config SÍ se crea (201) + * pero la UI muestra un toast de ERROR y NO auto-selecciona ni notifica éxito. Por eso el caso 1 NO + * asevera toast de éxito ni auto-select tras «Add»; certifica el 201 y luego localiza+elimina la + * config por UI (que es el fix G-143 bajo prueba). + * + * Idempotencia: el caso 1 pre-limpia cualquier config Global residual con ese código (por si una + * corrida previa se interrumpió) y siempre elimina lo que crea; el caso 2 usa un código sintético + * único por corrida. Auth por cookie de sesión (JWT); `page.request` comparte cookies del contexto. + */ +const ADMIN = { email: 'admin@beyondnet.com.pe', password: 'BeyondNet.Dev.2026' }; +/** DEFAULT_TENANT_ID del frontend (request-context.ts); el backend deriva IsInternalAdmin del JWT. */ +const TENANT_ID = '5f4e3d2c-1b0a-9f8e-7d6c-543210987654'; + +/** + * Definición sembrada scope Global&Tenant, valor por defecto ("false") y SIN AppConfiguration Global + * sembrada (sólo por inquilino) → crear su config Global es determinista y no colisiona. + */ +const PICK_PARAM_NAME = 'MFA Required for Admin'; +const EXPECTED_CODE = 'MFA_REQUIRED_FOR_ADMIN'; + +const uniqueCode = () => `E2E_APPCFG_${Date.now()}`; + +const addBtn = (page: Page) => page.locator('button[title="Agregar"]').first(); +const pickerSearch = (page: Page) => page.locator('input[placeholder="Search parameters..."]'); +const listSearch = (page: Page) => page.locator('[data-testid="list-search"]'); +const searchSubmitBtn = (page: Page) => page.getByRole('button', { name: /^buscar$/i }); +const detailDeleteBtn = (page: Page) => page.locator('button[aria-label="Eliminar"]').first(); +const confirmDialog = (page: Page) => page.getByRole('dialog'); +const rowByText = (page: Page, text: string) => + page.locator('[data-testid="entity-row"]').filter({ hasText: text }); +const statusBadge = (page: Page, status: string) => + page.locator(`[data-testid="status-badge"][data-status="${status}"]`).first(); + +function apiHeaders(cookies: { name: string; value: string }[]): Record { + const headers: Record = { 'X-Tenant-Id': TENANT_ID }; + const xsrf = cookies.find(c => c.name === 'XSRF-TOKEN')?.value; + if (xsrf) headers['X-CSRF-Token'] = xsrf; + return headers; +} + +/** Crea una AppConfiguration Global (Draft) vía API como fixture. */ +async function createConfigViaApi(page: Page, code: string): Promise { + const headers = apiHeaders(await page.context().cookies()); + const resp: APIResponse = await page.request.post('/api/v1/app-configurations', { + headers, + data: { code, value: 'e2e', description: 'Fixture E2E G-143' }, + }); + expect(resp.status(), await resp.text()).toBe(201); +} + +/** Idempotencia defensiva: borra por API cualquier AppConfiguration Global con ese código (residuo). */ +async function purgeGlobalConfig(page: Page, code: string): Promise { + const headers = apiHeaders(await page.context().cookies()); + const resp = await page.request.get( + `/api/v1/app-configurations?page=1&pageSize=50&search=${encodeURIComponent(code)}`, + { headers } + ); + if (!resp.ok()) return; + const body = await resp.json(); + for (const c of body.items ?? []) { + if (c.code === code && c.scope === 'Global') { + await page.request.delete(`/api/v1/app-configurations/${c.appConfigurationId}`, { headers }); + } + } +} + +/** Busca por código y abre la configuración en el detalle. */ +async function selectByCode(page: Page, code: string): Promise { + await listSearch(page).fill(code); + await searchSubmitBtn(page).click(); + const row = rowByText(page, code).first(); + await expect(row).toBeVisible({ timeout: 15000 }); + await row.click(); + await expect(detailDeleteBtn(page)).toBeVisible({ timeout: 15000 }); // detalle cargado +} + +/** Elimina la configuración seleccionada por la UI y devuelve el status HTTP del DELETE. */ +async function deleteSelected(page: Page): Promise { + await detailDeleteBtn(page).click(); + await expect(confirmDialog(page)).toBeVisible({ timeout: 15000 }); + const delResp = page.waitForResponse( + r => r.url().includes('/app-configurations/') && r.request().method() === 'DELETE', + { timeout: 20000 } + ); + await confirmDialog(page) + .getByRole('button', { name: /^eliminar$/i }) + .click(); + const resp = await delResp; + return resp.status(); +} + +test.describe('Configuraciones de Aplicación · ciclo de vida', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(/correo electrónico/i).fill(ADMIN.email); + await page.getByLabel(/contraseña/i).fill(ADMIN.password); + await page.getByRole('button', { name: /ingresar/i }).click(); + await expect(page).toHaveURL(/\/tenants/); + + await page.goto('/app-configurations'); + await expect(page).toHaveURL(/\/app-configurations/); + }); + + test('HAPPY · crear por UI (picker) y eliminar (DELETE 204) una config Global (G-144 + G-143)', async ({ + page, + }) => { + await purgeGlobalConfig(page, EXPECTED_CODE); // idempotencia: limpiar residuo previo + + // ── Crear por UI: «Agregar» → picker de parámetros (poblado por G-144) ────── + await addBtn(page).click(); + await expect(pickerSearch(page)).toBeVisible({ timeout: 15000 }); + await pickerSearch(page).fill('MFA'); // acota la lista del picker (getAll con search) + + const paramRow = page.getByRole('button', { name: new RegExp(PICK_PARAM_NAME, 'i') }); + await expect(paramRow).toBeVisible({ timeout: 15000 }); + await paramRow.click(); + + const createResp = page.waitForResponse( + r => r.url().includes('/app-configurations') && r.request().method() === 'POST', + { timeout: 20000 } + ); + await page.getByRole('button', { name: /^add(\s*\(\d+\))?$/i }).click(); + // El clic del usuario crea la config Global en el backend (201) y, tras corregir [G-147] + // (el schema del alta exigía `code`, ausente en la respuesta → el parseo lanzaba y salía un + // toast de ERROR pese al 201), la UI ahora notifica ÉXITO sin falso error. + expect((await createResp).status()).toBe(201); + await expect( + page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + ).toBeVisible({ timeout: 15000 }); + + // ── Localizar la config Global creada y eliminarla por UI (certifica G-143) ─ + await listSearch(page).fill(EXPECTED_CODE); + await searchSubmitBtn(page).click(); + const globalRow = page + .locator('[data-testid="entity-row"]') + .filter({ hasText: EXPECTED_CODE }) + .filter({ hasText: 'Global' }) + .first(); + await expect(globalRow).toBeVisible({ timeout: 15000 }); + await globalRow.click(); + await expect(detailDeleteBtn(page)).toBeVisible({ timeout: 15000 }); + + const status = await deleteSelected(page); + expect(status).toBe(204); + + // Desaparece: la fila Global con ese código ya no existe (las de inquilino permanecen intactas). + await expect( + page + .locator('[data-testid="entity-row"]') + .filter({ hasText: EXPECTED_CODE }) + .filter({ hasText: 'Global' }) + ).toHaveCount(0, { timeout: 15000 }); + }); + + test('LIFECYCLE · Draft → Publicar → Archivar refleja el estado real; limpieza por Eliminar', async ({ + page, + }) => { + const code = uniqueCode(); + await createConfigViaApi(page, code); + await selectByCode(page, code); + + // Nace en Draft. + await expect(statusBadge(page, 'Draft')).toBeVisible({ timeout: 15000 }); + + // Draft → Publicar (el botón sólo aparece en Draft; el badge refleja el estado persistido). + const pubResp = page.waitForResponse( + r => r.url().includes('/publish') && r.request().method() === 'POST', + { timeout: 20000 } + ); + await page.getByRole('button', { name: /^publicar$/i }).click(); + expect((await pubResp).status()).toBe(204); + await expect(statusBadge(page, 'Published')).toBeVisible({ timeout: 15000 }); + + // Published → Archivar. + const arcResp = page.waitForResponse( + r => r.url().includes('/archive') && r.request().method() === 'POST', + { timeout: 20000 } + ); + await page.getByRole('button', { name: /^archivar$/i }).click(); + expect((await arcResp).status()).toBe(204); + await expect(statusBadge(page, 'Archived')).toBeVisible({ timeout: 15000 }); + + // Limpieza: eliminar la configuración archivada (G-143 no tiene guarda de estado). + const status = await deleteSelected(page); + expect(status).toBe(204); + await expect(rowByText(page, code)).toHaveCount(0, { timeout: 15000 }); + }); +}); diff --git a/src/apps/ums.web-app/tests/auth.spec.ts b/src/apps/ums.web-app/tests/auth.spec.ts index 674816b9..8b9579f9 100644 --- a/src/apps/ums.web-app/tests/auth.spec.ts +++ b/src/apps/ums.web-app/tests/auth.spec.ts @@ -32,8 +32,8 @@ test.describe('Authentication Flow', () => { }); test('should login successfully with valid credentials', async ({ page }) => { - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.getByRole('button', { name: /ingresar/i }).click(); await page.waitForURL(/\/tenants/); }); @@ -53,18 +53,26 @@ test.describe('Authentication Flow', () => { test('should redirect to originally requested page after login', async ({ page }) => { await page.goto('/users'); await page.waitForURL('**/login', { timeout: 10000 }); - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.getByRole('button', { name: /ingresar/i }).click(); await page.waitForURL('**/users', { timeout: 10000 }); }); test('should lock account after 5 failed attempts', async ({ page }) => { + // Se espera la RESPUESTA de cada intento, no 100 ms de reloj. La espera fija era una carrera: + // el bloqueo exige que el servidor haya CONTADO los cinco intentos, y con el backend frio o + // cargado una respuesta puede tardar mas de 100 ms — entonces el siguiente clic sale antes de + // que el anterior se registre, se cuentan menos de cinco y el mensaje de bloqueo no aparece. + // Falla por lentitud, no por defecto, que es la definicion de prueba que parpadea. for (let i = 0; i < 5; i++) { await page.getByLabel(/correo electrónico/i).fill('invalid_user'); await page.getByLabel(/contraseña/i).fill('wrong_password'); + const intentoRegistrado = page.waitForResponse( + (r) => r.url().includes('/api/v1/auth/login') && r.request().method() === 'POST', + ); await page.getByRole('button', { name: /ingresar/i }).click(); - await page.waitForTimeout(100); + await intentoRegistrado; } await expect(page.getByText(/demasiados intentos/i)).toBeVisible(); await expect(page.getByRole('button', { name: /bloqueado/i })).toBeVisible(); @@ -74,8 +82,8 @@ test.describe('Authentication Flow', () => { test.describe('Logout Flow', () => { test('should logout and redirect to login', async ({ page }) => { await page.goto('/login'); - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.getByRole('button', { name: /ingresar/i }).click(); await expect(page).toHaveURL(/\/tenants/); @@ -90,8 +98,8 @@ test.describe('Logout Flow', () => { test('should clear session after logout', async ({ page }) => { await page.goto('/login'); - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.getByRole('button', { name: /ingresar/i }).click(); await expect(page).toHaveURL(/\/tenants/); diff --git a/src/apps/ums.web-app/tests/authorization-ui.spec.ts b/src/apps/ums.web-app/tests/authorization-ui.spec.ts index f1dfa1eb..f0e4e85f 100644 --- a/src/apps/ums.web-app/tests/authorization-ui.spec.ts +++ b/src/apps/ums.web-app/tests/authorization-ui.spec.ts @@ -3,28 +3,32 @@ import { test, expect } from '@playwright/test'; test.describe('Dynamic Authorization UI Tests', () => { test('Admin user should have Agregar button enabled', async ({ page }) => { await page.goto('/login'); - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + // Admin Root de BEYONDNET (DEV_PERSONAS); el selector ya arranca en BEYONDNET. + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.click('button[type="submit"]'); // Wait for tenants await page.waitForURL('**/tenants', { timeout: 10000 }); - // Check if the add button is visible and enabled - const addButton = page.locator('button[title="Agregar"]'); + // La barra de listado renderiza más de un botón "Agregar" (toolbar + vacío): + // basta con verificar el primero. + const addButton = page.locator('button[title="Agregar"]').first(); await expect(addButton).toBeVisible(); await expect(addButton).not.toBeDisabled(); }); - test('Tenant Supervisor should see Access Denied on Tenants page', async ({ page }) => { + test('Client user should see Access Denied on Tenants page', async ({ page }) => { await page.goto('/login'); - await page - .getByText(/INTERNAL_ADMIN/i) - .first() - .click(); - await page.getByText(/Ransa Comercial S.A./i).click(); - await page.getByLabel(/correo electrónico/i).fill('gerente.operaciones@ransa.pe'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + + // El TenantSelect es un dropdown propio (trigger + buscador), no un : [0] Tipo, [1] Suite del Sistema. index 1 del suite + // salta el placeholder «— Seleccionar —» y toma la primera suite real. + await page.locator('#feature-flag-form').getByRole('combobox').nth(1).selectOption({ index: 1 }); + await page.getByRole('button', { name: /^crear$/i }).click(); + + // (1) Éxito confirmado por el servidor. + await expect(toast(page, 'success')).toBeVisible({ timeout: 15000 }); + // (2) El drawer se cierra (su scrim no debe tapar el panel de detalle). + await expect(page.getByText(/nuevo feature flag/i)).toBeHidden({ timeout: 10000 }); + // (3) El flag recién creado queda auto-seleccionado (dashboard.onSuccess → setSelectedId) → + // su panel aparece en estado Inactive con «Activar». + await expect(activarBtn(page)).toBeVisible({ timeout: 15000 }); + + return code; +} + +test.describe('Feature Flag · estado y criterios (happy + error)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(/correo electrónico/i).fill(ADMIN.email); + await page.getByLabel(/contraseña/i).fill(ADMIN.password); + await page.getByRole('button', { name: /ingresar/i }).click(); + await expect(page).toHaveURL(/\/tenants/); + + // Navegar a la pantalla de Feature Flags (riel → «Feature Flags»). + await page.goto('/feature-flags'); + await expect(page.locator('button[title="Agregar"]').first()).toBeVisible({ timeout: 15000 }); + }); + + test('HAPPY · crear un flag lo deja Inactive y visible en la lista (list-search + status-badge)', async ({ + page, + }) => { + const code = await createFlag(page); + + // El detalle refleja el estado real persistido (no un placeholder): Inactive + acciones Draft. + await expect(page.getByText(/estado:\s*inactive/i)).toBeVisible(); + await expect(archivarBtn(page)).toBeVisible(); + + // Verificación cruzada por la LISTA con los testids compartidos: buscar el código y asertar + // que la fila muestra el badge de estado real «Inactive». + await page.locator('[data-testid="list-search"]').fill(code); + // La búsqueda de la lista es del lado servidor: se dispara con el botón «Buscar», no con Enter. + await page.getByRole('button', { name: /^buscar$/i }).last().click(); + + const row = page.locator('[data-testid="entity-row"]').filter({ hasText: code }); + await expect(row).toBeVisible({ timeout: 15000 }); + await expect( + row.locator('[data-testid="status-badge"][data-status="Inactive"]').first() + ).toBeVisible(); + }); + + test('HAPPY · Activar y luego Desactivar refleja el estado real (Inactive→Active→Inactive)', async ({ + page, + }) => { + await createFlag(page); + + // Inactive → Active + await activarBtn(page).click(); + await expect(toast(page, 'success')).toBeVisible({ timeout: 15000 }); + // El detalle se re-pide al servidor tras el onSuccess → estado REAL Active, botón «Desactivar». + await expect(page.getByText(/estado:\s*active/i)).toBeVisible({ timeout: 15000 }); + await expect(desactivarBtn(page)).toBeVisible(); + await expect(activarBtn(page)).toHaveCount(0); + + // Active → Inactive + await desactivarBtn(page).click(); + // Desactivar emite un toast de tipo `info` (useDeactivateFlag). + await expect(toast(page, 'info')).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/estado:\s*inactive/i)).toBeVisible({ timeout: 15000 }); + await expect(activarBtn(page)).toBeVisible(); + await expect(desactivarBtn(page)).toHaveCount(0); + }); + + test('HAPPY · Archivar un flag Inactive lo lleva al estado terminal (flag peligroso)', async ({ + page, + }) => { + await createFlag(page); + + await archivarBtn(page).click(); + // Archivar emite un toast de tipo `warning` (useArchiveFlag). + await expect(toast(page, 'warning')).toBeVisible({ timeout: 15000 }); + + // Estado terminal: «Archived», leyenda de acción terminal y NINGÚN botón de transición. + await expect(page.getByText(/estado:\s*archived/i)).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/archivado\s*—\s*acción terminal/i)).toBeVisible(); + await expect(activarBtn(page)).toHaveCount(0); + await expect(desactivarBtn(page)).toHaveCount(0); + await expect(archivarBtn(page)).toHaveCount(0); + }); + + test('HAPPY · añadir y quitar un criterio en un flag Draft', async ({ page }) => { + await createFlag(page); + + // Estado inicial: sin criterios. + await expect(page.getByText(/criterios de evaluación\s*\(0\)/i)).toBeVisible(); + + // Añadir un criterio (tipo/operador por defecto: TenantId / Equals). + const criteriaValue = `crit-${Date.now()}`; + await page.getByLabel(/^valor$/i).fill(criteriaValue); + // El único

- Ingrese su tenant y correo electrónico. Si existe una cuenta asociada, recibirá un código - de un solo uso para definir una contraseña nueva. Su contraseña actual seguirá siendo - válida hasta que lo use. + Ingrese su tenant y correo electrónico. Si existe una cuenta asociada, recibirá un código de + un solo uso para definir una contraseña nueva. Su contraseña actual seguirá siendo válida + hasta que lo use.

= ({ {isExpanded && ( <> - {/* Search group */} - {searchOptions && onSearchCriteriaChange && activeSearchCriteria !== undefined && ( -
-
- - -
- onSearchValueChange?.(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && onSearchSubmit) { - e.preventDefault(); - onSearchSubmit(); - } - if (e.key === 'Escape' && onSearchClear) { - onSearchClear(); - } - }} - placeholder="Buscar..." - className="h-6 flex-1 min-w-[4rem] bg-transparent text-[12px] text-m3-on-surface + {/* Search group */} + {searchOptions && onSearchCriteriaChange && activeSearchCriteria !== undefined && ( +
+
+ + +
+ onSearchValueChange?.(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && onSearchSubmit) { + e.preventDefault(); + onSearchSubmit(); + } + if (e.key === 'Escape' && onSearchClear) { + onSearchClear(); + } + }} + placeholder="Buscar..." + className="h-6 flex-1 min-w-[4rem] bg-transparent text-[12px] text-m3-on-surface placeholder:text-m3-secondary/40 focus:outline-none" + /> + {searchValue && onSearchClear && ( + + )} +
+ +
+ )} + + {/* Filter group */} + {filterOptions && onFilterChange && activeFilter !== undefined && ( +
+ onFilterChange(e.target.value)} + options={filterOptions} + /> +
+ )} + + {/* Sort group */} + {sortOptions && onSortByChange && sortBy !== undefined && ( +
+ onSortByChange(e.target.value)} + options={sortOptions} /> - {searchValue && onSearchClear && ( + {onSortOrderToggle && ( )}
- -
- )} - - {/* Filter group */} - {filterOptions && onFilterChange && activeFilter !== undefined && ( -
- onFilterChange(e.target.value)} - options={filterOptions} - /> -
- )} - - {/* Sort group */} - {sortOptions && onSortByChange && sortBy !== undefined && ( -
- onSortByChange(e.target.value)} - options={sortOptions} - /> - {onSortOrderToggle && ( - - )} -
- )} - + )} )} diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.tsx index 3dea1d13..e12a16ee 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/ProfileSelector.tsx @@ -41,7 +41,9 @@ export const ProfileSelector: React.FC<{ className?: string }> = ({ className }) if (perfiles.length < 2) return null; const rotulo = (p: GraphProfileOption) => - p.branch ? `${p.role.value} · ${p.system.code} · ${p.branch.value}` : `${p.role.value} · ${p.system.code}`; + p.branch + ? `${p.role.value} · ${p.system.code} · ${p.branch.value}` + : `${p.role.value} · ${p.system.code}`; const cambiar = async (p: GraphProfileOption) => { if (p.isCurrent || cambiando || !p.id) return; diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx index 8a94a0ef..cf87e735 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx @@ -93,7 +93,11 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } ) : ( <> - BEYONDNET + BEYONDNET BEYONDNET { // toast de ERROR pese al 201), la UI ahora notifica ÉXITO sin falso error. expect((await createResp).status()).toBe(201); await expect( - page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + page.locator('[data-testid="toast"][data-toast-type="success"]').first() ).toBeVisible({ timeout: 15000 }); // ── Localizar la config Global creada y eliminarla por UI (certifica G-143) ─ diff --git a/src/apps/ums.web-app/tests/auth.spec.ts b/src/apps/ums.web-app/tests/auth.spec.ts index 8b9579f9..5aa36190 100644 --- a/src/apps/ums.web-app/tests/auth.spec.ts +++ b/src/apps/ums.web-app/tests/auth.spec.ts @@ -69,7 +69,7 @@ test.describe('Authentication Flow', () => { await page.getByLabel(/correo electrónico/i).fill('invalid_user'); await page.getByLabel(/contraseña/i).fill('wrong_password'); const intentoRegistrado = page.waitForResponse( - (r) => r.url().includes('/api/v1/auth/login') && r.request().method() === 'POST', + r => r.url().includes('/api/v1/auth/login') && r.request().method() === 'POST' ); await page.getByRole('button', { name: /ingresar/i }).click(); await intentoRegistrado; diff --git a/src/apps/ums.web-app/tests/delegation-approval-gate.spec.ts b/src/apps/ums.web-app/tests/delegation-approval-gate.spec.ts index f6ca3b18..449f771f 100644 --- a/src/apps/ums.web-app/tests/delegation-approval-gate.spec.ts +++ b/src/apps/ums.web-app/tests/delegation-approval-gate.spec.ts @@ -44,7 +44,10 @@ async function searchById(page: Page, delegationId: string): Promise { await expect(search).toBeVisible({ timeout: 15000 }); await search.fill(''); await search.fill(delegationId); - await page.getByRole('button', { name: /^buscar$/i }).last().click(); + await page + .getByRole('button', { name: /^buscar$/i }) + .last() + .click(); } /** @@ -96,7 +99,10 @@ test.describe('Delegaciones · GATE de aprobación (self-approval rechazada)', ( ); await createDialog.getByRole('button', { name: /^crear/i }).click(); const createResp = await createRespP; - expect(createResp.ok(), `POST /delegations debe crear (2xx); status=${createResp.status()}`).toBeTruthy(); + expect( + createResp.ok(), + `POST /delegations debe crear (2xx); status=${createResp.status()}` + ).toBeTruthy(); const { delegationId } = (await createResp.json()) as { delegationId: string }; expect(delegationId, 'la respuesta de creación debe traer delegationId').toBeTruthy(); @@ -119,7 +125,8 @@ test.describe('Delegaciones · GATE de aprobación (self-approval rechazada)', ( // creación —también `success`— puede seguir visible y un `page.reload()` prematuro abortaría // el POST en vuelo dejando la delegación en Draft). El status HTTP es la fuente de verdad. const submitRespP = page.waitForResponse( - r => r.request().method() === 'POST' && /\/submit-for-approval$/.test(new URL(r.url()).pathname), + r => + r.request().method() === 'POST' && /\/submit-for-approval$/.test(new URL(r.url()).pathname), { timeout: 20000 } ); await submitBtn.click(); @@ -166,9 +173,7 @@ test.describe('Delegaciones · GATE de aprobación (self-approval rechazada)', ( ).toBeVisible(); await expect(page.getByRole('button', { name: /^aprobar$/i })).toBeVisible(); // Nunca debe haber flipado a Activa ni ofrecido «Revoke» (control exclusivo de Active). - await expect( - page.locator('[data-testid="status-badge"][data-status="Active"]') - ).toHaveCount(0); + await expect(page.locator('[data-testid="status-badge"][data-status="Active"]')).toHaveCount(0); await expect(page.getByRole('button', { name: /revoke/i })).toHaveCount(0); // (c) CERTIFICADO DEFINITIVO: el ESTADO PERSISTIDO en el servidor NO cambió a Active. @@ -179,9 +184,7 @@ test.describe('Delegaciones · GATE de aprobación (self-approval rechazada)', ( await expect( page.locator('[data-testid="status-badge"][data-status="PendingApproval"]').first() ).toBeVisible({ timeout: 15000 }); - await expect( - page.locator('[data-testid="status-badge"][data-status="Active"]') - ).toHaveCount(0); + await expect(page.locator('[data-testid="status-badge"][data-status="Active"]')).toHaveCount(0); await expect(page.getByRole('button', { name: /^aprobar$/i })).toBeVisible(); // ── (5) LIMPIEZA best-effort: rechazar la delegación (control terminal en PendingApproval). diff --git a/src/apps/ums.web-app/tests/feature-flag-state.spec.ts b/src/apps/ums.web-app/tests/feature-flag-state.spec.ts index 2adb309e..427f1d2d 100644 --- a/src/apps/ums.web-app/tests/feature-flag-state.spec.ts +++ b/src/apps/ums.web-app/tests/feature-flag-state.spec.ts @@ -92,7 +92,10 @@ test.describe('Feature Flag · estado y criterios (happy + error)', () => { // que la fila muestra el badge de estado real «Inactive». await page.locator('[data-testid="list-search"]').fill(code); // La búsqueda de la lista es del lado servidor: se dispara con el botón «Buscar», no con Enter. - await page.getByRole('button', { name: /^buscar$/i }).last().click(); + await page + .getByRole('button', { name: /^buscar$/i }) + .last() + .click(); const row = page.locator('[data-testid="entity-row"]').filter({ hasText: code }); await expect(row).toBeVisible({ timeout: 15000 }); @@ -153,14 +156,18 @@ test.describe('Feature Flag · estado y criterios (happy + error)', () => { await page.locator('form').getByRole('button').click(); await expect(toast(page, 'success')).toBeVisible({ timeout: 15000 }); - await expect(page.getByText(/criterios de evaluación\s*\(1\)/i)).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/criterios de evaluación\s*\(1\)/i)).toBeVisible({ + timeout: 15000, + }); // Quitar el criterio recién agregado. // NOTA (gap de testid): el botón de eliminar criterio (IconButton) no expone accessible-name // ni data-testid; se localiza por su clase de color (rose-500). Ver reporte. await page.locator('button[class*="text-rose-500"]').first().click(); await expect(toast(page, 'warning')).toBeVisible({ timeout: 15000 }); - await expect(page.getByText(/criterios de evaluación\s*\(0\)/i)).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/criterios de evaluación\s*\(0\)/i)).toBeVisible({ + timeout: 15000, + }); }); test('ERROR · añadir un criterio DUPLICADO falla y el conteo NO cambia (G-129)', async ({ @@ -174,7 +181,9 @@ test.describe('Feature Flag · estado y criterios (happy + error)', () => { await page.getByLabel(/^valor$/i).fill(criteriaValue); await page.locator('form').getByRole('button').click(); await expect(toast(page, 'success')).toBeVisible({ timeout: 15000 }); - await expect(page.getByText(/criterios de evaluación\s*\(1\)/i)).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/criterios de evaluación\s*\(1\)/i)).toBeVisible({ + timeout: 15000, + }); // Segundo alta con MISMO tipo/operador/valor → el dominio rechaza (DuplicateCriteria). await page.getByLabel(/^valor$/i).fill(criteriaValue); diff --git a/src/apps/ums.web-app/tests/helpers/limpieza.ts b/src/apps/ums.web-app/tests/helpers/limpieza.ts index 1a91dafa..5c1c9f83 100644 --- a/src/apps/ums.web-app/tests/helpers/limpieza.ts +++ b/src/apps/ums.web-app/tests/helpers/limpieza.ts @@ -38,10 +38,14 @@ export async function borrarResiduo(recurso: string, patron: RegExp): Promise> = (await r.json()).items ?? []; for (const it of items) { - const texto = ['code', 'name', 'email', 'displayName'].map((k) => String(it[k] ?? '')).join(' '); + const texto = ['code', 'name', 'email', 'displayName'] + .map(k => String(it[k] ?? '')) + .join(' '); if (!patron.test(texto)) continue; - const id = it[`${recurso.replace(/s$/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())}Id`] - ?? it.id ?? it.userAccountId; + const id = + it[`${recurso.replace(/s$/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())}Id`] ?? + it.id ?? + it.userAccountId; if (!id) continue; const d = await ctx.delete(`/api/v1/${recurso}/${id}`); if (d.ok()) borrados += 1; diff --git a/src/apps/ums.web-app/tests/identity-provider.spec.ts b/src/apps/ums.web-app/tests/identity-provider.spec.ts index cfb833ab..f4c63bc1 100644 --- a/src/apps/ums.web-app/tests/identity-provider.spec.ts +++ b/src/apps/ums.web-app/tests/identity-provider.spec.ts @@ -92,7 +92,9 @@ async function registerIdpViaApi(page: Page): Promise { async function removeIdpViaApi(page: Page, idpId: string): Promise { if (!idpId) return; const headers = apiHeaders(await page.context().cookies()); - await page.request.delete(`/api/v1/tenants/${TENANT_ID}/identity-providers/${idpId}`, { headers }); + await page.request.delete(`/api/v1/tenants/${TENANT_ID}/identity-providers/${idpId}`, { + headers, + }); } /** Selecciona la organización propia (tenant raíz) y abre su pestaña de proveedores de identidad. */ @@ -133,7 +135,9 @@ test.describe('Proveedores de Identidad · listado, guarda de modo y baja por in await expect(row).toContainText(IDP_CODE); // ── (2) Guarda de modo Local: el IdP nace inactivo y su activación está deshabilitada ─ - const isLocalMode = !(await authModeSwitch(page).isChecked().catch(() => false)); + const isLocalMode = !(await authModeSwitch(page) + .isChecked() + .catch(() => false)); if (isLocalMode) { // Anti-G-129: sin flip optimista — en modo Local el botón de activar está deshabilitado. await expect(row.locator('button[disabled]')).toHaveCount(1, { timeout: 15000 }); diff --git a/src/apps/ums.web-app/tests/mvp-creation-chain.spec.ts b/src/apps/ums.web-app/tests/mvp-creation-chain.spec.ts index c0cd9616..eca7279b 100644 --- a/src/apps/ums.web-app/tests/mvp-creation-chain.spec.ts +++ b/src/apps/ums.web-app/tests/mvp-creation-chain.spec.ts @@ -62,9 +62,7 @@ async function createAndSelectSuite(page: Page, code: string, name: string): Pro await page.getByPlaceholder('CRM System Suite').fill(name); // La descripción es OBLIGATORIA en el backend (`CreateSystemSuiteCommandValidator` // exige `Description.NotEmpty()`) aunque el formulario la trate como opcional. - await page - .getByPlaceholder('Customer relationship management module') - .fill(`Suite E2E ${name}`); + await page.getByPlaceholder('Customer relationship management module').fill(`Suite E2E ${name}`); await page.getByRole('button', { name: /registrar suite|register system suite/i }).click(); // Confirmación real de la mutación: toast de éxito (no la lista paginada). @@ -158,10 +156,7 @@ test.describe('Cadena profunda de creación MVP', () => { // ── 5) Nodo hijo = Opción (bajo el Menú) ────────────────────────────────── // La fila del nodo Menú se identifica por su CodeBadge; el botón «Agregar nodo // hijo» es el primer IconButton de la fila (icono lucide-plus). - const menuRow = page - .locator('div[class~="group/node"]') - .filter({ hasText: menuCode }) - .first(); + const menuRow = page.locator('div[class~="group/node"]').filter({ hasText: menuCode }).first(); await menuRow.hover(); await menuRow.locator('button:has(svg.lucide-plus)').first().click(); const optForm = page diff --git a/src/apps/ums.web-app/tests/mvp-flows.spec.ts b/src/apps/ums.web-app/tests/mvp-flows.spec.ts index fe62f0c1..0a31fbdc 100644 --- a/src/apps/ums.web-app/tests/mvp-flows.spec.ts +++ b/src/apps/ums.web-app/tests/mvp-flows.spec.ts @@ -67,7 +67,9 @@ test.describe('Caminos MVP', () => { await expect(add).not.toBeDisabled(); }); - test('US-009 · los perfiles de autorización cargan para un admin autorizado', async ({ page }) => { + test('US-009 · los perfiles de autorización cargan para un admin autorizado', async ({ + page, + }) => { await rail(page) .getByRole('button', { name: /(perfiles de autorización|authorization profiles)/i }) .first() diff --git a/src/apps/ums.web-app/tests/navigation.spec.ts b/src/apps/ums.web-app/tests/navigation.spec.ts index abd60d39..12bb4a3d 100644 --- a/src/apps/ums.web-app/tests/navigation.spec.ts +++ b/src/apps/ums.web-app/tests/navigation.spec.ts @@ -124,7 +124,8 @@ test.describe('Navigation', () => { const menuButton = page.locator('button[aria-label="Toggle navigation"]'); const riel = page.getByTestId('nav-rail'); - const anchoDe = async () => ((await riel.getAttribute('class')) ?? '').includes('w-20') ? 'w-20' : 'w-64'; + const anchoDe = async () => + ((await riel.getAttribute('class')) ?? '').includes('w-20') ? 'w-20' : 'w-64'; const inicial = await anchoDe(); const contrario = inicial === 'w-20' ? 'w-64' : 'w-20'; diff --git a/src/apps/ums.web-app/tests/parameter-catalog-state.spec.ts b/src/apps/ums.web-app/tests/parameter-catalog-state.spec.ts index b7a466df..0a235778 100644 --- a/src/apps/ums.web-app/tests/parameter-catalog-state.spec.ts +++ b/src/apps/ums.web-app/tests/parameter-catalog-state.spec.ts @@ -111,7 +111,9 @@ test.describe('Parametrización · CRUD de definiciones', () => { await expect(page).toHaveURL(/\/parameter-catalog/); }); - test('HAPPY · crear cierra el diálogo y auto-selecciona la definición (G-145)', async ({ page }) => { + test('HAPPY · crear cierra el diálogo y auto-selecciona la definición (G-145)', async ({ + page, + }) => { const code = uniqueCode(); // createDefinition ya certifica: 201 + diálogo cerrado + auto-selección en el detalle. await createDefinition(page, { diff --git a/src/apps/ums.web-app/tests/permission-template-state.spec.ts b/src/apps/ums.web-app/tests/permission-template-state.spec.ts index 20c4a377..95c4c292 100644 --- a/src/apps/ums.web-app/tests/permission-template-state.spec.ts +++ b/src/apps/ums.web-app/tests/permission-template-state.spec.ts @@ -37,7 +37,8 @@ const successToast = (page: Page) => const errorToast = (page: Page) => page.locator('[data-testid="toast"][data-toast-type="error"]').first(); -const draftBadge = (page: Page) => page.locator('[data-testid="status-badge"][data-status="Draft"]'); +const draftBadge = (page: Page) => + page.locator('[data-testid="status-badge"][data-status="Draft"]'); const publishedBadge = (page: Page) => page.locator('[data-testid="status-badge"][data-status="Published"]'); diff --git a/src/apps/ums.web-app/tests/profile-state.spec.ts b/src/apps/ums.web-app/tests/profile-state.spec.ts index 0278f6e5..698e1663 100644 --- a/src/apps/ums.web-app/tests/profile-state.spec.ts +++ b/src/apps/ums.web-app/tests/profile-state.spec.ts @@ -193,7 +193,10 @@ test.describe('Profile · Perfiles de Autorización (happy + error) — G-130', // Expandir la tarjeta (vista thumbnail) revela las acciones; Rechazar dispara la mutación real. await card.click(); - await page.getByRole('button', { name: /rechazar/i }).first().click(); + await page + .getByRole('button', { name: /rechazar/i }) + .first() + .click(); // Confirmación real (useRejectProfileRequest → «Solicitud rechazada»), nunca un error. await expect(page.getByText(/solicitud rechazada/i).first()).toBeVisible({ timeout: 15000 }); @@ -221,7 +224,9 @@ test.describe('Profile · Perfiles de Autorización (happy + error) — G-130', // // Ahora lee cuál de los tres está activo y reafirma ESE. La propiedad que verifica es la misma y // deja de depender de con qué se sembró la base esta mañana. - test('HAPPY · reafirmar el efecto vigente de un permiso siempre tiene éxito', async ({ page }) => { + test('HAPPY · reafirmar el efecto vigente de un permiso siempre tiene éxito', async ({ + page, + }) => { // Se BUSCA un perfil con permisos materializados en vez de confiar en el primero de la lista. // // El primero no sirve: depende de qué sembró la base y de cómo ordena el listado. En la corrida @@ -262,7 +267,9 @@ test.describe('Profile · Perfiles de Autorización (happy + error) — G-130', let vigente: (typeof efectos)[number] | null = null; for (const efecto of efectos) { - const clases = (await page.getByRole('button', { name: efecto.nombre }).first().getAttribute('class')) ?? ''; + const clases = + (await page.getByRole('button', { name: efecto.nombre }).first().getAttribute('class')) ?? + ''; if (efecto.activo.test(clases)) { vigente = efecto; break; diff --git a/src/apps/ums.web-app/tests/role-state.spec.ts b/src/apps/ums.web-app/tests/role-state.spec.ts index f1a748e4..06c179ea 100644 --- a/src/apps/ums.web-app/tests/role-state.spec.ts +++ b/src/apps/ums.web-app/tests/role-state.spec.ts @@ -40,7 +40,7 @@ async function createRole( page: Page, code: string, value: string, - parentValue?: string, + parentValue?: string ): Promise { await page.locator('button[title="Nuevo rol"]').click(); await page.getByLabel(/código/i).fill(code); @@ -50,7 +50,7 @@ async function createRole( } await page.getByRole('button', { name: /guardar rol/i }).click(); await expect( - page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + page.locator('[data-testid="toast"][data-toast-type="success"]').first() ).toBeVisible({ timeout: 15000 }); await expect(roleRow(page, value)).toBeVisible({ timeout: 15000 }); } @@ -93,12 +93,14 @@ test.describe('Rol · CRUD y toggle de estado (happy + error)', () => { // Editar = primer botón de la fila (Pencil). Al entrar en edición, RoleRow // reemplaza la fila por un formulario con «Nombre visible» + «Guardar rol». - await roleRow(page, value).getByRole('button', { name: /editar/i }).click(); + await roleRow(page, value) + .getByRole('button', { name: /editar/i }) + .click(); await page.getByLabel(/nombre visible/i).fill(nuevoValor); await page.getByRole('button', { name: /guardar rol/i }).click(); await expect( - page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + page.locator('[data-testid="toast"][data-toast-type="success"]').first() ).toBeVisible({ timeout: 15000 }); await expect(roleRow(page, nuevoValor)).toBeVisible({ timeout: 15000 }); }); @@ -118,7 +120,7 @@ test.describe('Rol · CRUD y toggle de estado (happy + error)', () => { await fila.getByRole('button', { name: /desactivar/i }).click(); await expect( - page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + page.locator('[data-testid="toast"][data-toast-type="success"]').first() ).toBeVisible({ timeout: 15000 }); // El estado pasa de verdad a Inactivo (no flip optimista: el patch refleja el real). await expect(roleRow(page, value).getByText(/^inactivo$/i)).toBeVisible({ timeout: 15000 }); @@ -143,7 +145,7 @@ test.describe('Rol · CRUD y toggle de estado (happy + error)', () => { // (1) Sale un toast de ERROR (no un aviso de éxito prematuro). await expect( - page.locator('[data-testid="toast"][data-toast-type="error"]').first(), + page.locator('[data-testid="toast"][data-toast-type="error"]').first() ).toBeVisible({ timeout: 15000 }); // (2) El control NO flipó: el padre sigue «Activo» y NUNCA muestra «Inactivo» diff --git a/src/apps/ums.web-app/tests/system-suite-actions.spec.ts b/src/apps/ums.web-app/tests/system-suite-actions.spec.ts index 1b13bdaa..4ff24891 100644 --- a/src/apps/ums.web-app/tests/system-suite-actions.spec.ts +++ b/src/apps/ums.web-app/tests/system-suite-actions.spec.ts @@ -152,7 +152,10 @@ test.describe('Acciones del Sistema · catálogo (happy + error)', () => { await expect(actionCard(page, code)).toHaveCount(1); // Cerrar el formulario abierto y limpiar. - await page.getByRole('button', { name: /cancelar/i }).first().click(); + await page + .getByRole('button', { name: /cancelar/i }) + .first() + .click(); await removeAction(page, code); }); }); diff --git a/src/apps/ums.web-app/tests/system-suite-modules.spec.ts b/src/apps/ums.web-app/tests/system-suite-modules.spec.ts index 16473f28..34094f0a 100644 --- a/src/apps/ums.web-app/tests/system-suite-modules.spec.ts +++ b/src/apps/ums.web-app/tests/system-suite-modules.spec.ts @@ -142,7 +142,7 @@ test.describe('Módulos · CRUD anidado + árbol de nodos (happy)', () => { await expect(moduleStatus(page, name)).toHaveAttribute('data-status', 'Inactive'); // Al estar inactivo, la cabecera ofrece «Activar» (no «Desactivar»). await expect( - moduleHeader(page, name).getByRole('button', { name: 'Activar', exact: true }), + moduleHeader(page, name).getByRole('button', { name: 'Activar', exact: true }) ).toBeVisible(); await removeModule(page, name); diff --git a/src/apps/ums.web-app/tests/system-suite-state.spec.ts b/src/apps/ums.web-app/tests/system-suite-state.spec.ts index 8a3ee4de..4aaa2473 100644 --- a/src/apps/ums.web-app/tests/system-suite-state.spec.ts +++ b/src/apps/ums.web-app/tests/system-suite-state.spec.ts @@ -60,7 +60,11 @@ test.describe('Sistemas · ciclo de estado (happy path)', () => { test('HAPPY · Active → Mantenimiento → Active refleja el estado REAL', async ({ page }) => { // ── Normalización: garantizar punto de partida Active ────────────────────── - if (await activateBtn(page).isVisible().catch(() => false)) { + if ( + await activateBtn(page) + .isVisible() + .catch(() => false) + ) { await activateBtn(page).click(); await expect(successToast(page)).toBeVisible({ timeout: 15000 }); } diff --git a/src/apps/ums.web-app/tests/tenant-state-toggle.spec.ts b/src/apps/ums.web-app/tests/tenant-state-toggle.spec.ts index 5b2fedac..e6a47591 100644 --- a/src/apps/ums.web-app/tests/tenant-state-toggle.spec.ts +++ b/src/apps/ums.web-app/tests/tenant-state-toggle.spec.ts @@ -35,7 +35,7 @@ test.describe('Tenant · toggle de estado (happy + error)', () => { // (1) Sale un toast de ERROR (no un aviso de éxito prematuro). await expect( - page.locator('[data-testid="toast"][data-toast-type="error"]').first(), + page.locator('[data-testid="toast"][data-toast-type="error"]').first() ).toBeVisible({ timeout: 15000 }); // (2) El botón NO flipó: sigue «Suspender» y NUNCA aparece «Activar» (el corazón de G-129). @@ -55,7 +55,7 @@ test.describe('Tenant · toggle de estado (happy + error)', () => { await page.getByPlaceholder('Transportes Lima S.A.C.').fill(`E2E Toggle ${code}`); await page.getByRole('button', { name: /registrar tenant/i }).click(); await expect( - page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + page.locator('[data-testid="toast"][data-toast-type="success"]').first() ).toBeVisible({ timeout: 15000 }); // Esperar a que el drawer (y su scrim) se cierre para que no tape el panel de detalle. @@ -73,7 +73,10 @@ test.describe('Tenant · toggle de estado (happy + error)', () => { const search = page.locator('[data-testid="list-search"]').last(); await expect(search).toBeVisible({ timeout: 15000 }); await search.fill(code); - await page.getByRole('button', { name: /^buscar$/i }).last().click(); + await page + .getByRole('button', { name: /^buscar$/i }) + .last() + .click(); const row = page.locator('[data-testid="entity-row"]').filter({ hasText: code }).first(); await expect(row).toBeVisible({ timeout: 15000 }); @@ -90,7 +93,7 @@ test.describe('Tenant · toggle de estado (happy + error)', () => { // sobre knownTenants refleja el estado REAL, no un flip optimista de G-129) y el botón del // panel cambia a «Activar». await expect( - page.locator('[data-testid="status-badge"][data-status="Suspended"]').first(), + page.locator('[data-testid="status-badge"][data-status="Suspended"]').first() ).toBeVisible({ timeout: 15000 }); await expect(page.getByRole('button', { name: /^activar$/i })).toBeVisible(); }); diff --git a/src/apps/ums.web-app/tests/user-account-state.spec.ts b/src/apps/ums.web-app/tests/user-account-state.spec.ts index e1a99b6f..fe488b01 100644 --- a/src/apps/ums.web-app/tests/user-account-state.spec.ts +++ b/src/apps/ums.web-app/tests/user-account-state.spec.ts @@ -46,7 +46,10 @@ async function searchByEmail(page: Page, email: string): Promise { await search.fill(''); await search.fill(email); // La búsqueda de la lista es del lado servidor: se dispara con el botón «Buscar», no con Enter. - await page.getByRole('button', { name: /^buscar$/i }).last().click(); + await page + .getByRole('button', { name: /^buscar$/i }) + .last() + .click(); } /** Localiza la fila de la cuenta por email y la selecciona (abre su detalle). */ @@ -70,7 +73,7 @@ async function createAccount(page: Page, email: string): Promise { await page.getByPlaceholder('user@company.com').fill(email); await page.getByRole('button', { name: /^crear usuario$/i }).click(); await expect( - page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + page.locator('[data-testid="toast"][data-toast-type="success"]').first() ).toBeVisible({ timeout: 15000 }); // El diálogo de creación se cierra en onSuccess. await expect(page.getByText(/crear nueva cuenta de usuario/i)).toBeHidden({ timeout: 10000 }); @@ -101,7 +104,7 @@ test.describe('Cuentas de Usuario · ciclo de estado (happy path)', () => { // Estado real recién creado = Pending; la card muestra el botón «Activar». await expect( - page.locator('[data-testid="status-badge"][data-status="Pending"]').first(), + page.locator('[data-testid="status-badge"][data-status="Pending"]').first() ).toBeVisible({ timeout: 15000 }); await expect(page.getByRole('button', { name: /^activar$/i })).toBeVisible(); // En Pending NO existen los botones de otros estados. @@ -123,20 +126,23 @@ test.describe('Cuentas de Usuario · ciclo de estado (happy path)', () => { // ── (1) ACTIVAR: Pending → Active ──────────────────────────────────────── await page.getByRole('button', { name: /^activar$/i }).click(); await expect( - page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + page.locator('[data-testid="toast"][data-toast-type="success"]').first() ).toBeVisible({ timeout: 15000 }); // Re-lectura del estado REAL persistido (la card no se auto-refresca; ver cabecera). await reloadAndSelect(page, email); await expect( - page.locator('[data-testid="status-badge"][data-status="Active"]').first(), + page.locator('[data-testid="status-badge"][data-status="Active"]').first() ).toBeVisible({ timeout: 15000 }); // En Active la card ofrece «Bloquear» (y desaparece «Activar»). await expect(page.getByRole('button', { name: /^bloquear$/i })).toBeVisible(); await expect(page.getByRole('button', { name: /^activar$/i })).toHaveCount(0); // ── (2) BLOQUEAR: Active → Blocked (diálogo con motivo) ─────────────────── - await page.getByRole('button', { name: /^bloquear$/i }).first().click(); + await page + .getByRole('button', { name: /^bloquear$/i }) + .first() + .click(); // Diálogo de motivo: el campo se ancla por su nombre accesible (label «Motivo de Bloqueo»). // Nota: M3TextField renderiza el `placeholder` como hint flotante (nodo de texto), NO como // atributo `placeholder` nativo del input → `getByPlaceholder` no lo encuentra; usamos el rol. @@ -152,31 +158,39 @@ test.describe('Cuentas de Usuario · ciclo de estado (happy path)', () => { await blockDialog.getByRole('button', { name: /^bloquear$/i }).click(); // El bloqueo notifica con tipo `warning` (no `success`). await expect( - page.locator('[data-testid="toast"][data-toast-type="warning"]').first(), + page.locator('[data-testid="toast"][data-toast-type="warning"]').first() ).toBeVisible({ timeout: 15000 }); await reloadAndSelect(page, email); await expect( - page.locator('[data-testid="status-badge"][data-status="Blocked"]').first(), + page.locator('[data-testid="status-badge"][data-status="Blocked"]').first() ).toBeVisible({ timeout: 15000 }); // En Blocked la card ofrece «Restaurar» (y desaparece «Bloquear»). await expect(page.getByRole('button', { name: /^restaurar$/i })).toBeVisible(); await expect(page.getByRole('button', { name: /^bloquear$/i })).toHaveCount(0); // ── (3) RESTAURAR: Blocked → Active (diálogo de confirmación) ───────────── - await page.getByRole('button', { name: /^restaurar$/i }).first().click(); + await page + .getByRole('button', { name: /^restaurar$/i }) + .first() + .click(); // El diálogo de confirmación (ConfirmDialog, role=dialog) añade un 2º botón «Restaurar»; // se confirma dentro del propio diálogo (el .last() clicaría el de la card, tapado por el scrim). - await expect(page.getByRole('button', { name: /^restaurar$/i })).toHaveCount(2, { timeout: 10000 }); - await page.getByRole('dialog').getByRole('button', { name: /^restaurar$/i }).click(); + await expect(page.getByRole('button', { name: /^restaurar$/i })).toHaveCount(2, { + timeout: 10000, + }); + await page + .getByRole('dialog') + .getByRole('button', { name: /^restaurar$/i }) + .click(); await expect( - page.locator('[data-testid="toast"][data-toast-type="success"]').first(), + page.locator('[data-testid="toast"][data-toast-type="success"]').first() ).toBeVisible({ timeout: 15000 }); await reloadAndSelect(page, email); // Restaurar deja la cuenta de nuevo en Active → vuelve a ofrecer «Bloquear». await expect( - page.locator('[data-testid="status-badge"][data-status="Active"]').first(), + page.locator('[data-testid="status-badge"][data-status="Active"]').first() ).toBeVisible({ timeout: 15000 }); await expect(page.getByRole('button', { name: /^bloquear$/i })).toBeVisible(); await expect(page.getByRole('button', { name: /^restaurar$/i })).toHaveCount(0); From c3faac18fdaa284fab3e3bd169a65f6e06698be4 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 9 Aug 2026 20:55:24 -0500 Subject: [PATCH 09/35] =?UTF-8?q?refactor(web):=20elimina=20imports,=20bin?= =?UTF-8?q?dings=20y=20asignaciones=20muertas=20del=20=C3=A1rbol=20importa?= =?UTF-8?q?do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primer tramo de la limpieza de lint sobre el código traído en la resincronización: 598 -> 372 errores. Todo es código inerte; no hay cambios de comportamiento. - Imports sin usar: se quitan los especificadores que nadie referencia y las sentencias que quedan vacías. - Bindings sin usar: propiedades de desestructuración que nadie lee y `const` cuyo valor no se consume. Solo se borran cuando el inicializador no encierra llamadas; los que sí las tenían se revisaron uno a uno para confirmar que la llamada es una consulta pura (useI18n, useQueryClient, getActionState) antes de retirarla. - Casos con criterio: `PaginationControls` declaraba estado de colapso en el componente que no lo pinta (lo usa el segundo del fichero); `DataViewShell` destructuraba los manejadores de un splitter horizontal que no monta; `ParameterCatalogScreen` construía tres catálogos de opciones que ninguna vista consume; el test del esquema de auditoría usaba el idioma `const { x: _omitted, ...resto }` y ahora omite la clave sin dejar un binding muerto. Validación: tsc sin errores; vite build OK; vitest 1697/1697. Co-Authored-By: Claude Opus 5 --- .../hooks/use-access-resolution.ts | 4 ---- .../authorization/hooks/use-system-suite.ts | 7 +------ .../application/hooks/use-form-validation.ts | 4 +--- .../identity/hooks/use-delegation.ts | 7 +------ .../application/identity/hooks/use-tenant.ts | 7 +------ .../identity/hooks/use-user-account.ts | 7 +------ .../shared/hooks/use-list-panel-state.tsx | 7 ------- .../src/application/stores/auth.store.ts | 3 +-- .../audit/schemas/audit-record.schema.test.ts | 3 ++- .../schemas/authorization-graph.schema.ts | 2 -- .../infrastructure/http/graphqlClient.test.ts | 2 -- .../components/PermissionTemplateForm.tsx | 1 - .../profile/components/ProfileDetailPanel.tsx | 4 +--- .../profile/components/ProfileForm.tsx | 6 +----- .../profile/components/ProfileListPanel.tsx | 4 +--- .../tree/ProfileDomainResourcesPanel.tsx | 1 - .../tree/ProfileSystemActionsPanel.tsx | 2 -- .../components/SystemSuiteDetailPanel.tsx | 2 -- .../SystemSuiteFeatureFlagsPanel.tsx | 1 - .../components/SystemSuiteListPanel.tsx | 4 +--- .../components/AppConfigurationListPanel.tsx | 7 +------ .../components/FeatureFlagListPanel.tsx | 4 +--- .../ParameterCatalogDetailPanel.tsx | 2 -- .../components/ParameterCatalogListPanel.tsx | 7 +------ .../screens/ParameterCatalogScreen.tsx | 19 ------------------- .../delegation/components/DelegationForm.tsx | 4 +--- .../components/DelegationListPanel.tsx | 4 +--- .../components/DelegationProfileCard.tsx | 1 - .../identity/profile/screens/LoginScreen.tsx | 2 -- .../profile/screens/ProfileScreen.tsx | 1 - .../identity/tenant/components/IdpPanel.tsx | 1 - .../components/TenantConfigurationsPanel.tsx | 4 ++-- .../tenant/components/TenantListPanel.tsx | 3 +-- .../tenant/components/TenantProfileCard.tsx | 1 - .../tenant/components/tenant-list-renders.tsx | 2 -- .../components/UserAccountListPanel.tsx | 5 +---- .../shared/components/ConnectedUserDrawer.tsx | 4 ---- .../shared/components/EmptyState.test.tsx | 2 -- .../shared/components/EntityCard.test.tsx | 1 - .../shared/components/InlineAddForm.tsx | 1 - .../shared/components/M3DataView.tsx | 2 +- .../shared/components/M3Select.test.tsx | 2 +- .../shared/components/M3Skeleton.test.tsx | 2 +- .../shared/components/M3TextField.test.tsx | 2 +- .../shared/components/ProtectedRoute.tsx | 1 - .../shared/components/Spinner.test.tsx | 2 +- .../shared/components/Tooltip.test.tsx | 2 +- .../shared/components/data-view/FilterBar.tsx | 2 +- .../data-view/PaginationControls.tsx | 3 --- .../components/layouts/DataViewShell.tsx | 3 --- .../tests/permission-template-state.spec.ts | 2 -- 51 files changed, 28 insertions(+), 148 deletions(-) diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.ts index 6df41e90..5841e04c 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.ts @@ -2,10 +2,6 @@ import { useAuthStore } from '@app/stores/auth.store'; import { AccessEffect, AuthorizationGraph, - GraphMenuModule, - GraphMenu, - GraphSubMenu, - GraphMenuOption, } from '@domain/authorization/schemas/authorization-graph.schema'; export const useAccessResolution = () => { diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts index 7d38e1ce..7b515d62 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts @@ -7,12 +7,7 @@ import { SystemSuite, SystemSuitePage, } from '@domain/authorization/models/system-suite.model'; -import { - getHttpStatus, - isNonRecoverable, - isNetworkError, - getRetryOptions, -} from '@app/utils/error-utils'; +import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; // ─── Query params ─────────────────────────────────────────────────────────── diff --git a/src/apps/ums.web-app/src/application/hooks/use-form-validation.ts b/src/apps/ums.web-app/src/application/hooks/use-form-validation.ts index e4e45f02..8320cf01 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-form-validation.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-form-validation.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { z } from 'zod'; export type FieldErrors = Record; @@ -72,8 +72,6 @@ export function useFormValidation( const validateField = useCallback( (fieldName: string, value: unknown): boolean => { - const partialData = { ...initialValues, [fieldName]: value }; - try { const fieldSchema = schema.shape?.[fieldName]; if (!fieldSchema) return true; diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts index 87391a55..6ac7d2be 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts @@ -9,12 +9,7 @@ import delegationService from '@infra/identity/services/delegation.service'; import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { useI18n } from '@app/i18n/use-i18n'; import type { CreateDelegationPayload, Delegation } from '@domain/identity/models/delegation.model'; -import { - getHttpStatus, - isNonRecoverable, - isNetworkError, - getRetryOptions, -} from '@app/utils/error-utils'; +import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; // ─── Queries ──────────────────────────────────────────────────────────────── diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts index f5206283..65da9a8d 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts @@ -3,12 +3,7 @@ import tenantService from '@infra/identity/services/tenant.service'; import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { useI18n } from '@app/i18n/use-i18n'; import { CreateTenantPayload, Tenant, TenantPage } from '@domain/identity/models/tenant.model'; -import { - getHttpStatus, - isNonRecoverable, - isNetworkError, - getRetryOptions, -} from '@app/utils/error-utils'; +import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; // ─── Query params ─────────────────────────────────────────────────────────── diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts index 2ea66b11..fd1a8c39 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts @@ -14,12 +14,7 @@ import { UserAccountPage, } from '@domain/identity/models/user-account.model'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; -import { - getHttpStatus, - isNonRecoverable, - isNetworkError, - getRetryOptions, -} from '@app/utils/error-utils'; +import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; export interface UserAccountQueryParams { page: number; diff --git a/src/apps/ums.web-app/src/application/shared/hooks/use-list-panel-state.tsx b/src/apps/ums.web-app/src/application/shared/hooks/use-list-panel-state.tsx index f0a1089a..d85849e3 100644 --- a/src/apps/ums.web-app/src/application/shared/hooks/use-list-panel-state.tsx +++ b/src/apps/ums.web-app/src/application/shared/hooks/use-list-panel-state.tsx @@ -64,15 +64,8 @@ export interface ListPanelOptions { */ export function useListPanelState(options: ListPanelOptions) { const { - items, - selectedId, - isLoading, - error, - viewMode, - onViewModeChange, queryState, paginationState, - onRegisterNew, requiresFilter = false, filterPromptTitle = 'Aplica un filtro para cargar datos', filterPromptMessage = 'Selecciona un estado o ingresa un término de búsqueda para visualizar los elementos.', diff --git a/src/apps/ums.web-app/src/application/stores/auth.store.ts b/src/apps/ums.web-app/src/application/stores/auth.store.ts index 696be7cc..47aa4646 100644 --- a/src/apps/ums.web-app/src/application/stores/auth.store.ts +++ b/src/apps/ums.web-app/src/application/stores/auth.store.ts @@ -103,7 +103,6 @@ const ACCESS_TOKEN_DURATION = 60 * 60 * 1000; const REFRESH_TOKEN_DURATION = 7 * 24 * 60 * 60 * 1000; const SESSION_WARNING_THRESHOLD = 5 * 60 * 1000; const ACTIVITY_UPDATE_INTERVAL = 60 * 1000; -const REFRESH_THRESHOLD = 5 * 60 * 1000; function isSessionExpired(expiresAt: number | null): boolean { if (!expiresAt) return true; @@ -418,7 +417,7 @@ let activityInterval: ReturnType | null = null; export function startActivityMonitor() { if (activityInterval) return; - const { updateActivity, isAuthenticated } = useAuthStore.getState(); + const { isAuthenticated } = useAuthStore.getState(); if (!isAuthenticated) return; activityInterval = setInterval(() => { diff --git a/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.test.ts b/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.test.ts index f89f118f..d0686225 100644 --- a/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.test.ts +++ b/src/apps/ums.web-app/src/domain/audit/schemas/audit-record.schema.test.ts @@ -29,7 +29,8 @@ describe('AuditRecordSchema', () => { }); it('acepta metadata omitida (opcional)', () => { - const { metadata: _omitted, ...withoutMetadata } = validRecord; + const withoutMetadata: Partial = { ...validRecord }; + delete withoutMetadata.metadata; const record = AuditRecordSchema.parse(withoutMetadata); expect(record.metadata).toBeUndefined(); }); diff --git a/src/apps/ums.web-app/src/domain/authorization/schemas/authorization-graph.schema.ts b/src/apps/ums.web-app/src/domain/authorization/schemas/authorization-graph.schema.ts index 7debee48..67562c60 100644 --- a/src/apps/ums.web-app/src/domain/authorization/schemas/authorization-graph.schema.ts +++ b/src/apps/ums.web-app/src/domain/authorization/schemas/authorization-graph.schema.ts @@ -1,6 +1,4 @@ import { z } from 'zod'; -import { SchemaVersion } from '@domain/constants/schema-version'; - // Effect and Source Enums export enum AccessEffect { Allow = 'Allow', diff --git a/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.test.ts b/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.test.ts index 4faf04fa..0074ad0b 100644 --- a/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.test.ts @@ -67,8 +67,6 @@ describe('GraphQlValidationError', () => { }); describe('graphqlClient', () => { - const originalFetch = global.fetch; - beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateForm.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateForm.tsx index 393c336a..669de36b 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateForm.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateForm.tsx @@ -3,7 +3,6 @@ * Uses M3Dialog with shared form components. */ import React, { useState } from 'react'; -import { ShieldPlus } from 'lucide-react'; import { M3Dialog, FieldSelect } from '@shared/components'; import { useCreatePermissionTemplate } from '@app/authorization/hooks/use-permission-template'; import { useGetAllSystemSuites } from '@app/authorization/hooks/use-system-suite'; diff --git a/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileDetailPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileDetailPanel.tsx index 8436b79f..ee1457ec 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileDetailPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileDetailPanel.tsx @@ -66,9 +66,7 @@ export const ProfileDetailPanel: React.FC = ({ profile, isLoading }) => { const deactivatePermMutation = useDeactivateProfilePermission(); const activateProfileMutation = useActivateProfile(); const deactivateProfileMutation = useDeactivateProfile(); - const { data: suite, isLoading: loadingSuite } = useGetSystemSuite( - profile?.systemSuiteId ?? null - ); + const { data: suite } = useGetSystemSuite(profile?.systemSuiteId ?? null); const addNotification = useNotificationStore(s => s.addNotification); const renderInlineActions = (node: { id: string }) => { diff --git a/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileForm.tsx b/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileForm.tsx index f4cdae41..c3bdf10b 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileForm.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileForm.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from 'react'; import { - UserCheck, ShieldAlert, Shield, ShieldCheck, @@ -12,7 +11,7 @@ import { Zap, Building2, } from 'lucide-react'; -import { M3Dialog, M3Tabs, FieldSelect, FormButton } from '@shared/components'; +import { M3Dialog, M3Tabs, FieldSelect } from '@shared/components'; import { useEffectiveTenant } from '@app/shared/hooks/use-effective-tenant'; import { useAuthStore } from '@app/stores/auth.store'; import { useGetAllUserAccounts } from '@app/identity/hooks/use-user-account'; @@ -43,8 +42,6 @@ import { getSiblingViewOptions, } from '../../../../application/authorization/utils/permission-cascade'; import { useNotificationStore } from '@app/stores/notification.store'; -import { useQueryClient } from '@tanstack/react-query'; - interface Props { isOpen: boolean; onClose: () => void; @@ -67,7 +64,6 @@ export const ProfileForm: React.FC = ({ isOpen, onClose, onSuccess, tenan const effectiveTenantId = useEffectiveTenant(tenantId); const sessionTenantName = useAuthStore(state => state.user?.tenantName); const addNotification = useNotificationStore(s => s.addNotification); - const queryClient = useQueryClient(); const [userId, setUserId] = useState(''); const [systemSuiteId, setSystemSuiteId] = useState(''); diff --git a/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileListPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileListPanel.tsx index 32db3eb4..fe702396 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileListPanel.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { UserCheck, Share2, Info, Building2, Shield, Key } from 'lucide-react'; +import { UserCheck, Share2, Building2, Shield, Key } from 'lucide-react'; import { type Profile } from '@domain/authorization/schemas/profile.schema'; import { StatusBadge } from '@shared/components/StatusBadge'; import { @@ -222,8 +222,6 @@ export const ProfileListPanel: React.FC = ({ : undefined; const totalItems = paginationState.totalItems; - const startIndex = paginationState.startIndex ?? 0; - const pageSize = paginationState.pageSize; const filterPrompt = requiresFilter ? ( nodes.map(node => { const isExpanded = expandedNodes.has(node.id); - const isSelected = false; const hasChildren = node.children.length > 0; return (
diff --git a/src/apps/ums.web-app/src/presentation/authorization/profile/components/tree/ProfileSystemActionsPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/profile/components/tree/ProfileSystemActionsPanel.tsx index 5592e6f4..0f8e4818 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/profile/components/tree/ProfileSystemActionsPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/profile/components/tree/ProfileSystemActionsPanel.tsx @@ -101,8 +101,6 @@ export const ProfileSystemActionsPanel: React.FC }; const renderCard = (action: NonNullable[number]) => { - const state = getActionState(action.id); - return ( = ({ ); const totalItems = paginationState.totalItems; - const startIndex = paginationState.startIndex ?? 0; - const pageSize = paginationState.pageSize; const pagination = paginationState.totalPages > 0 diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx index 5d8b69ee..a4e6bd62 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from 'react'; -import { Settings, Key, Lock, Globe, Building2, Cog, Info } from 'lucide-react'; +import { Settings, Key, Lock, Globe, Building2, Cog } from 'lucide-react'; import type { AppConfiguration } from '@domain/configuration/schemas/app-configuration.schema'; -import { useI18n } from '@app/i18n/use-i18n'; import { DataViewShell, DataList, @@ -64,8 +63,6 @@ export function AppConfigurationListPanel({ requiresFilter, filterOptions, }: AppConfigurationListPanelProps): React.JSX.Element { - const t = useI18n(); - const defaultFilterOptions: AtomicFilterOption[] = [ { label: 'Todos', value: 'all' }, { label: 'Borrador', value: 'Draft' }, @@ -173,8 +170,6 @@ export function AppConfigurationListPanel({ ); const totalItems = paginationState.totalItems; - const startIndex = paginationState.startIndex ?? 0; - const pageSize = paginationState.pageSize; const pagination = paginationState.totalPages > 0 diff --git a/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagListPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagListPanel.tsx index b264d4ed..ee1fbbf6 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagListPanel.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Flag, Info } from 'lucide-react'; +import { Flag } from 'lucide-react'; import { type FeatureFlag } from '@domain/configuration/models/feature-flag.model'; import { StatusBadge } from '@shared/components/StatusBadge'; import { @@ -191,8 +191,6 @@ export const FeatureFlagListPanel: React.FC = ({ ); const totalItems = paginationState.totalItems; - const startIndex = paginationState.startIndex ?? 0; - const pageSize = paginationState.pageSize; const pagination = paginationState.totalPages > 0 diff --git a/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/components/ParameterCatalogDetailPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/components/ParameterCatalogDetailPanel.tsx index a63d84dd..04cf7c1a 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/components/ParameterCatalogDetailPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/components/ParameterCatalogDetailPanel.tsx @@ -9,7 +9,6 @@ import { DataTypeLabels, ScopeLabels, } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; -import { M3Card } from '@shared/components/M3Card'; import { useI18n } from '@app/i18n/use-i18n'; import { LoadingSpinner } from '@shared/components/LoadingSpinner'; @@ -22,7 +21,6 @@ interface ParameterCatalogDetailPanelProps { } export const ParameterCatalogDetailPanel: React.FC = ({ - definitionId, activeParameter, isLoading, onEdit, diff --git a/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/components/ParameterCatalogListPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/components/ParameterCatalogListPanel.tsx index ab601bfb..842b5f75 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/components/ParameterCatalogListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/components/ParameterCatalogListPanel.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from 'react'; -import { Tag, Info } from 'lucide-react'; +import { Tag } from 'lucide-react'; import type { ParameterDefinition } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; -import { useI18n } from '@app/i18n/use-i18n'; import { DataTypeLabels, ScopeLabels, @@ -70,8 +69,6 @@ export function ParameterCatalogListPanel({ onSelectParameter, requiresFilter, }: ParameterCatalogListPanelProps): React.JSX.Element { - const t = useI18n(); - const filterOptions: AtomicFilterOption[] = [ { label: 'Todos', value: 'all' }, { label: 'Activo', value: 'true' }, @@ -197,8 +194,6 @@ export function ParameterCatalogListPanel({ ); const totalItems = paginationState.totalItems; - const startIndex = paginationState.startIndex ?? 0; - const pageSize = paginationState.pageSize; const pagination = paginationState.totalPages > 0 diff --git a/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/screens/ParameterCatalogScreen.tsx b/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/screens/ParameterCatalogScreen.tsx index e4023a66..09159f3a 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/screens/ParameterCatalogScreen.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/parameter-catalog/screens/ParameterCatalogScreen.tsx @@ -11,31 +11,12 @@ import { ParameterDefinitionForm } from '../components/ParameterDefinitionForm'; import { PageShell } from '@shared/layouts/PageShell'; import { MasterDetailLayout } from '@shared/layouts/MasterDetailLayout'; import { M3Dialog } from '@shared/components/M3Dialog'; -import { SortOption, FilterOption, QueryCriteriaOption } from '@shared/components/M3DataView'; import type { CreateParameterDefinitionPayload } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; export default function ParameterCatalogScreen(): React.JSX.Element { const t = useI18n(); const d = useParameterCatalogDashboard(); - const criteriaOptions: QueryCriteriaOption[] = [ - { label: t.byCode ?? 'Código', value: 'code' }, - { label: t.byName ?? 'Nombre', value: 'name' }, - ]; - - const filterOptions: FilterOption[] = [ - { label: t.allStatuses ?? 'Todos', value: 'all' }, - { label: 'Activo', value: 'true' }, - { label: 'Inactivo', value: 'false' }, - ]; - - const sortOptions: SortOption[] = [ - { label: t.sortByCode ?? 'Código', value: 'code' }, - { label: t.sortByName ?? 'Nombre', value: 'name' }, - { label: 'Tipo', value: 'dataTypeId' }, - { label: 'Alcance', value: 'scopeId' }, - ]; - const handleCreate = async (data: CreateParameterDefinitionPayload) => { await d.createParameter(data); }; diff --git a/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationForm.tsx b/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationForm.tsx index 427ee1f1..05f2b9ad 100644 --- a/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationForm.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationForm.tsx @@ -2,10 +2,8 @@ import React, { useState } from 'react'; import { useCreateDelegation } from '@app/identity/hooks/use-delegation'; import { useI18n } from '@app/i18n/use-i18n'; import { M3Dialog } from '@shared/components/M3Dialog'; -import { FormField, FormInput, FormSelect, FormButton, FieldSelect } from '@shared/components'; +import { FormField, FormInput, FieldSelect } from '@shared/components'; import { CreateDelegationPayloadSchema } from '@domain/identity/schemas/delegation.schema'; -import { Shield } from 'lucide-react'; - const SCOPE_TYPES = ['Tenant', 'Organization', 'Department', 'System', 'Team'] as const; const DELEGATED_ACTIONS = [ 'CreateUser', diff --git a/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationListPanel.tsx b/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationListPanel.tsx index da31bc58..4038b7d0 100644 --- a/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationListPanel.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Shield, ArrowRight, Info } from 'lucide-react'; +import { Shield, ArrowRight } from 'lucide-react'; import type { Delegation } from '@domain/identity/models/delegation.model'; import { StatusBadge } from '@shared/components/StatusBadge'; import { CodeBadge } from '@shared/components/CodeBadge'; @@ -146,8 +146,6 @@ export const DelegationListPanel: React.FC = ({ ); const totalItems = paginationState.totalItems; - const startIndex = paginationState.startIndex ?? 0; - const pageSize = paginationState.pageSize; const pagination = paginationState.totalPages > 0 diff --git a/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationProfileCard.tsx b/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationProfileCard.tsx index b89ae8fd..cd32e5e4 100644 --- a/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationProfileCard.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/delegation/components/DelegationProfileCard.tsx @@ -11,7 +11,6 @@ import { useStatusLabel } from '@app/hooks/use-status-label'; import { Delegation } from '@domain/identity/models/delegation.model'; import { Shield, Sliders, ShieldAlert, CheckCircle2, Send, XCircle } from 'lucide-react'; import { M3Card } from '@shared/components/M3Card'; -import { M3Button } from '@shared/components/M3Button'; import { StatusBadge } from '@shared/components/StatusBadge'; import { CodeBadge } from '@shared/components/CodeBadge'; diff --git a/src/apps/ums.web-app/src/presentation/identity/profile/screens/LoginScreen.tsx b/src/apps/ums.web-app/src/presentation/identity/profile/screens/LoginScreen.tsx index 20c07701..b8b141bb 100644 --- a/src/apps/ums.web-app/src/presentation/identity/profile/screens/LoginScreen.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/profile/screens/LoginScreen.tsx @@ -17,7 +17,6 @@ import { useAuthStore, detectBrowserTimezone } from '@app/stores/auth.store'; import { useI18nStore } from '@app/stores/i18n.store'; import type { SupportedLanguage } from '@app/stores/i18n.store'; import { useDevToolsStore } from '@app/stores/devTools.store'; -import { useI18n } from '@app/i18n/use-i18n'; import { M3Card } from '@shared/components/M3Card'; import { M3Button } from '@shared/components/M3Button'; import { M3TextField } from '@shared/components/M3TextField'; @@ -39,7 +38,6 @@ export default function LoginScreen(): React.JSX.Element { const { setLanguage } = useI18nStore(); const { setDevUserId } = useDevToolsStore(); const addNotification = useNotificationStore(state => state.addNotification); - const t = useI18n(); const [tenantId, setTenantId] = useState(DEV_TENANTS[0].id); const [username, setUsername] = useState(''); diff --git a/src/apps/ums.web-app/src/presentation/identity/profile/screens/ProfileScreen.tsx b/src/apps/ums.web-app/src/presentation/identity/profile/screens/ProfileScreen.tsx index c82bfb71..317fdd2f 100644 --- a/src/apps/ums.web-app/src/presentation/identity/profile/screens/ProfileScreen.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/profile/screens/ProfileScreen.tsx @@ -9,7 +9,6 @@ import { M3Card } from '@shared/components/M3Card'; import { M3Button } from '@shared/components/M3Button'; import { KeyValueRow } from '@shared/components/KeyValueRow'; import { CodeBadge } from '@shared/components/CodeBadge'; -import { StatusBadge } from '@shared/components/StatusBadge'; import { User, ShieldCheck, diff --git a/src/apps/ums.web-app/src/presentation/identity/tenant/components/IdpPanel.tsx b/src/apps/ums.web-app/src/presentation/identity/tenant/components/IdpPanel.tsx index 53acc1d4..6ef0e3f4 100644 --- a/src/apps/ums.web-app/src/presentation/identity/tenant/components/IdpPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/tenant/components/IdpPanel.tsx @@ -10,7 +10,6 @@ import { import { formatSystemCode } from '@app/utils/security'; import { idpService } from '@infra/identity/services/idp.service'; import type { IdentityProvider } from '@domain/identity/schemas/identity-provider.schema'; -import { M3Button } from '@shared/components/M3Button'; import { M3TextField } from '@shared/components/M3TextField'; import { M3Select } from '@shared/components/M3Select'; import { M3Switch } from '@shared/components/M3Switch'; diff --git a/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantConfigurationsPanel.tsx b/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantConfigurationsPanel.tsx index 9fb5747c..cc839048 100644 --- a/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantConfigurationsPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantConfigurationsPanel.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useCallback, useState } from 'react'; import { useI18n } from '@app/i18n/use-i18n'; import { useNotificationStore } from '@app/stores/notification.store'; -import { Plus, Globe, Building2, Edit2, X, Check } from 'lucide-react'; +import { Globe, Building2, Edit2, X, Check } from 'lucide-react'; import { appConfigurationService } from '@infra/configuration/services/app-configuration.service'; import { EmptyState } from '@presentation/shared/components/EmptyState'; import { SectionHeader } from '@presentation/shared/components/SectionHeader'; @@ -18,7 +18,7 @@ import { ConfigValueDisplay } from '@presentation/shared/components/ConfigValueD import { ListToolbar } from '@shared/components/ListToolbar'; import { StatusBadge } from '@shared/components/StatusBadge'; import { M3Dialog } from '@shared/components/M3Dialog'; -import { FormField, FormInput, FormButton } from '@shared/components/form'; +import { FormInput } from '@shared/components/form'; import { parameterCatalogService } from '@infra/configuration/services/parameter-catalog/parameter-catalog.service'; import type { ParameterDefinition } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; import { getHttpErrorMessage, getSupportReferenceId } from '@app/errors/http-error'; diff --git a/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantListPanel.tsx b/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantListPanel.tsx index 0ecfb3be..4de77b48 100644 --- a/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantListPanel.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Info, LayoutList, LayoutGrid } from 'lucide-react'; +import { Info } from 'lucide-react'; import { ListToolbar } from '@shared/components/ListToolbar'; import { Tenant } from '@domain/identity/models/tenant.model'; import { HierarchicalList } from '@shared/components/HierarchicalList'; @@ -16,7 +16,6 @@ import { AtomicQueryCriteriaOption, AtomicFilterOption, AtomicSortOption, - PaginationFooter, RequiresFilterPrompt, } from '@shared/components'; import { useQueryState } from '@app/shared/hooks/use-query-state'; diff --git a/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantProfileCard.tsx b/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantProfileCard.tsx index f55a8fac..332ed146 100644 --- a/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantProfileCard.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantProfileCard.tsx @@ -24,7 +24,6 @@ import { ShieldCheck, } from 'lucide-react'; import { M3Card } from '@shared/components/M3Card'; -import { M3Button } from '@shared/components/M3Button'; import { M3TextField } from '@shared/components/M3TextField'; import { M3Select } from '@shared/components/M3Select'; import { M3Dialog } from '@shared/components/M3Dialog'; diff --git a/src/apps/ums.web-app/src/presentation/identity/tenant/components/tenant-list-renders.tsx b/src/apps/ums.web-app/src/presentation/identity/tenant/components/tenant-list-renders.tsx index bfa73766..8a459c78 100644 --- a/src/apps/ums.web-app/src/presentation/identity/tenant/components/tenant-list-renders.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/tenant/components/tenant-list-renders.tsx @@ -6,8 +6,6 @@ import { StatusBadge } from '@shared/components/StatusBadge'; import { CodeBadge } from '@shared/components/CodeBadge'; import { EntityRow } from '@shared/components/EntityRow'; import type { TreeNode } from '@app/hooks/use-tree-nodes'; -import { useGetAllAppConfigurations } from '@app/configuration/hooks/use-app-configuration'; - export const AuthModeBadge: React.FC<{ tenantId: string; tenantCode?: string }> = ({ tenantId, tenantCode, diff --git a/src/apps/ums.web-app/src/presentation/identity/user-account/components/UserAccountListPanel.tsx b/src/apps/ums.web-app/src/presentation/identity/user-account/components/UserAccountListPanel.tsx index 2efef9e5..a5ed25f7 100644 --- a/src/apps/ums.web-app/src/presentation/identity/user-account/components/UserAccountListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/user-account/components/UserAccountListPanel.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Mail, Layers, ArrowRight, Info, Building2 } from 'lucide-react'; +import { Mail, Layers, ArrowRight } from 'lucide-react'; import { UserAccount } from '@domain/identity/models/user-account.model'; import { Tenant } from '@domain/identity/models/tenant.model'; import { StatusBadge } from '@shared/components/StatusBadge'; @@ -10,13 +10,11 @@ import { AtomicQueryCriteriaOption, AtomicFilterOption, AtomicSortOption, - PaginationFooter, RequiresFilterPrompt, } from '@shared/components'; import { ListToolbar } from '@shared/components/ListToolbar'; import { EntityRow } from '@shared/components/EntityRow'; import { EntityCard } from '@shared/components/EntityCard'; -import { M3Card } from '@shared/components/M3Card'; import { M3Button } from '@shared/components/M3Button'; import { useI18n } from '@app/i18n/use-i18n'; import { useStatusLabel } from '@app/hooks/use-status-label'; @@ -70,7 +68,6 @@ export const UserAccountListPanel: React.FC = ({ tenants, selectedTenantId, onTenantChange, - sessionTenantName, requiresFilter = false, }) => { const t = useI18n(); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ConnectedUserDrawer.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ConnectedUserDrawer.tsx index 3184ab02..53da0a0b 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/ConnectedUserDrawer.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/ConnectedUserDrawer.tsx @@ -3,8 +3,6 @@ import { useAuthStore } from '@app/stores/auth.store'; import { useI18n } from '@app/i18n/use-i18n'; import { M3Drawer } from '@shared/components/M3Drawer'; import { M3Button } from '@shared/components/M3Button'; -import { Spinner } from '@shared/components/Spinner'; -import { KeyValueRow } from '@shared/components/KeyValueRow'; import { CodeBadge } from '@shared/components/CodeBadge'; import { StatusBadge } from '@shared/components/StatusBadge'; import { @@ -12,8 +10,6 @@ import { Building2, ShieldCheck, Clock, - Key, - Globe, ChevronDown, ChevronRight, Copy, diff --git a/src/apps/ums.web-app/src/presentation/shared/components/EmptyState.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/EmptyState.test.tsx index 697caf44..390bbba8 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/EmptyState.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/EmptyState.test.tsx @@ -1,8 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { EmptyState } from './EmptyState'; -import * as M3CardModule from './M3Card'; -import * as TooltipModule from './Tooltip'; vi.mock('./M3Card', () => ({ M3Card: ({ children, className }: any) => ( diff --git a/src/apps/ums.web-app/src/presentation/shared/components/EntityCard.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/EntityCard.test.tsx index 30de8823..9dc2c991 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/EntityCard.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/EntityCard.test.tsx @@ -1,7 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { EntityCard, EntityCardGeneric } from './EntityCard'; -import * as M3CardModule from './M3Card'; vi.mock('./M3Card', () => ({ M3Card: ({ children, onClick, variant, className }: any) => ( diff --git a/src/apps/ums.web-app/src/presentation/shared/components/InlineAddForm.tsx b/src/apps/ums.web-app/src/presentation/shared/components/InlineAddForm.tsx index 0aad68e6..6eda9a61 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/InlineAddForm.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/InlineAddForm.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Plus, X } from 'lucide-react'; import { M3Card } from './M3Card'; -import { M3Button } from './M3Button'; import { IconButton } from './Tooltip'; /** diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx index a3d7a034..82c6d7ad 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { Search, LayoutList, LayoutGrid, Plus, Database } from 'lucide-react'; import { M3Card } from './M3Card'; import { M3TextField } from './M3TextField'; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3Select.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3Select.test.tsx index c742991f..340e5e92 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3Select.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3Select.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { M3Select } from './M3Select'; describe('M3Select', () => { diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3Skeleton.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3Skeleton.test.tsx index 6a957190..b4f97596 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3Skeleton.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3Skeleton.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { M3Skeleton, M3SkeletonRow } from './M3Skeleton'; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3TextField.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3TextField.test.tsx index 0b153291..d2fa273a 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3TextField.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3TextField.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { M3TextField } from './M3TextField'; describe('M3TextField', () => { diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.tsx index 62e11945..8abbf5b8 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.tsx @@ -13,7 +13,6 @@ import React, { useEffect, useState } from 'react'; import { Navigate, useLocation } from 'react-router'; import { useAuthStore } from '@app/stores/auth.store'; -import { authService } from '@app/identity/services/auth.service'; import { Spinner } from '@shared/components/Spinner'; interface ProtectedRouteProps { diff --git a/src/apps/ums.web-app/src/presentation/shared/components/Spinner.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/Spinner.test.tsx index 33ae6c1a..57df3a61 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/Spinner.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/Spinner.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { Spinner } from './Spinner'; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.test.tsx index 70344264..bba2b94e 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.test.tsx @@ -1,5 +1,5 @@ import { render, screen, act, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { Tooltip, IconButton } from './Tooltip'; describe('Tooltip', () => { diff --git a/src/apps/ums.web-app/src/presentation/shared/components/data-view/FilterBar.tsx b/src/apps/ums.web-app/src/presentation/shared/components/data-view/FilterBar.tsx index 7ca7ab6c..366631f6 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/data-view/FilterBar.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/data-view/FilterBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid } from 'lucide-react'; +import { Filter, ArrowUp, ArrowDown, List, Grid } from 'lucide-react'; import type { FilterOption, SortOption } from '../M3DataView'; interface FilterBarProps { diff --git a/src/apps/ums.web-app/src/presentation/shared/components/data-view/PaginationControls.tsx b/src/apps/ums.web-app/src/presentation/shared/components/data-view/PaginationControls.tsx index cdc3401c..677dceb1 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/data-view/PaginationControls.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/data-view/PaginationControls.tsx @@ -15,9 +15,6 @@ export const PaginationControls: React.FC = ({ totalPages, onPageChange, }) => { - const [isCollapsed, setIsCollapsed] = useState(false); - const toggle = useCallback(() => setIsCollapsed(v => !v), []); - return (
diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ApiErrorBanner.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ApiErrorBanner.tsx index adad196f..2e62c2b2 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/ApiErrorBanner.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/ApiErrorBanner.tsx @@ -3,7 +3,50 @@ import { Info, WifiOff, AlertTriangle } from 'lucide-react'; import { useI18n } from '@app/i18n/use-i18n'; import { getSupportReferenceId } from '@app/errors/http-error'; import { getHttpStatus } from '@app/utils/error-utils'; +import type { LucideIcon } from 'lucide-react'; +/** + * Qué clase de error muestra el banner. Icono, título y pista se decidían por separado, cada uno + * con su propia cascada de ternarios sobre los mismos dos booleanos. + */ +type ApiErrorKind = 'unavailable' | 'validation' | 'generic'; + +function resolveErrorKind(status: number): ApiErrorKind { + if (status >= 500) return 'unavailable'; + if (status === 400 || status === 422) return 'validation'; + return 'generic'; +} + +type Translations = ReturnType; + +const ERROR_PRESENTATION: Record< + ApiErrorKind, + { + Icon: LucideIcon; + iconClass: string; + title: (t: Translations) => string; + hint: (t: Translations) => string; + } +> = { + unavailable: { + Icon: WifiOff, + iconClass: 'text-amber-500', + title: t => t.errorBackendUnavailableTitle || 'Backend API Unavailable', + hint: t => t.errorBackendUnavailableHint || 'Start the backend API and refresh.', + }, + validation: { + Icon: AlertTriangle, + iconClass: 'text-rose-500', + title: t => t.errorInvalidRequestTitle || 'Invalid Request', + hint: t => t.errorInvalidRequestHint || 'Check the request parameters.', + }, + generic: { + Icon: Info, + iconClass: 'text-rose-500', + title: t => t.errorGenericTitle || 'Error', + hint: t => t.errorGenericHint || 'Ensure the backend API is running.', + }, +}; export interface ApiErrorBannerProps { error: Error; } @@ -11,29 +54,13 @@ export interface ApiErrorBannerProps { export const ApiErrorBanner: React.FC = ({ error }) => { const t = useI18n(); const status = getHttpStatus(error); - const isUnavailable = status >= 500; - const isValidation = status === 400 || status === 422; const supportReferenceId = getSupportReferenceId(error); - const icon = isUnavailable ? ( - - ) : isValidation ? ( - - ) : ( - - ); - - const title = isUnavailable - ? t.errorBackendUnavailableTitle || 'Backend API Unavailable' - : isValidation - ? t.errorInvalidRequestTitle || 'Invalid Request' - : t.errorGenericTitle || 'Error'; - - const hint = isUnavailable - ? t.errorBackendUnavailableHint || 'Start the backend API and refresh.' - : isValidation - ? t.errorInvalidRequestHint || 'Check the request parameters.' - : t.errorGenericHint || 'Ensure the backend API is running.'; + const kind = resolveErrorKind(status); + const { Icon, iconClass } = ERROR_PRESENTATION[kind]; + const icon = ; + const title = ERROR_PRESENTATION[kind].title(t); + const hint = ERROR_PRESENTATION[kind].hint(t); return (
diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3FieldsetWrapper.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3FieldsetWrapper.tsx index af1e6050..c72344ad 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3FieldsetWrapper.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3FieldsetWrapper.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { resolveBorderClass, resolveLabelColorClass } from './field-state'; /** * M3FieldsetWrapper — MD3 outlined field container with notched label. @@ -48,19 +49,9 @@ export const M3FieldsetWrapper: React.FC = ({ onBlur?.(); }; - const borderClass = focused - ? `border-2 ${error ? 'border-m3-error' : 'border-m3-primary'}` - : error - ? 'border border-m3-error' - : 'border border-m3-outline hover:border-m3-on-surface'; + const borderClass = resolveBorderClass({ focused: focused, error: !!error }); - const labelColorClass = focused - ? error - ? 'text-m3-error' - : 'text-m3-primary' - : error - ? 'text-m3-error' - : 'text-m3-secondary'; + const labelColorClass = resolveLabelColorClass({ focused: focused, error: !!error }); return (
diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3Select.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3Select.tsx index 73e3e110..a29e0ebf 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3Select.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3Select.tsx @@ -1,5 +1,6 @@ import React, { useState, useId } from 'react'; import { ChevronDown } from 'lucide-react'; +import { resolveBorderClass, resolveLabelColorClass } from './field-state'; interface M3SelectProps extends React.SelectHTMLAttributes { label: string; @@ -41,19 +42,9 @@ export const M3Select: React.FC = ({ const [focused, setFocused] = useState(false); - const borderClass = focused - ? `border-2 ${error ? 'border-m3-error' : 'border-m3-primary'}` - : error - ? 'border border-m3-error' - : 'border border-m3-outline hover:border-m3-on-surface'; + const borderClass = resolveBorderClass({ focused: focused, error: !!error }); - const labelColorClass = focused - ? error - ? 'text-m3-error' - : 'text-m3-primary' - : error - ? 'text-m3-error' - : 'text-m3-secondary'; + const labelColorClass = resolveLabelColorClass({ focused: focused, error: !!error }); return (
diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3TextField.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3TextField.tsx index f56b3e87..73591431 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3TextField.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3TextField.tsx @@ -1,4 +1,5 @@ import React, { useState, useId } from 'react'; +import { resolveBorderClass, resolveLabelColorClass } from './field-state'; /** * Densidad del campo. Vive como una sola decisión con nombre porque de ella cuelgan la altura y @@ -35,17 +36,6 @@ const HINT_INSET = { withoutIcon: { dense: 'left-3 right-3', normal: 'left-4 right-4' }, } as const; -/** El foco manda sobre el error en el grosor del borde; el error manda en el color. */ -function resolveBorderClass({ focused, error }: { focused: boolean; error: boolean }): string { - if (focused) return `border-2 ${error ? 'border-m3-error' : 'border-m3-primary'}`; - if (error) return 'border border-m3-error'; - return 'border border-m3-outline hover:border-m3-on-surface'; -} - -function resolveLabelColorClass({ focused, error }: { focused: boolean; error: boolean }): string { - if (error) return 'text-m3-error'; - return focused ? 'text-m3-primary' : 'text-m3-secondary'; -} interface M3TextFieldProps extends React.InputHTMLAttributes { label: string; error?: string; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/SearchableSelect.tsx b/src/apps/ums.web-app/src/presentation/shared/components/SearchableSelect.tsx index e4e30cad..881b9055 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/SearchableSelect.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/SearchableSelect.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { ChevronDown, Search, X, Check, AlertCircle, Loader2 } from 'lucide-react'; +import { resolveBorderClass, resolveLabelColorClass } from './field-state'; export type SearchCriteria = 'contains' | 'startsWith' | 'endsWith' | 'equals' | 'notEquals'; @@ -143,19 +144,9 @@ export function SearchableSelect({ const hasError = !!error; const heightClass = compact ? 'h-12' : 'h-14'; - const borderClass = isOpen - ? `border-2 ${hasError ? 'border-m3-error' : 'border-m3-primary'}` - : hasError - ? 'border border-m3-error' - : 'border border-m3-outline hover:border-m3-on-surface'; + const borderClass = resolveBorderClass({ focused: isOpen, error: !!hasError }); - const labelColorClass = isOpen - ? hasError - ? 'text-m3-error' - : 'text-m3-primary' - : hasError - ? 'text-m3-error' - : 'text-m3-secondary'; + const labelColorClass = resolveLabelColorClass({ focused: isOpen, error: !!hasError }); return (
diff --git a/src/apps/ums.web-app/src/presentation/shared/components/field-state.ts b/src/apps/ums.web-app/src/presentation/shared/components/field-state.ts new file mode 100644 index 00000000..2b166726 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/field-state.ts @@ -0,0 +1,23 @@ +/** + * field-state.ts — Cómo se pinta un campo según su foco y su error. + * + * La misma matriz vivía duplicada en M3TextField, M3Select, M3FieldsetWrapper y SearchableSelect, + * cada una como una cascada de ternarios anidados. Aquí la regla se lee de una vez: el foco manda + * en el grosor del borde, el error manda en el color. + */ + +export interface FieldVisualState { + focused: boolean; + error: boolean; +} + +export function resolveBorderClass({ focused, error }: FieldVisualState): string { + if (focused) return `border-2 ${error ? 'border-m3-error' : 'border-m3-primary'}`; + if (error) return 'border border-m3-error'; + return 'border border-m3-outline hover:border-m3-on-surface'; +} + +export function resolveLabelColorClass({ focused, error }: FieldVisualState): string { + if (error) return 'text-m3-error'; + return focused ? 'text-m3-primary' : 'text-m3-secondary'; +} From dc7bbf6832da25024b6ed936ba3ee76fbf1a96a2 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 9 Aug 2026 21:43:33 -0500 Subject: [PATCH 14/35] =?UTF-8?q?refactor(web):=20elimina=20el=20=C3=BAlti?= =?UTF-8?q?mo=20`any`=20del=20web-app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 145 -> 104 errores. `@typescript-eslint/no-explicit-any` queda a cero. - Decoradores (`RequireOption`, `RequirePermission`): `target` no se usa en el cuerpo, así que pasa a `_target: unknown`; los argumentos se reenvían tal cual con `apply`, así que `unknown[]` es exactamente lo que se sabe de ellos. - `catch (err: any)` (5 sitios) pasa a `unknown`, que es lo que un catch debe recibir. El acceso al cuerpo del error va por `asApiError`, nuevo en httpClient: no valida nada —el error viene de la red y puede ser cualquier cosa— sino que da acceso tipado a los sitios donde tiene sentido mirar (`normalised`, `response.data`, `message`). - Mocks y stubs de test (32): selectores de store, variables de mutación, colecciones y stubs de componentes quedan tipados. Un mock parcial sigue siendo parcial, pero deja de apagar el comprobador en todo lo que toque. Validación: tsc sin errores; vite build OK; vitest 1697/1697. Co-Authored-By: Claude Opus 5 --- .../authorization/decorators/RequireAccess.ts | 4 +- .../require-permission.decorator.ts | 4 +- .../hooks/use-access-resolution.test.ts | 2 +- .../identity/hooks/use-branch.test.tsx | 6 +- .../hooks/use-delegation-dashboard.test.ts | 18 ++++-- .../identity/hooks/use-inbox.test.tsx | 6 +- .../services/app-configuration.service.ts | 5 +- .../src/infrastructure/http/httpClient.ts | 16 +++++ .../ProfileRequestsInboxPanel.test.tsx | 4 +- .../tree/ProfileModulePermissionsPanel.tsx | 4 +- .../AppConfigurationDashboardScreen.tsx | 18 ++++-- .../GlobalAppConfigurationDashboardScreen.tsx | 16 +++-- .../components/TenantProfileCard.test.tsx | 16 +++-- .../components/UserSignupsInboxPanel.test.tsx | 4 +- .../shared/components/EmptyState.test.tsx | 6 +- .../shared/components/EntityCard.test.tsx | 2 +- .../shared/layouts/MainLayout.test.tsx | 28 ++++---- .../shared/layouts/TopAppBar.test.tsx | 64 +++++++++++-------- 18 files changed, 140 insertions(+), 83 deletions(-) diff --git a/src/apps/ums.web-app/src/application/authorization/decorators/RequireAccess.ts b/src/apps/ums.web-app/src/application/authorization/decorators/RequireAccess.ts index 8fd96e1a..8d822359 100644 --- a/src/apps/ums.web-app/src/application/authorization/decorators/RequireAccess.ts +++ b/src/apps/ums.web-app/src/application/authorization/decorators/RequireAccess.ts @@ -39,7 +39,7 @@ const checkOptionAccess = (menuCode: string, optionCode: string): boolean => { * del nombre del método (ej. 'createUsuario' -> 'create'). */ export function RequireOption(menuCode: string, optionCode?: string) { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + return function (_target: unknown, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; const inferredOptionCode = optionCode || @@ -48,7 +48,7 @@ export function RequireOption(menuCode: string, optionCode?: string) { .toLowerCase() .split('-')[0]; - descriptor.value = function (...args: any[]) { + descriptor.value = function (this: unknown, ...args: unknown[]) { const hasAccess = checkOptionAccess(menuCode, inferredOptionCode); if (!hasAccess) { diff --git a/src/apps/ums.web-app/src/application/authorization/decorators/require-permission.decorator.ts b/src/apps/ums.web-app/src/application/authorization/decorators/require-permission.decorator.ts index 2bbb11f5..c6866921 100644 --- a/src/apps/ums.web-app/src/application/authorization/decorators/require-permission.decorator.ts +++ b/src/apps/ums.web-app/src/application/authorization/decorators/require-permission.decorator.ts @@ -13,10 +13,10 @@ import { useAuthStore } from '@app/stores/auth.store'; * @param actionCode The code of the action (e.g. 'VIEW', 'MANAGE') */ export function RequirePermission(resourceCode: string, actionCode: string) { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + return function (_target: unknown, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; - descriptor.value = function (...args: any[]) { + descriptor.value = function (this: unknown, ...args: unknown[]) { // In a non-React-component context, we can read the store state directly from Zustand const state = useAuthStore.getState(); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.test.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.test.ts index abfe8793..c434879e 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.test.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.test.ts @@ -36,7 +36,7 @@ describe('useAccessResolution', () => { }; beforeEach(() => { - vi.mocked(useAuthStore).mockImplementation((selector: any) => + vi.mocked(useAuthStore).mockImplementation((selector: (state: never) => unknown) => selector({ user: { authorizationGraph: mockGraph }, }) diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx index 6dd5b383..04606a31 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx @@ -56,11 +56,11 @@ vi.mock('@app/i18n/use-i18n', () => ({ })); vi.mock('@app/hooks/use-notified-mutation', () => ({ - useNotifiedMutation: (config: any) => { + useNotifiedMutation: (config: { mutationFn: (vars: never) => unknown }) => { const mutationFn = config.mutationFn; return { - mutate: vi.fn((vars: any) => mutationFn(vars)), - mutateAsync: vi.fn(async (vars: any) => mutationFn(vars)), + mutate: vi.fn((vars: never) => mutationFn(vars)), + mutateAsync: vi.fn(async (vars: never) => mutationFn(vars)), isPending: false, isSuccess: true, isError: false, diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.test.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.test.ts index 23bc492e..6cff72f4 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.test.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.test.ts @@ -322,8 +322,11 @@ describe('useDelegationDashboard', () => { handleResetQuery: vi.fn(), } as unknown as ReturnType); - vi.mocked(useLocalOverridesModule.useLocalOverrides).mockImplementation((items: any) => ({ - items: items?.filter((d: any) => d.delegationId.includes('d-1')) ?? [], + vi.mocked(useLocalOverridesModule.useLocalOverrides).mockImplementation((items: never[]) => ({ + items: + items?.filter((d: { delegationId: string; status: string }) => + d.delegationId.includes('d-1') + ) ?? [], patchItem: vi.fn(), patchItems: vi.fn(), clearOverrides: vi.fn(), @@ -355,8 +358,9 @@ describe('useDelegationDashboard', () => { handleResetQuery: vi.fn(), } as unknown as ReturnType); - vi.mocked(useLocalOverridesModule.useLocalOverrides).mockImplementation((items: any) => ({ - items: items?.filter((d: any) => d.status === 'Active') ?? [], + vi.mocked(useLocalOverridesModule.useLocalOverrides).mockImplementation((items: never[]) => ({ + items: + items?.filter((d: { delegationId: string; status: string }) => d.status === 'Active') ?? [], patchItem: vi.fn(), patchItems: vi.fn(), clearOverrides: vi.fn(), @@ -367,7 +371,11 @@ describe('useDelegationDashboard', () => { })); const { result } = renderHook(() => useDelegationDashboard()); - expect(result.current.knownDelegations.every((d: any) => d.status === 'Active')).toBe(true); + expect( + result.current.knownDelegations.every( + (d: { delegationId: string; status: string }) => d.status === 'Active' + ) + ).toBe(true); }); it('returns totalItems based on known delegations length', () => { diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-inbox.test.tsx b/src/apps/ums.web-app/src/application/identity/hooks/use-inbox.test.tsx index cc5031eb..7156a113 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-inbox.test.tsx +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-inbox.test.tsx @@ -32,9 +32,9 @@ vi.mock('@infra/identity/services/inbox.service', () => ({ })); vi.mock('@app/hooks/use-notified-mutation', () => ({ - useNotifiedMutation: (config: any) => ({ - mutate: vi.fn((vars: any) => config.mutationFn(vars)), - mutateAsync: vi.fn((vars: any) => config.mutationFn(vars)), + useNotifiedMutation: (config: { mutationFn: (vars: never) => unknown }) => ({ + mutate: vi.fn((vars: never) => config.mutationFn(vars)), + mutateAsync: vi.fn((vars: never) => config.mutationFn(vars)), isPending: false, isSuccess: false, isError: false, diff --git a/src/apps/ums.web-app/src/infrastructure/configuration/services/app-configuration.service.ts b/src/apps/ums.web-app/src/infrastructure/configuration/services/app-configuration.service.ts index 854e8ee1..f5f30e1c 100644 --- a/src/apps/ums.web-app/src/infrastructure/configuration/services/app-configuration.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/configuration/services/app-configuration.service.ts @@ -96,8 +96,9 @@ export const appConfigurationService = { const { data } = await httpClient.post('/app-configurations', payload); logger.info('CreateAppConfiguration success', data); return CreateAppConfigurationResponseSchema.parse(data); - } catch (error: any) { - logger.error('CreateAppConfiguration failed', { payload, error: error.response?.data }); + } catch (error: unknown) { + const apiError = asApiError(error); + logger.error('CreateAppConfiguration failed', { payload, error: apiError.response?.data }); throw error; } }, diff --git a/src/apps/ums.web-app/src/infrastructure/http/httpClient.ts b/src/apps/ums.web-app/src/infrastructure/http/httpClient.ts index 95163b8a..77cb57a0 100644 --- a/src/apps/ums.web-app/src/infrastructure/http/httpClient.ts +++ b/src/apps/ums.web-app/src/infrastructure/http/httpClient.ts @@ -157,6 +157,22 @@ function extractErrorMessage(data: unknown): string { return JSON.stringify(data).slice(0, 200); } +/** + * Forma del error que sale del interceptor. No es una promesa de que el objeto la cumpla —viene + * de la red y puede ser cualquier cosa—, sino la lista de sitios donde tiene sentido mirar. + * Por eso todas las propiedades son opcionales y `asApiError` no valida: solo da acceso tipado + * a un `unknown`, que es justo lo que un `catch` debe recibir. + */ +export interface NormalisedApiError { + normalised?: { message: string; status: number; code: string }; + response?: { status?: number; data?: { detail?: string; message?: string; title?: string } }; + message?: string; +} + +export function asApiError(error: unknown): NormalisedApiError { + return (error ?? {}) as NormalisedApiError; +} + export const httpClient = createHttpClient(); export function invalidateAndRetry( diff --git a/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileRequestsInboxPanel.test.tsx b/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileRequestsInboxPanel.test.tsx index cb4315fc..809c78cd 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileRequestsInboxPanel.test.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/profile/components/ProfileRequestsInboxPanel.test.tsx @@ -40,7 +40,9 @@ vi.mock('@shared/components/ListToolbar', () => ({ })); vi.mock('@shared/components/data-display/DataList', () => ({ - DataList: ({ renderThumbnail }: any) =>
{renderThumbnail()}
, + DataList: ({ renderThumbnail }: Record) => ( +
{renderThumbnail()}
+ ), })); describe('ProfileRequestsInboxPanel', () => { diff --git a/src/apps/ums.web-app/src/presentation/authorization/profile/components/tree/ProfileModulePermissionsPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/profile/components/tree/ProfileModulePermissionsPanel.tsx index 34504179..87b7df6e 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/profile/components/tree/ProfileModulePermissionsPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/profile/components/tree/ProfileModulePermissionsPanel.tsx @@ -26,7 +26,9 @@ export type ProfilePermItem = { isActive: boolean; isOverride?: boolean; permissionId?: string; - originalFromTemplate?: any; + /** Snapshot del ítem tal como venía de la plantilla, para detectar overrides. Opaco aquí: + * esta vista solo comprueba si existe, no lee su contenido. */ + originalFromTemplate?: unknown; }; export type ModulePermNode = { diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/AppConfigurationDashboardScreen.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/AppConfigurationDashboardScreen.tsx index 8df1f883..8dd565e0 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/AppConfigurationDashboardScreen.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/AppConfigurationDashboardScreen.tsx @@ -21,6 +21,7 @@ import { M3Dialog } from '@shared/components/M3Dialog'; import { useNotificationStore } from '@app/stores/notification.store'; import { useI18n } from '@app/i18n/use-i18n'; import type { ParameterDefinition } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; +import { asApiError } from '@infra/http/httpClient'; export default function AppConfigurationDashboardScreen(): React.JSX.Element { const t = useI18n(); @@ -61,13 +62,14 @@ export default function AppConfigurationDashboardScreen(): React.JSX.Element { d.setSelectedId(results[0].appConfigurationId); } setIsPickerOpen(false); - } catch (err: any) { + } catch (err: unknown) { + const apiError = asApiError(err); const errorMsg = - err?.normalised?.message || - err?.response?.data?.detail || - err?.message || + apiError?.normalised?.message || + apiError?.response?.data?.detail || + apiError?.message || t.failedToLinkParameter; - console.error('Create config error:', err?.response?.data); + console.error('Create config error:', apiError?.response?.data); addNotification({ title: t.error ?? 'Error', message: errorMsg, @@ -95,8 +97,10 @@ export default function AppConfigurationDashboardScreen(): React.JSX.Element { if (d.selectedId === pendingDeleteId) { d.setSelectedId(''); } - } catch (err: any) { - const errorMsg = err?.normalised?.message || err?.response?.data?.detail || t.deleteFailed; + } catch (err: unknown) { + const apiError = asApiError(err); + const errorMsg = + apiError?.normalised?.message || apiError?.response?.data?.detail || t.deleteFailed; addNotification({ title: t.error ?? 'Error', message: errorMsg, diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx index 057c3ded..619fd07c 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx @@ -20,6 +20,7 @@ import { useNotificationStore } from '@app/stores/notification.store'; import { useI18n } from '@app/i18n/use-i18n'; import { useAuthStore } from '@app/stores/auth.store'; import type { ParameterDefinition } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; +import { asApiError } from '@infra/http/httpClient'; export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Element { const t = useI18n(); @@ -70,11 +71,12 @@ export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Eleme d.setSelectedId(results[0].appConfigurationId); } setIsPickerOpen(false); - } catch (err: any) { + } catch (err: unknown) { + const apiError = asApiError(err); const errorMsg = - err?.normalised?.message || - err?.response?.data?.detail || - err?.message || + apiError?.normalised?.message || + apiError?.response?.data?.detail || + apiError?.message || t.failedToLinkParameter; addNotification({ title: t.error ?? 'Error', @@ -103,8 +105,10 @@ export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Eleme if (d.selectedId === pendingDeleteId) { d.setSelectedId(''); } - } catch (err: any) { - const errorMsg = err?.normalised?.message || err?.response?.data?.detail || t.deleteFailed; + } catch (err: unknown) { + const apiError = asApiError(err); + const errorMsg = + apiError?.normalised?.message || apiError?.response?.data?.detail || t.deleteFailed; addNotification({ title: t.error ?? 'Error', message: errorMsg, diff --git a/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantProfileCard.test.tsx b/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantProfileCard.test.tsx index eabe082c..fe944a5c 100644 --- a/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantProfileCard.test.tsx +++ b/src/apps/ums.web-app/src/presentation/identity/tenant/components/TenantProfileCard.test.tsx @@ -67,19 +67,23 @@ vi.mock('@app/hooks/use-reset-on-change', () => ({ })); vi.mock('@app/stores/notification.store', () => ({ - useNotificationStore: (selector: any) => selector({ addNotification }), + useNotificationStore: (selector: (state: never) => unknown) => selector({ addNotification }), })); vi.mock('@shared/components/M3Card', () => ({ - M3Card: ({ children, ...props }: any) =>
{children}
, + M3Card: ({ children, ...props }: React.PropsWithChildren>) => ( +
{children}
+ ), })); vi.mock('@shared/components/M3Button', () => ({ - M3Button: ({ children, ...props }: any) => , + M3Button: ({ children, ...props }: React.PropsWithChildren>) => ( + + ), })); vi.mock('@shared/components/M3TextField', () => ({ - M3TextField: ({ label, value }: any) => ( + M3TextField: ({ label, value }: Record) => (