User synchronization revamped.

This commit is contained in:
2025-07-04 23:33:33 +02:00
parent 4393977389
commit c775e3a25e
14 changed files with 348 additions and 139 deletions

View File

@ -68,12 +68,12 @@ Additional tunables, with their sustainable default values:
Manage users and credentials in DB manually.
_Alternatively_, mount a config to the container's root dir with name of `users.conf` with structure that contains `username;id;apikey;comma_separated_allowed_recipients_if_any` per line:
_Alternatively_, mount a config to the container's root dir with name of `users.conf` with structure that contains `username;is_enabled;id;apikey;comma_separated_allowed_recipients_if_any` per line:
user1;90ddab90-0b73-4c6c-8dcb-2d8d1ec3c0b8;81ccf737-d424-4f83-929c-92d20491abfa;user2,user3,user4
user2;8f5971c3-5e19-4b5c-88a7-e0ec4856ce44;f480568f-8884-47e5-a6d7-82480f1ffb3b;user1
user3;f253a157-f336-4029-b90e-80a9f64b453b;46b882b7-4b96-4fa2-ba1b-4955a9500c36
user4;5f20ec92-3168-4df5-b20d-5441d08b3f9a;51d11e51-efb2-43e9-beb8-52fb8e879bee;user2
user1;true;90ddab90-0b73-4c6c-8dcb-2d8d1ec3c0b8;81ccf737-d424-4f83-929c-92d20491abfa;user2,user3,user4
user2;true;8f5971c3-5e19-4b5c-88a7-e0ec4856ce44;f480568f-8884-47e5-a6d7-82480f1ffb3b;user1
user3;true;f253a157-f336-4029-b90e-80a9f64b453b;46b882b7-4b96-4fa2-ba1b-4955a9500c36
user4;true;5f20ec92-3168-4df5-b20d-5441d08b3f9a;51d11e51-efb2-43e9-beb8-52fb8e879bee;user2
Upon launch, Messenger will synchronize contents of the file with the database. Synchronization uses `id` as primary identifier to make it easy to rotate API keys and change names. Synchronization is done `users.conf => db` and treats the config file as single source of truth, meaning data present in the file but not in db will be added to db, and data not present in file, but present in db will be deleted from db. Editing the file and restarting the service will then update the data accordingly.

View File

@ -53,16 +53,22 @@ namespace MessengerApi.Configuration.Model
/// </summary>
public HousekeepingMessageStates HousekeepingMessageState { get; set; }
/// <summary>
/// File containing all user credentials.
/// </summary>
public FileInfo UsersConfig { get; set; }
public MessengerConfiguration() { }
public MessengerConfiguration(string[] origins, PersistenceConfiguration persistenceConfiguration)
{
if(persistenceConfiguration == null)
if (persistenceConfiguration == null)
{
throw new ArgumentNullException(nameof(persistenceConfiguration));
}
this.PersistenceConfiguration = persistenceConfiguration;
this.UsersConfig = new FileInfo("./users.conf");
this.Origins = origins ?? [];
this.Proxies = [];
this.RateLimitPerMinute = 120;
@ -84,6 +90,7 @@ namespace MessengerApi.Configuration.Model
Populate<int>(config, Env.HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES, x => this.HousekeepingMessageAgeInMinutes = x);
Populate<string>(config, Env.HOUSEKEEPING_MESSAGE_STATE, x => this.HousekeepingMessageState = HousekeepingMessageStateParser.Parse(x));
Populate<string>(config, Env.LOGGING_VERBOSITY, x => this.Verbosity = LoggingVerbosityParser.Parse(x));
Populate<string>(config, Env.USERSCONFIG_FILE_PATH, x => this.UsersConfig = new FileInfo(x));
void Populate<T>(IEnvironmentConfigurationSource config, string key, Action<T> set)
{

View File

@ -15,6 +15,7 @@
public const string HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES = nameof(HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES);
public const string HOUSEKEEPING_MESSAGE_STATE = nameof(HOUSEKEEPING_MESSAGE_STATE);
public const string LOGGING_VERBOSITY = nameof(LOGGING_VERBOSITY);
public const string USERSCONFIG_FILE_PATH = nameof(USERSCONFIG_FILE_PATH);
}
}
}

View File

@ -3,12 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.11.35312.102
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Contracts", "MessengerApi.Contracts\MessengerApi.Contracts.csproj", "{833ED77F-A4E9-4FB3-BB84-4E55898B726A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.SubscriptionClient", "MessengerApi.SubscriptionClient\MessengerApi.SubscriptionClient.csproj", "{127D24B0-47F3-40E9-9136-899AFF206F19}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.QueryClient", "MessengerApi.QueryClient\MessengerApi.QueryClient.csproj", "{6441673B-2621-4E2C-A9A0-971E83C3F80A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{60B75400-A315-4B57-AFCF-5B4094785A62}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.PhonebookClient", "MessengerApi.Examples.PhonebookClient\MessengerApi.Examples.PhonebookClient.csproj", "{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}"
@ -36,6 +30,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
..\Directory.Packages.props = ..\Directory.Packages.props
..\docker-compose.yml = ..\docker-compose.yml
..\Dockerfile = ..\Dockerfile
..\assets\example-users.config = ..\assets\example-users.config
..\NuGet.config = ..\NuGet.config
EndProjectSection
EndProject
@ -63,18 +58,6 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Release|Any CPU.Build.0 = Release|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Debug|Any CPU.Build.0 = Debug|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Release|Any CPU.ActiveCfg = Release|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Release|Any CPU.Build.0 = Release|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Release|Any CPU.Build.0 = Release|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@ -0,0 +1,263 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Contracts.Factories;
using MessengerApi.Db.Entities;
using MessengerApi.Models;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace MessengerApi.Handlers
{
public class UserConfigHandler
{
private readonly MessengerConfiguration configuration;
private readonly ILogger logger;
private readonly IDbContextFactory dbContextFactory;
public UserConfigHandler(
MessengerConfiguration configuration,
ILogger logger,
IDbContextFactory dbContextFactory)
{
this.configuration = configuration;
this.logger = logger;
this.dbContextFactory = dbContextFactory;
}
public async Task<UserIngestionItem[]> GetItemsFromFile(FileInfo file)
{
if (file == null)
{
throw new InvalidOperationException("No file provided.");
}
else if (File.Exists(file.FullName) == false)
{
throw new FileNotFoundException($"Userfile doesn't exist: {this.configuration.UsersConfig.FullName}.");
}
var lines = await File.ReadAllLinesAsync(file.FullName, Encoding.UTF8);
var collection = new List<Tuple<UserIngestionItem, string>>();
foreach (var line in lines)
{
var columns = line.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var item = new UserIngestionItem();
item.UserName = columns[0];
item.IsEnabled = bool.Parse(columns[1]);
item.Id = Guid.Parse(columns[2]);
item.ApiKey = Guid.Parse(columns[3]);
var recipients = columns.Length == 5
? columns[4]
: null;
collection.Add(new Tuple<UserIngestionItem, string>(item, recipients));
this.logger.Trace($"Reading user {item.UserName} with ID {item.Id}");
}
foreach (var item in collection)
{
if(item.Item2 == null)
{
item.Item1.CanSendTo = [];
continue;
}
var names = item.Item2.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
item.Item1.CanSendTo = names
.Select(x => collection.Single(c => c.Item1.UserName == x).Item1)
.ToArray();
this.logger.Trace($"User {item.Item1.UserName} has buddies {item.Item2}.");
}
return collection.Select(x => x.Item1).ToArray();
}
public Task<UserIngestionOperation[]> GetOperationsFromItems(UserIngestionItem[] fileItems)
{
if(fileItems == null)
{
throw new ArgumentNullException(nameof(fileItems));
}
using var context = this.dbContextFactory.CreateDbContext();
var existingUsers = context.Users.ToList();
var existingRoutes = context.UserRoutes.Include(x => x.From).Include(x => x.To).ToList();
var collection = new List<UserIngestionOperation>();
foreach (var item in fileItems)
{
var dbItem = context.Users.SingleOrDefault(x => x.Id == item.Id);
if (dbItem == null)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Create,
Subtype = UserIngestionOperationSubtypes.AddUser,
Username = item.UserName,
Operation = (c) =>
{
c.Users.Local.Add(new User
{
Id = item.Id,
ApiKey = item.ApiKey,
IsEnabled = item.IsEnabled,
Name = item.UserName,
});
}
});
}
else
{
if(dbItem.ApiKey != item.ApiKey)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Update,
Subtype = UserIngestionOperationSubtypes.ChangeUserApiKey,
Username = item.UserName,
Operation = (c) =>
{
c.Users.Single(x => x.Id == item.ApiKey).ApiKey = item.ApiKey;
}
});
}
if(dbItem.IsEnabled != item.IsEnabled)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Update,
Subtype = UserIngestionOperationSubtypes.ChangeUserIsEnabled,
Username = item.UserName,
Operation = (c) =>
{
c.Users.Single(x => x.Id == item.ApiKey).IsEnabled = item.IsEnabled;
}
});
}
if (dbItem.Name != item.UserName)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Update,
Subtype = UserIngestionOperationSubtypes.ChangeUserName,
Username = item.UserName,
Operation = (c) =>
{
c.Users.Single(x => x.Id == item.ApiKey).Name = item.UserName;
}
});
}
}
this.logger.Trace($"Processed line with {item.UserName}.");
}
this.logger.Debug("Finished evaluating add/update users, doing routes.");
foreach(var item in fileItems)
{
foreach(var com in item.CanSendTo)
{
this.logger.Trace($"Validating if {item.UserName} can send to {com.UserName}.");
var dbRoute = context.UserRoutes
.Include(x => x.From)
.Include(x => x.To)
.SingleOrDefault(x => x.From.Id == item.Id && x.To.Id == com.Id);
if(dbRoute == null)
{
collection.Add(new UserIngestionOperation
{
Type = UserIngestionOperationTypes.Create,
Subtype = UserIngestionOperationSubtypes.AddUserRoute,
Username = item.UserName,
Operation = (c) =>
{
var from = c.Users
.SingleOrDefault(x => x.Id == item.Id) ??
c.Users.Local.Single(x => x.Id == item.Id);
var to = c.Users
.SingleOrDefault(x => x.Id == com.Id) ??
c.Users.Local.Single(x => x.Id == com.Id);
c.Add(new UserRoute
{
From = from,
To = to
});
}
});
}
}
}
this.logger.Debug("Finished evaluating add userroutes, doing user deletes.");
foreach(var existingDbUser in existingUsers)
{
if(fileItems.Any(x=>x.Id == existingDbUser.Id) == false)
{
collection.Add(new UserIngestionOperation
{
Username = existingDbUser.Name,
Type = UserIngestionOperationTypes.Delete,
Subtype = UserIngestionOperationSubtypes.RemoveUser,
Operation = (c) =>
{
c.Remove(c.Users.Single(x => x.Id == existingDbUser.Id));
}
});
}
}
foreach (var existingRoute in existingRoutes)
{
if (fileItems.Any(fi => fi.CanSendTo.Any(fic => fi.Id == existingRoute.From.Id && fic.Id == existingRoute.To.Id)) == false)
{
collection.Add(new UserIngestionOperation
{
Username = existingRoute.From.Name,
Type = UserIngestionOperationTypes.Delete,
Subtype = UserIngestionOperationSubtypes.RemoveUserRoute,
Operation = (c) =>
{
c.Remove(c.UserRoutes.Single(x => x.Id == existingRoute.Id));
}
});
}
}
return Task.FromResult(collection.ToArray());
}
public async Task UpdateFromFile(FileInfo file)
{
var fileItems = await this.GetItemsFromFile(file);
var operations = await this.GetOperationsFromItems(fileItems);
using var context = this.dbContextFactory.CreateDbContext();
foreach(var operation in operations)
{
this.logger.Info(operation.ToString());
operation.Operation(context);
}
if(operations.Any())
{
await context.SaveChangesAsync();
}
}
}
}

View File

@ -1,99 +0,0 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Contracts.Factories;
using MessengerApi.Db;
using MessengerApi.Models;
using System.Text;
namespace MessengerApi.Handlers
{
// TODO: This needs to be redone, because at every run, it wipes users and creates new ones. This makes
// all existing DB messages unassignable.
public class UserSetupHandler
{
private readonly MessengerConfiguration configuration;
private readonly ILogger logger;
private readonly IDbContextFactory dbContextFactory;
public UserSetupHandler(
MessengerConfiguration configuration,
ILogger logger,
IDbContextFactory dbContextFactory)
{
this.configuration = configuration;
this.logger = logger;
this.dbContextFactory = dbContextFactory;
}
public async Task UpdateFromFile(FileInfo file)
{
if(file.Exists)
{
var lines = await File.ReadAllLinesAsync(file.FullName, Encoding.UTF8);
var items = await this.ReadLines(lines);
await this.SynchronizeUsers(items);
}
}
private async Task<UserSetupItem[]> ReadLines(string[] lines)
{
var items = new List<UserSetupItem>();
foreach (var line in lines)
{
var values = line.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var item = new UserSetupItem
{
UserName = values[0],
ApiKey = values[1],
};
if(values.Length > 2)
{
item.CanSendToUserNames = values[2].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
items.Add(item);
}
if (items.GroupBy(x => x.UserName).Any(x => x.Count() > 1))
{
throw new InvalidOperationException("Usernames are not unique. One username per line.");
}
else if(items.GroupBy(x=>x.ApiKey).Any(x=>x.Count() > 1))
{
throw new InvalidOperationException("API keys are not unique. One API key per line.");
}
return items.ToArray();
}
private Task SynchronizeUsers(IEnumerable<UserSetupItem> users)
{
using var db = this.dbContextFactory.CreateDbContext();
db.RemoveRange(db.Users);
db.RemoveRange(db.UserRoutes);
var dbUsers = users.Select(x => new Db.Entities.User
{
Id = new Guid(),
Name = x.UserName,
ApiKey = Guid.Parse(x.ApiKey),
IsEnabled = true
});
var dbRoutes = users.SelectMany(x => x.CanSendToUserNames.Select(cs => new Db.Entities.UserRoute
{
Id = new Guid(),
From = dbUsers.Single(dbu => dbu.Name == x.UserName),
To = dbUsers.Single(dbu => dbu.Name == x.UserName)
}));
db.AddRange(dbUsers);
db.AddRange(dbRoutes);
db.SaveChanges();
return Task.CompletedTask;
}
}
}

View File

@ -21,7 +21,6 @@
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Configuration\MessengerApi.Configuration.csproj" />
<ProjectReference Include="..\MessengerApi.Contracts\MessengerApi.Contracts.csproj" />
<ProjectReference Include="..\MessengerApi.Db.Npg\MessengerApi.Db.Npg.csproj" />
<ProjectReference Include="..\MessengerApi.Db.Sql\MessengerApi.Db.Sql.csproj" />
<ProjectReference Include="..\MessengerApi.Db\MessengerApi.Db.csproj" />

View File

@ -0,0 +1,15 @@
namespace MessengerApi.Models
{
public class UserIngestionItem
{
public string UserName { get; set; }
public Guid Id { get; set; }
public Guid ApiKey { get; set; }
public bool IsEnabled { get; set; }
public UserIngestionItem[] CanSendTo { get; set; }
}
}

View File

@ -0,0 +1,21 @@
using MessengerApi.Db;
namespace MessengerApi.Models
{
public class UserIngestionOperation
{
public UserIngestionOperationTypes Type { get; set; }
public UserIngestionOperationSubtypes Subtype { get; set; }
public string Username { get; set; }
public Action<MessengerDbContext> Operation { get; set; }
public override string ToString()
{
var value = $"Operation: {this.Type}/{this.Subtype} for {this.Username}.";
return value;
}
}
}

View File

@ -0,0 +1,14 @@
namespace MessengerApi.Models
{
public enum UserIngestionOperationSubtypes
{
Unknown,
ChangeUserName,
ChangeUserApiKey,
ChangeUserIsEnabled,
AddUser,
AddUserRoute,
RemoveUser,
RemoveUserRoute
}
}

View File

@ -0,0 +1,10 @@
namespace MessengerApi.Models
{
public enum UserIngestionOperationTypes
{
Unknown,
Create,
Update,
Delete
}
}

View File

@ -1,11 +0,0 @@
namespace MessengerApi.Models
{
public class UserSetupItem
{
public string UserName { get; set; }
public string ApiKey { get; set; }
public string[] CanSendToUserNames { get; set; }
}
}

View File

@ -14,6 +14,7 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Runtime.CompilerServices;
using System.Threading.RateLimiting;
namespace MessengerApi.Api
@ -43,7 +44,7 @@ namespace MessengerApi.Api
builder.Services.AddSingleton<ILogger>(new Factories.LoggerFactory(configuration).CreateLogger());
builder.Services.AddSingleton<SendEndpointHandler>();
builder.Services.AddSingleton<HousekeepingHandler>();
builder.Services.AddSingleton<UserSetupHandler>();
builder.Services.AddSingleton<UserConfigHandler>();
builder.Services.AddSingleton<IDbContextFactory, DbContextFactory>();
builder.Services.AddScoped<Timing>();
@ -142,8 +143,12 @@ namespace MessengerApi.Api
}
// User synchronization
var userSetupHandler = app.Services.GetRequiredService<UserSetupHandler>();
userSetupHandler.UpdateFromFile(new FileInfo(Constants.USERFILE_FILENAME)).GetAwaiter().GetResult();
if (configuration.UsersConfig != null)
{
app.Services.GetRequiredService<ILogger>().Info($"Running userconfig synchronization for {configuration.UsersConfig.FullName}.");
var handler = app.Services.GetRequiredService<UserConfigHandler>();
handler.UpdateFromFile(configuration.UsersConfig).GetAwaiter().GetResult();
}
app.UseStaticFiles();
app.UseRouting();

View File

@ -5,6 +5,7 @@
"launchBrowser": true,
"environmentVariables": {
"PERSISTENCE_TYPE": "Sql",
"USERSCONFIG_FILE_PATH": "./../../assets/example-users.config",
"CORS_ORIGINS": "",
"PROXIES": "",
"QUERY_RATE_PER_MINUTE": "100",