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;
}
}