using System; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FrameworkBLL.KeyCloak; using FrameworkDAL.DTO.KeyCloak; using GB5Shared.ActionProcessor; using GB5Shared.Connection; using Microsoft.Extensions.Logging; namespace FrameworkBLL.ActionProcessor.Handlers { /// /// ActionType = 6 (Keycloak user sync). MACTION-configured for EVENTTYPEID = SAVEUSEREVENTTYPEID /// (-1799999982) — fires whenever UserBLL.SaveUser completes (create or update) for a tenant. /// /// Keycloak is the sole credential authority for AUTHMODE=1 tenants — this handler creates the /// Keycloak identity (username/email) but deliberately never sets a password (KeyCloakDTO.PassWord /// left unset): GB5's own stored password is encrypted for GB5's own digest-auth scheme, not a /// usable Keycloak credential, and syncing it would undercut the whole point of separating /// credential authority. A real password is set once, out-of-band, via Keycloak's own /// reset-password/invite flow — not by this sync. /// /// AUTHMODE=Native tenants (or any tenant with no MSERVERCONFIG row at all) are a permanent, /// legitimate no-op here — Keycloak is optional per tenant/deployment, not assumed. /// public sealed class KeycloakSyncActionHandler : IActionHandler { public int ActionType => 6; private readonly IKeyCloakBLL _keyCloakBLL; private readonly IApplicationConnection _connection; private readonly ILogger _logger; public KeycloakSyncActionHandler( IKeyCloakBLL keyCloakBLL, IApplicationConnection connection, ILogger logger) { _keyCloakBLL = keyCloakBLL ?? throw new ArgumentNullException(nameof(keyCloakBLL)); _connection = connection ?? throw new ArgumentNullException(nameof(connection)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task HandleAsync(ActionEventDto dto, CancellationToken ct = default) { if (dto == null) return ActionResult.Permanent("ActionEventDto cannot be null"); try { var authConfig = await _connection.AuthConfigCached(dto.ConnectionName).ConfigureAwait(false); if (!authConfig.IsKeycloak) { _logger.LogInformation( "KeycloakSync: tenant {ConnectionName} is AuthMode=Native — skipping | ActionRunId={ActionRunId}", dto.ConnectionName, dto.ActionRunId); return ActionResult.Ok("Skipped — tenant is not Keycloak-enabled (AuthMode=Native)"); } if (string.IsNullOrWhiteSpace(authConfig.KeycloakRealm)) { _logger.LogWarning( "KeycloakSync: tenant {ConnectionName} has AuthMode=Keycloak but no KeycloakRealm configured — skipping | ActionRunId={ActionRunId}", dto.ConnectionName, dto.ActionRunId); return ActionResult.Permanent("AuthMode=Keycloak but KeycloakRealm is not configured"); } // dto.Payload is NOT the raw UserDTO directly — EventSubBLL wraps it as // {EventTypeId, ObjectTypeId, ObjectId, EntityPayload} where EntityPayload is // itself the entity's JSON serialized as a STRING (message.Payload, a string // property on OutboxEventMessage) — so it needs a second JsonDocument.Parse. if (dto.Payload.ValueKind != JsonValueKind.Object || !dto.Payload.TryGetProperty("EntityPayload", out var entityPayloadEl) || entityPayloadEl.ValueKind != JsonValueKind.String) { return ActionResult.Permanent("Payload missing EntityPayload"); } using var entityDoc = JsonDocument.Parse(entityPayloadEl.GetString()!); var entity = entityDoc.RootElement; string? userCode = GetString(entity, "UserCode"); if (string.IsNullOrWhiteSpace(userCode)) return ActionResult.Permanent("EntityPayload missing UserCode"); var keyCloakDTO = new KeyCloakDTO { RealmName = authConfig.KeycloakRealm, UserName = userCode, FirstName = GetString(entity, "UserName") ?? userCode, LastName = string.Empty, EMail = GetString(entity, "UserPrimaryMail") ?? string.Empty // PassWord deliberately left unset — see class remarks. }; string result = await _keyCloakBLL.CreateUser(keyCloakDTO).ConfigureAwait(false); if (result.StartsWith("User created", StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation( "KeycloakSync: synced | UserCode={UserCode} Realm={Realm} Result={Result}", userCode, authConfig.KeycloakRealm, result); return ActionResult.Ok(result); } if (result.StartsWith("Invalid request", StringComparison.OrdinalIgnoreCase)) { // Bad input on our side (e.g. missing RealmName/UserName) — retrying won't help. _logger.LogWarning( "KeycloakSync: permanent failure | UserCode={UserCode} Realm={Realm} Result={Result}", userCode, authConfig.KeycloakRealm, result); return ActionResult.Permanent(result); } // "Error creating user: ...", "Error in CreateUser: ...", or the "not indexed yet" // eventual-consistency message — all worth a retry. _logger.LogWarning( "KeycloakSync: transient failure | UserCode={UserCode} Realm={Realm} Result={Result}", userCode, authConfig.KeycloakRealm, result); return ActionResult.Transient(result); } catch (Exception ex) { _logger.LogError(ex, "KeycloakSync: unexpected exception | TenantId={TenantId} ActionRunId={ActionRunId}", dto.TenantId, dto.ActionRunId); return ActionResult.Transient(ex.Message); } } private static string? GetString(JsonElement obj, string propertyName) => obj.TryGetProperty(propertyName, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; } }