KNM.Core 1.8.2
KNM.Core
KNM.Core is a shared NuGet infrastructure library for KoNiMa enterprise .NET applications. It eliminates code duplication across projects by providing battle-tested, configurable core services.
Features
| Feature | Description |
|---|---|
| Email Sending | SMTP email with 4 responsive HTML templates + plain-text fallback, configurable retry policy |
| In-Memory Cache | Prefixed cache keys, absolute + sliding expiration, tenant-aware key isolation |
| Structured Logging | Database, File, Application Insights, and Serilog providers with automatic log cleanup |
| Template Rendering | $$PLACEHOLDER$$ substitution engine for HTML/text templates |
| Result Pattern | Result<T> for error handling without exceptions |
| Extension Methods | String, Number, Date, Enum helpers |
| Progress Tracker | Generic progress reporting with subscription support for long-running operations |
| Event Bus | In-memory publish/subscribe event bus, thread-safe, singleton-scoped |
| Correlation ID | Automatic request correlation across middleware, logging, and outgoing HTTP calls |
| Multi-Tenancy | Header-based tenant resolution with cache isolation and log segregation |
| Health Checks | SMTP, Redis, and file storage health checks |
| Exception Handler | Global exception middleware returning standardized JSON error responses |
| License Validator | Offline (RSA+AES), online (API), and hybrid license validation with feature query API |
| Directory (LDAP/AD) | Cross-platform LDAP/Active Directory user lookup with auto-discovery, multi-tenant aware, opt-in |
| Zip Builder | Fluent utility for in-memory and file-based ZIP creation with path-traversal guards and atomic writes |
Installation
dotnet add package KNM.Core
Runtime Configuration Updates (IOptionsMonitor<T>)
Every option class configured through the fluent AddKnmCore() builder — EmailOptions, CacheOptions, LoggerOptions, FileStorageOptions, CorrelationOptions, MultiTenantOptions, ExceptionHandlerOptions, DirectoryOptions, LdapDirectoryOptions, GraphDirectoryOptions, LicenseValidatorOptions, and RetryPolicyOptions — is registered through the standard .NET options pattern. This means all three resolution shapes are available in the DI container:
IOptions<T>— single frozen snapshotIOptionsSnapshot<T>— fresh snapshot per scopeIOptionsMonitor<T>— live reference whoseCurrentValuereflects every subsequentservices.Configure<T>(...)call made by the host application
All internal services consume IOptionsMonitor<T>.CurrentValue at use time, so runtime configuration updates — typically SMTP credentials rotated by an admin UI, or a Redis connection string refreshed from a secret store — take effect on the next operation without restarting the process. Derived state (the EmailSenderService sliding-window rate limiter) is rebuilt automatically through IOptionsMonitor<T>.OnChange.
// Host-side runtime update — takes effect on the next SendAsync call
services.Configure<EmailOptions>(opts =>
{
opts.SmtpHost = "smtp.new-provider.com";
opts.SmtpPort = 587;
opts.SmtpCredentialProvider = () => secretStore.ResolveAsync("smtp/creds");
});
Migration note: direct injection of option classes (e.g.
sp.GetRequiredService<EmailOptions>()) is no longer supported. Replace withIOptionsMonitor<EmailOptions>and read.CurrentValueat use time. The fluentAddKnmCore().WithEmail(...)builder path is unchanged.
Quick Start
// Program.cs — service registration
builder.Services.AddKnmCore()
.WithCache(cache => {
cache.Prefix = "MYAPP_";
cache.DurationInMinutes = 60;
})
.WithEmail(email => {
email.SmtpHost = "smtp.example.com";
email.SmtpPort = 587;
email.SmtpCredentialProvider = async () =>
new NetworkCredential(
await vault.GetSecretAsync("smtp-user"),
await vault.GetSecretAsync("smtp-password"));
email.SmtpDisplayName = "My App";
email.SmtpDisplayEmail = "noreply@example.com";
email.EnableSsl = true;
email.MaxSendRetries = 3;
})
.WithLogger(logger => {
logger.Level = KnmLogLevel.Info;
logger.Provider = KnmLogProvider.File;
logger.FilePath = "logs/app.log";
})
.WithCorrelation(c => c.HeaderName = "X-Request-Id")
.WithMultiTenancy(t => t.HeaderName = "X-Tenant-Id")
.WithHealthChecks()
.WithExceptionHandler(e => e.IncludeDetails = builder.Environment.IsDevelopment())
.WithLogCleanup();
// Program.cs — middleware pipeline
app.UseKnmCorrelation(); // first — sets correlation ID
app.UseKnmTenant(); // after correlation
app.UseKnmExceptionHandler(); // after tenant, before controllers
app.MapHealthChecks("/health");
Or via appsettings.json inline configuration:
builder.Services.AddKnmCore(options => {
options.Cache.Prefix = "MYAPP_";
options.Email.SmtpHost = "smtp.example.com";
});
Email Service
Send a simple email
public class MyService(IEmailSenderService emailSender)
{
public async Task SendWelcome(string userEmail, string userName)
{
var dto = new SendMailDto
{
Company = "MyApp",
LogoUrl = "https://myapp.com/logo.png",
Template = EmailTemplate.General,
Recipients = [
new UserEmailSettings
{
Language = "en",
Subject = $"Welcome, {userName}!",
DisplayName = userName,
EmailAddress = userEmail,
Message = new MailMessage
{
Preview = "Welcome to MyApp",
Title = "WELCOME!",
Salutation = $"Dear {userName}",
Message = "Thank you for joining our platform.",
Goodbye = "The MyApp Team"
}
}
]
};
var result = await emailSender.SendAsync(dto);
if (!result.IsSuccess)
Console.WriteLine($"Send failed: {result.Error}");
}
}
Available Templates
| Template | Use case |
|---|---|
EmailTemplate.General |
Basic informational email |
EmailTemplate.WithButton |
Email with a call-to-action button |
EmailTemplate.WithDetailedDetails |
Email with icon + highlighted details block |
EmailTemplate.WithCode |
Email with a prominently displayed OTP/verification code |
Custom Template Override
Supply per-project HTML/TXT files via EmailOptions.TemplateOverrides. When a key is present the renderer loads from disk; otherwise it falls back to the embedded template.
builder.Services.AddKnmCore()
.WithEmail(email => {
email.TemplateOverrides[EmailTemplate.General] =
("wwwroot/emails/General.html", "wwwroot/emails/General.txt");
});
Paths are absolute or relative to the working directory. The file content is cached in memory after the first load, identical to embedded templates.
OAuth2 / XOAUTH2 authentication
Available since 1.7.0.
Modern SMTP relays (Microsoft 365, Google Workspace, Zoho, many enterprise tenants) require OAuth2 via XOAUTH2 and have disabled basic authentication. KNM.Core supports both modes via the AuthMode option:
builder.Services.AddKnmCore()
.WithEmail(email =>
{
email.SmtpHost = "smtp.office365.com";
email.SmtpPort = 587;
email.SecureMode = SmtpSecureMode.StartTls;
email.SmtpDisplayEmail = "noreply@contoso.com";
email.SmtpDisplayName = "KoNiMa Support";
email.AuthMode = SmtpAuthMode.OAuth2;
email.SmtpOAuth2Username = "noreply@contoso.com";
email.SmtpAccessTokenProvider = async ct =>
{
// Resolve a fresh access token at send time.
// Typical implementations: MSAL (Azure AD / Entra ID),
// Google.Apis.Auth, or a managed identity token fetcher.
var result = await msalApp
.AcquireTokenForClient(["https://outlook.office365.com/.default"])
.ExecuteAsync(ct);
return result.AccessToken;
};
});
Guarantees:
- Tokens are never cached, never logged, never stored — the provider is invoked fresh on every send attempt (including retries).
- Fail-closed: if the token provider throws or returns an empty string, the send fails with
EMAIL_OAUTH2_TOKEN_FAILED. There is no silent fallback to basic auth. - The provider honours the
CancellationTokenpassed toSendAsync.
Transport-layer security (GDPR / NIS2)
| Option | Default | Purpose |
|---|---|---|
SecureMode |
Auto |
MailKit picks STARTTLS on port 587, SSL-on-connect on 465, plain on 25 (rejected unless RequireTls=false). Use StartTls, SslOnConnect, or StartTlsWhenAvailable for strict control. |
RequireTls |
true |
Fail-fast if SecureMode is set to None. GDPR/NIS2 compliance safeguard. |
AllowInvalidCertificates |
false |
If true, accepts self-signed / expired / wrong-host certificates. Never enable in production. |
// Strict production setup
email.SecureMode = SmtpSecureMode.StartTls;
email.RequireTls = true;
email.AllowInvalidCertificates = false;
// Legacy on-prem relay with self-signed cert (dev/test only)
email.SecureMode = SmtpSecureMode.StartTlsWhenAvailable;
email.RequireTls = false;
email.AllowInvalidCertificates = true;
Configuration from appsettings.json
Every scalar option binds natively via IConfiguration.Bind(). The Func<> providers (credentials, access tokens) stay code-side so they can reach your vault / MSAL / managed identity safely.
{
"Knm": {
"Email": {
"SmtpHost": "smtp.office365.com",
"SmtpPort": 587,
"SmtpDisplayEmail": "noreply@contoso.com",
"SmtpDisplayName": "KoNiMa Support",
"AuthMode": "OAuth2",
"SmtpOAuth2Username": "noreply@contoso.com",
"SecureMode": "StartTls",
"RequireTls": true,
"AllowInvalidCertificates": false,
"MaxEmailsPerMinute": 30,
"MaxSendRetries": 3,
"UseExponentialBackoff": true
}
}
}
builder.Services.AddKnmCore(opt => builder.Configuration.GetSection("Knm").Bind(opt))
.WithEmail(email =>
{
// Only the secret-resolving delegate stays in code
email.SmtpAccessTokenProvider = tokenProvider.GetTokenAsync;
});
In-memory / stream attachments (KnmAttachment)
When the attachment content is generated in memory — GDPR data exports, PDF reports, in-process ZIPs — the temp-file dance is no longer required. IEmailSenderService exposes a second SendAsync overload that accepts IEnumerable<KnmAttachment> alongside the existing string[] file-path overload.
// Simple: in-memory byte array (simplest path, best for sub-100 MB content)
var zipBytes = BuildExportZip();
await emailSender.SendAsync(dto, [
KnmAttachment.FromBytes("export.zip", zipBytes, "application/zip")
]);
// Lazy + retry-safe: stream factory. Invoked once per send attempt (including retries)
// — the SDK owns the stream lifecycle, so the caller never worries about disposal.
await emailSender.SendAsync(dto, [
KnmAttachment.FromStream("report.pdf", () => File.OpenRead(reportPath))
]);
Both factories share the same validation as the file-path overload: extension is checked against EmailOptions.AllowedAttachmentExtensions, size against EmailOptions.MaxAttachmentSizeMb. For FromStream, size is enforced while reading so truly oversized content is rejected without buffering the whole payload in memory.
Audit record (NIS2)
Every SendAsync call returns an EmailAuditRecord containing operational metadata — never credentials. AuthMode and SecureMode are included so that audit logs can prove which authentication/TLS mode was negotiated for each send.
var result = await emailSender.SendAsync(dto);
if (result.IsSuccess)
{
var audit = result.Value!;
logger.LogInformation(
"Email {EmailId} sent to {Count} recipients via {Auth}/{Tls} at {Time}",
audit.EmailId, audit.RecipientsCount, audit.AuthMode, audit.SecureMode, audit.SentAtUtc);
}
Cache Service
IAppCache provides a unified interface for both in-memory and Redis caching. The backend is selected automatically based on CacheOptions.UseRedis.
In-memory (default)
builder.Services.AddKnmCore(o => {
o.Cache.Prefix = "MYAPP_";
o.Cache.DurationInMinutes = 60;
});
Redis
Note: Redis must be configured via the inline
configureaction inAddKnmCore(), not viaWithCache(), so the connection string is available when the DI container is built.
builder.Services.AddKnmCore(o => {
o.Cache.UseRedis = true;
o.Cache.RedisConnectionString = "localhost:6379";
o.Cache.Prefix = "MYAPP_";
o.Cache.DurationInMinutes = 120;
});
Usage
public class MyService(IAppCache cache)
{
// Works with int IDs
public async Task<User?> GetUser(int id)
{
return await cache.GetOrSetAsync(
key: $"user:{id}",
factory: async () => await db.Users.FindAsync(id),
duration: TimeSpan.FromMinutes(15)
);
}
// Works with Guid IDs — the cache key is always a string
public async Task<User?> GetUser(Guid id)
{
return await cache.GetOrSetAsync(
key: $"user:{id}",
factory: async () => await db.Users.FindAsync(id),
duration: TimeSpan.FromMinutes(15)
);
}
}
Values are automatically JSON-serialized when Redis is active.
Logger Service
IKnmLogger is a structured application logger with pluggable providers. Configure via WithLogger().
Providers
| Provider | Description |
|---|---|
KnmLogProvider.File |
Daily + size-rotated log files (e.g. app.2026-03-11.log, app.2026-03-11_001.log) |
KnmLogProvider.Database |
Persists entries via IKnmLogStore (consumer-implemented) |
KnmLogProvider.ApplicationInsights |
Sends TraceTelemetry / ExceptionTelemetry to Azure Application Insights |
KnmLogProvider.Serilog |
Delegates to a fully configured Serilog pipeline (any sink, enricher, or filter) |
Configuration
// File provider
.WithLogger(logger => {
logger.Provider = KnmLogProvider.File;
logger.Level = KnmLogLevel.Info;
logger.FilePath = "logs/app.log"; // date suffix added automatically
logger.LogCulture = "en-US"; // language for log messages
logger.MaxFileSizeKb = 5120; // rotate at 5 MB (default: 10 MB, 0 = disabled)
});
// Database provider — requires IKnmLogStore registration (see below)
.WithLogger(logger => {
logger.Provider = KnmLogProvider.Database;
logger.Level = KnmLogLevel.Warning;
});
// Application Insights provider
.WithLogger(logger => {
logger.Provider = KnmLogProvider.ApplicationInsights;
logger.AppInsightsConnString = "InstrumentationKey=...";
});
// Serilog provider (any sink — Console, File, Seq, Elastic, …)
.WithLogger(logger => {
logger.Provider = KnmLogProvider.Serilog;
logger.Level = KnmLogLevel.Debug;
logger.SerilogConfiguration = cfg => cfg
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
.Enrich.FromLogContext();
});
Database Logging with IKnmLogStore
When using KnmLogProvider.Database, KNM.Core resolves IKnmLogStore from the DI container to persist log entries. You own the schema — KNM.Core never creates or migrates tables.
Step 1 — Create your table (example SQL Server):
CREATE TABLE KnmLogs (
Id INT IDENTITY PRIMARY KEY,
TimestampUtc DATETIME2 NOT NULL,
Level TINYINT NOT NULL, -- maps to KnmLogLevel enum
EventName NVARCHAR(200) NOT NULL,
Category NVARCHAR(500) NOT NULL,
Message NVARCHAR(MAX) NOT NULL,
EventId INT NULL,
EntityGuid UNIQUEIDENTIFIER NULL, -- for Guid-based entity correlation
CorrelationId NVARCHAR(64) NULL, -- request correlation
TenantId NVARCHAR(128) NULL -- multi-tenant isolation
);
Step 2 — Implement IKnmLogStore:
// EF Core example
public class EfLogStore(MyDbContext db) : IKnmLogStore
{
public async Task SaveAsync(KnmLogEntry entry)
{
db.KnmLogs.Add(entry);
await db.SaveChangesAsync();
}
}
// Dapper example
public class DapperLogStore(IDbConnection connection) : IKnmLogStore
{
public async Task SaveAsync(KnmLogEntry entry)
{
await connection.ExecuteAsync(
"INSERT INTO KnmLogs (TimestampUtc, Level, EventName, Category, Message, EventId) " +
"VALUES (@TimestampUtc, @Level, @EventName, @Category, @Message, @EventId)",
new { entry.TimestampUtc, Level = (int)entry.Level, entry.EventName,
entry.Category, entry.Message, entry.EventId });
}
}
Step 3 — Register in DI:
builder.Services.AddScoped<IKnmLogStore, EfLogStore>(); // or DapperLogStore, etc.
builder.Services.AddKnmCore()
.WithLogger(logger => {
logger.Provider = KnmLogProvider.Database;
logger.Level = KnmLogLevel.Warning;
});
KnmLogEntry fields:
| Property | Type | Description |
|---|---|---|
TimestampUtc |
DateTime |
UTC timestamp of the log event |
Level |
KnmLogLevel |
Severity (Trace=0, Debug=1, Info=2, Warning=3, Error=4, Critical=5) |
EventName |
string |
Action or event name (e.g. "SendEmail") |
Category |
string |
Logger category (typically the class name) |
Message |
string |
Log message (PII-redacted when RedactPii = true) |
EventId |
int? |
Optional numeric event identifier |
EntityGuid |
Guid? |
Optional GUID-based entity identifier |
CorrelationId |
string? |
Request correlation identifier |
TenantId |
string? |
Tenant identifier for multi-tenant isolation |
Usage
public class MyService(IKnmLogger logger)
{
// With int entity ID
public void DoWork(int orderId)
{
logger.LogInfo("Order processing started", action: "DoWork", entity: "Order", entityId: orderId);
try { /* ... */ }
catch (Exception ex)
{
logger.LogError("Order processing failed", exception: ex, action: "DoWork", entity: "Order", entityId: orderId);
}
}
// With Guid entity ID
public void DoWork(Guid userId)
{
logger.LogInfo("User sync started", action: "DoWork", entity: "User", entityGuid: userId);
try { /* ... */ }
catch (Exception ex)
{
logger.LogError("User sync failed", userId, exception: ex, action: "DoWork", entity: "User");
}
}
}
Log methods
| Method | Level |
|---|---|
LogInfo(message, ...) |
Information |
LogWarning(message, ...) |
Warning |
LogError(message, exception?, ...) |
Error |
LogCritical(message, exception?, ...) |
Critical |
Background Jobs
KnmBackgroundService<TJob> wraps any IBackgroundJob implementation as an IHostedService with automatic retry, configurable interval, and integrated IKnmLogger logging. The job is resolved from a fresh DI scope on every execution, so scoped dependencies are fully supported.
Implement a job
public class ReportJob(IReportService reports) : IBackgroundJob
{
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
await reports.GenerateDailyReportAsync(cancellationToken);
}
}
Register
builder.Services.AddKnmCore()
.WithBackgroundJob<ReportJob>(job => {
job.JobName = "DailyReport";
job.ExecutionInterval = TimeSpan.FromHours(24);
job.MaxRetries = 3;
job.RetryDelay = TimeSpan.FromMinutes(5);
job.StopOnUnhandledFailure = false; // keep running even after exhausted retries
});
Multiple jobs can be chained:
builder.Services.AddKnmCore()
.WithBackgroundJob<ReportJob>(...)
.WithBackgroundJob<CleanupJob>(...);
BackgroundJobOptions
| Property | Default | Description |
|---|---|---|
JobName |
type name | Name used in log messages |
ExecutionInterval |
1 minute | Delay between execution cycles |
MaxRetries |
3 | Retry attempts per cycle on failure |
RetryDelay |
5 seconds | Fixed delay between retries |
StopOnUnhandledFailure |
false | Terminate hosted service after retries exhausted |
Enabled |
true | When false, the job is not registered |
Correlation ID
KNM.Core provides automatic request correlation via ICorrelationContext. A unique correlation ID is generated per request (or read from an incoming header) and propagated through:
- Log entries —
CorrelationIdfield inKnmLogEntry+ file log format[timestamp] LEVEL [correlationId] category: message - Outgoing HTTP calls — via
CorrelationIdDelegatingHandler - Response headers — automatically added to HTTP responses
Configuration
builder.Services.AddKnmCore()
.WithCorrelation(c => {
c.HeaderName = "X-Request-Id"; // default: "X-Correlation-Id"
c.Enabled = true; // default: true
});
// Middleware pipeline (register early, before logging/exception middleware)
app.UseKnmCorrelation();
Propagate to outgoing HTTP calls
builder.Services.AddHttpClient<MyApiClient>()
.AddHttpMessageHandler<CorrelationIdDelegatingHandler>();
Access in code
public class MyService(ICorrelationContext correlation)
{
public void DoWork()
{
var id = correlation.CorrelationId; // auto-generated GUID or from incoming header
}
}
Multi-Tenancy
Header-based tenant resolution with automatic cache key isolation and log segregation.
Configuration
builder.Services.AddKnmCore()
.WithMultiTenancy(t => {
t.HeaderName = "X-Tenant-Id"; // default: "X-Tenant-Id"
});
// Middleware pipeline (after correlation, before exception handler)
app.UseKnmTenant();
How it works
- Cache keys — automatically prefixed with
t:{tenantId}:when a tenant is active - Log entries —
TenantIdfield inKnmLogEntry+ file log format includes[t:tenantId] - Custom resolution — implement
ITenantResolverto resolve from JWT claims, subdomains, etc. and register in DI to override the default header-based resolver
Access in code
public class MyService(ITenantContext tenant)
{
public void DoWork()
{
var tenantId = tenant.TenantId; // null when no tenant resolved
}
}
Health Checks
Built-in health checks for SMTP, Redis, and file storage. Integrates with the standard ASP.NET Core health check system.
Configuration
builder.Services.AddKnmCore()
.WithHealthChecks(hc => {
hc.IncludeSmtp = true; // default: true
hc.IncludeRedis = true; // default: true (only registered when UseRedis=true)
hc.IncludeFileStorage = true; // default: true
hc.Timeout = TimeSpan.FromSeconds(5);
});
app.MapHealthChecks("/health");
| Check | Tags | What it verifies |
|---|---|---|
knm-smtp |
smtp, email | TCP connection to SMTP host:port |
knm-redis |
redis, cache | SET/REMOVE on IDistributedCache |
knm-file-storage |
storage, files | Directory exists (local) or connection string present (Azure) |
Exception Handler
Global exception middleware that catches unhandled exceptions, logs them via IKnmLogger, and returns a standardized JSON error response.
Configuration
builder.Services.AddKnmCore()
.WithExceptionHandler(e => {
e.IncludeDetails = builder.Environment.IsDevelopment(); // stack traces in dev only
e.LogLevel = KnmLogLevel.Error; // default: Error
});
// Register after correlation + tenant middleware, before controllers
app.UseKnmExceptionHandler();
Response format
{
"success": false,
"error": "An unexpected error occurred. Please try again later.",
"errorCode": "INTERNAL_ERROR",
"traceId": "d4f5e6a7-...",
"details": "System.NullReferenceException: ..."
}
traceIdis the correlation ID (when available)detailsis only included whenIncludeDetails = true
Email Retry Policy
Configure automatic retry on SMTP send failures with optional exponential backoff.
.WithEmail(email => {
email.MaxSendRetries = 3; // default: 0 (no retry)
email.RetryDelay = TimeSpan.FromSeconds(2); // default: 2s
email.UseExponentialBackoff = true; // default: true → 2s, 4s, 8s
});
Log Cleanup Job
Built-in background job that periodically purges old log entries from the database via IKnmLogStore.PurgeAsync(DateTime).
Configuration
builder.Services.AddKnmCore()
.WithLogger(logger => {
logger.Provider = KnmLogProvider.Database;
logger.LogRetentionDays = 30; // entries older than 30 days are deleted
})
.WithLogCleanup(job => {
job.ExecutionInterval = TimeSpan.FromHours(24); // default: 24h
});
Implement PurgeAsync
The consumer must implement PurgeAsync in their IKnmLogStore:
public class EfLogStore(MyDbContext db) : IKnmLogStore
{
public async Task SaveAsync(KnmLogEntry entry)
{
db.KnmLogs.Add(entry);
await db.SaveChangesAsync();
}
public async Task<int> PurgeAsync(DateTime olderThan)
{
return await db.KnmLogs
.Where(l => l.TimestampUtc < olderThan)
.ExecuteDeleteAsync();
}
}
Note:
PurgeAsynchas a default interface implementation that returns -1 (no-op). ExistingIKnmLogStoreimplementations continue to work without changes.
Progress Tracker
IProgressTracker provides generic progress reporting with subscription support for long-running operations. Registered as a singleton — safe to inject anywhere.
Configuration
builder.Services.AddKnmCore()
.WithProgressTracking();
Create and report progress
public class ImportService(IProgressTracker tracker)
{
public async Task ImportAsync(string operationId, List<Record> records)
{
var progress = tracker.CreateProgress<int>(operationId);
for (var i = 0; i < records.Count; i++)
{
await ProcessRecord(records[i]);
progress.Report((i + 1) * 100 / records.Count);
}
tracker.Complete(operationId);
}
}
Subscribe to progress updates
public class ProgressHub(IProgressTracker tracker)
{
public IDisposable Watch(string operationId, Func<int, Task> onProgress)
{
return tracker.Subscribe<int>(operationId, onProgress);
}
public int? GetCurrent(string operationId)
{
return tracker.GetLastProgress<int>(operationId);
}
}
API
| Method | Description |
|---|---|
CreateProgress<T>(operationId) |
Creates an IProgress<T> bound to the operation |
ReportAsync<T>(operationId, value, ct?) |
Reports progress and notifies all subscribers |
Subscribe<T>(operationId, handler) |
Subscribes to updates — returns IDisposable for cleanup |
GetLastProgress<T>(operationId) |
Gets the last reported value, or null |
Complete(operationId) |
Removes all tracking state for a completed operation |
Event Bus
IEventBus is an in-memory publish/subscribe event bus. Thread-safe, singleton-scoped. Failed subscriber callbacks are caught and logged — delivery continues to remaining subscribers.
Configuration
builder.Services.AddKnmCore()
.WithEventBus();
Subscribe and publish
public record OrderCreatedEvent(Guid OrderId, string CustomerEmail);
public class NotificationService(IEventBus eventBus) : IDisposable
{
private readonly IDisposable _subscription = eventBus.Subscribe<OrderCreatedEvent>(
async e => await SendEmailAsync(e.CustomerEmail, e.OrderId));
private Task SendEmailAsync(string email, Guid orderId)
{
// send notification...
return Task.CompletedTask;
}
public void Dispose() => _subscription.Dispose();
}
public class OrderService(IEventBus eventBus)
{
public async Task CreateOrderAsync(Order order)
{
// save order...
await eventBus.PublishAsync(new OrderCreatedEvent(order.Id, order.CustomerEmail));
}
}
API
| Method | Description |
|---|---|
Subscribe<TEvent>(handler) |
Subscribes to events of type TEvent — returns IDisposable for cleanup |
PublishAsync<TEvent>(event, ct?) |
Publishes an event to all subscribers of TEvent |
Result Pattern
// Return Result<T> from your methods
public Result<User> GetUser(int id)
{
var user = db.Users.Find(id);
return user is null
? Result<User>.Failure("User not found", "USER_404")
: Result<User>.Success(user);
}
// Consume with chaining
var result = GetUser(42)
.Map(user => user.Email)
.Map(email => email.ToLower());
if (result.IsSuccess)
Console.WriteLine(result.Value);
else
Console.WriteLine($"Error [{result.ErrorCode}]: {result.Error}");
HttpClient Extensions
HttpClientExtensions wraps any HttpClient with automatic Polly retry governed by RetryPolicyOptions. All methods return Result<T> or Result — exceptions never propagate to the caller.
| Method | Description |
|---|---|
GetWithRetryAsync<T>(url, retryPolicy?, timeout?) |
GET → deserialize JSON to T |
PostWithRetryAsync<T>(url, body, retryPolicy?, timeout?) |
POST JSON body → deserialize response |
PutWithRetryAsync<T>(url, body, retryPolicy?, timeout?) |
PUT JSON body → deserialize response |
DeleteWithRetryAsync(url, retryPolicy?, timeout?) |
DELETE → Result |
Configure retry policy globally
builder.Services.AddKnmCore()
.WithRetryPolicy(retry => {
retry.MaxRetries = 5;
retry.BaseDelay = TimeSpan.FromMilliseconds(500);
retry.UseExponentialBackoff = true;
retry.UseJitter = true;
retry.DefaultTimeout = TimeSpan.FromSeconds(20);
retry.RetryOnStatusCodes = [HttpStatusCode.ServiceUnavailable, HttpStatusCode.GatewayTimeout];
retry.RetryOnHttpRequestException = true;
retry.RetryOnExceptionTypes = [typeof(TimeoutException)];
});
Usage (with DI)
public class MyService(HttpClient httpClient, RetryPolicyOptions retryPolicy)
{
public async Task<Result<WeatherData>> GetWeather()
=> await httpClient.GetWithRetryAsync<WeatherData>(
"https://api.example.com/weather/rome",
retryPolicy: retryPolicy,
timeout: TimeSpan.FromSeconds(10));
}
Usage (one-off, no DI)
var result = await httpClient.GetWithRetryAsync<WeatherData>(
url: "https://api.example.com/weather/rome");
// Uses HttpClientExtensions.DefaultRetryPolicy (3 retries, 30s timeout, 5xx/408 triggers)
File Storage Service
IFileStorage provides a unified API for file upload, download, delete, existence check, and URL resolution. Inject it via DI and swap the provider with WithFileStorage().
| Provider | Description |
|---|---|
FileStorageProvider.LocalDisk |
Files stored on local file system under LocalBasePath |
FileStorageProvider.AzureBlob |
Files stored in Azure Blob Storage container |
Configuration
// Local disk (default)
builder.Services.AddKnmCore()
.WithFileStorage(fs => {
fs.Provider = FileStorageProvider.LocalDisk;
fs.LocalBasePath = "/var/app/uploads";
});
// Azure Blob Storage
builder.Services.AddKnmCore()
.WithFileStorage(fs => {
fs.Provider = FileStorageProvider.AzureBlob;
fs.AzureConnectionString = "DefaultEndpointsProtocol=https;...";
fs.AzureContainerName = "my-container";
fs.AzurePublicBaseUrl = "https://cdn.example.com"; // optional CDN prefix
});
Usage
public class DocumentService(IFileStorage storage)
{
public async Task<Result<string>> SaveAsync(Stream pdfStream, string name)
=> await storage.UploadAsync(pdfStream, $"docs/{name}.pdf", "application/pdf");
public async Task<Result<Stream>> ReadAsync(string name)
=> await storage.DownloadAsync($"docs/{name}.pdf");
public async Task<string?> GetLink(string name)
{
var result = await storage.GetUrlAsync($"docs/{name}.pdf");
return result.IsSuccess ? result.Value : null;
}
}
Configuration Extensions
Extension methods for IConfiguration and IConfigurationSection providing safe, fail-fast access patterns and DataAnnotations-based validation.
| Method | Description |
|---|---|
GetRequired<T>(key) |
Returns the value or throws InvalidOperationException if missing/empty |
GetOrDefault<T>(key, defaultValue) |
Returns the value or defaultValue — never throws |
BindAndValidate<T>(sectionKey?) |
Binds section to T and validates DataAnnotations → Result<T> |
All methods work on both IConfiguration and IConfigurationSection.
// Fail-fast: throws if "App:ApiKey" is absent or empty
var apiKey = configuration.GetRequired<string>("App:ApiKey");
// Safe fallback
var timeout = configuration.GetOrDefault<int>("App:TimeoutMs", defaultValue: 5000);
// Bind + validate (uses [Required], [Range], etc. attributes on the options class)
public class SmtpOptions
{
[Required] public string Host { get; set; } = string.Empty;
[Range(1, 65535)] public int Port { get; set; } = 587;
}
Result<SmtpOptions> result = configuration.BindAndValidate<SmtpOptions>("Smtp");
if (!result.IsSuccess)
throw new InvalidOperationException(result.Error);
// Also works on a section directly
var section = configuration.GetSection("Smtp");
Result<SmtpOptions> result2 = section.BindAndValidate<SmtpOptions>();
Template Renderer Extensions
TemplateRendererExtensions adds two optional rendering engines on top of the default $$PLACEHOLDER$$ substitution. Both methods are extension methods on ITemplateRenderer — inject it as usual and call the method directly.
Handlebars
Uses Handlebars.Net — familiar {{ }} syntax with helpers, partials, and block expressions.
public class MyService(ITemplateRenderer renderer)
{
public string BuildEmail(string name, string[] items)
{
var template = """
Hello, {{Name}}!
{{#each Items}}
- {{this}}
{{/each}}
""";
return renderer.RenderHandlebars(template, new { Name = name, Items = items });
}
}
Scriban
Uses Scriban — a fast scripting engine with full loop/conditional support and a Liquid-compatible mode.
public class MyService(ITemplateRenderer renderer)
{
public string BuildEmail(string name, string[] items)
{
var template = """
Hello, {{ Name }}!
{{ for item in Items }}
- {{ item }}
{{ end }}
""";
return renderer.RenderScriban(template, new { Name = name, Items = items });
}
}
Note: Scriban exposes property names exactly as they are declared (PascalCase). Use
{{ Name }},{{ Title }}, etc.
Extension Methods
String
"hello world".Truncate(8); // "hello..."
"User@Example.COM".NormalizeEmail(); // "user@example.com"
"<b>Hello</b>".ToCleanText(); // "Hello"
myObject.ToJson(indent: true); // pretty JSON
"{\"name\":\"Alice\"}".FromJson<User>(); // User object
Number
1234.56m.ToCurrency("it-IT"); // "€ 1.234,56"
1234.5678m.RoundBankers(2); // 1234.57
1_500_000L.ToHumanReadable(); // "1.5M"
Date
DateTime.Now.ToShortDate(); // "11/03/2026" (default: it-IT)
DateTime.Now.ToShortDate("en-US"); // "3/11/2026"
DateTime.Now.ToFormattedDate("yyyy-MM-dd"); // "2026-03-11"
DateTime.Now.ToIso8601(); // "2026-03-11T10:00:00.000Z"
new DateTime(1990, 5, 15).GetAge(); // 35
DateTime.Now.ToUnixTimestamp(); // Unix epoch seconds
DateTime.Now.IsWeekend(); // false
DateTime.Now.StartOfMonth(); // 2026-03-01
DateTime.Now.EndOfMonth(); // 2026-03-31
Enum
MyEnum.SomeValue.GetDescription(); // reads [Description] attribute
"Active".TryParse<StatusEnum>(); // StatusEnum? (null-safe)
Zip Builder
A fluent utility for creating ZIP archives in-memory (for browser downloads, API responses) or on disk (for email attachments, archival). Zero external dependencies — built on the BCL System.IO.Compression.
In-memory archive (UI downloads, API responses)
using KNM.Core.Utils;
var bytes = KnmZipBuilder.Create()
.AddJson("profile.json", userProfile)
.AddText("readme.txt", "Export generated on 2026-04-11")
.AddBytes("logo.png", logoBytes)
.WithCompression(CompressionLevel.Optimal)
.Build();
// or async with cancellation
var bytes = await KnmZipBuilder.Create()
.AddJson("export.json", largeDto)
.BuildAsync(cancellationToken);
File-based archive (email attachments, archival)
// Writes atomically: temp file + rename on success — crash-safe
await KnmZipBuilder.Create()
.AddJson("export.json", exportDto)
.AddFile("contract.pdf", contractPath)
.SaveToFileAsync(tempZipPath, cancellationToken);
// Then attach to an email
await emailSender.SendEmailAsync(new SendMailDto
{
/* ... */
GlobalAttachments = [tempZipPath]
});
Fluent API
| Method | Purpose |
|---|---|
Create() |
Factory — returns a new builder. |
AddJson<T>(entryName, data, options?, replace?) |
Serializes data as JSON (indented by default) and adds it. |
AddText(entryName, content, encoding?, replace?) |
Adds a text entry; UTF-8 by default. |
AddBytes(entryName, content, replace?) |
Adds a raw byte-array entry. |
AddFile(entryName, sourcePath, replace?) |
Adds a file from disk, streamed at build time. |
AddStream(entryName, content, replace?) |
Adds a stream (fully buffered at call time — caller may dispose). |
WithCompression(level) |
Sets the compression level. Default: Optimal. |
Build() / BuildAsync(ct) |
Builds the archive and returns byte[]. |
SaveToFileAsync(path, ct) |
Writes atomically to a file path. |
WriteToStreamAsync(target, ct) |
Writes into an existing stream (leaves it open). |
Guarantees
- Path-traversal safe: entry names are rejected if they contain
..segments, absolute paths, or Windows drive prefixes. - Duplicate detection: adding the same entry name twice throws
InvalidOperationException; passreplace: trueto overwrite. - Atomic file writes:
SaveToFileAsyncwrites to<path>.tmpand renames on success, so a crash cannot leave a partial file at the target. - Cancellation: every async method honours
CancellationToken.
License Validator
Opt-in module for software license validation. Supports offline (file-based with RSA + AES), online (API), and hybrid modes. Disabled by default.
Registration
builder.Services.AddKnmCore()
.WithLicenseValidator(license =>
{
license.Mode = ValidationMode.Hybrid;
license.ApiBaseUrl = "https://licenses.konima.it";
license.ApiKey = "my-api-key";
license.LicenseKey = "ABCDE-FGHIJ-KLMNO-PQRST-UVWXY";
license.LicenseDirectory = "./Licenses";
license.ValidateOnStartup = true;
license.ThrowOnInvalidLicense = false;
license.CacheExpiration = TimeSpan.FromHours(24);
});
Or from appsettings.json:
builder.Services.AddKnmCore()
.WithLicenseValidator(builder.Configuration.GetSection("LicenseValidator"));
Feature Query API
After validation, use ILicenseFeatures (singleton) for stable, always-available feature checks:
[Inject] private ILicenseFeatures License { get; set; } = default!;
// Top-level features
if (License.Has("REPORTING")) { /* module enabled */ }
var maxUsers = License.Get<int>("MAX_USERS");
// Sub-module features
if (License.Has("REPORTING", "EXPORT_PDF")) { /* sub-feature enabled */ }
var maxReports = License.Get<int>("REPORTING", "MAX_REPORTS");
// All features
var features = License.GetAll();
On-Demand Validation
Use ILicenseValidator for explicit re-validation:
[Inject] private ILicenseValidator Validator { get; set; } = default!;
var result = await Validator.ValidateLicenseAsync();
if (result.IsValid)
{
// result.HasFeature("EXPORT_PDF")
// result.GetFeatureValue<int>("MAX_USERS")
}
Validation Modes
| Mode | Description |
|---|---|
OfflineOnly |
Validates using local PublicKey.xml + License.txt files |
OnlineOnly |
Validates via HTTP to the license API |
Hybrid |
Online first, falls back to offline (configurable) |
Startup Gate
Block application startup if the license is invalid:
license.ValidateOnStartup = true;
license.ThrowOnInvalidLicense = true; // throws InvalidOperationException
Custom Key Provider
Implement ILicenseKeyProvider for database-backed license storage:
builder.Services.AddKnmCore()
.WithLicenseValidator(license =>
{
license.Mode = ValidationMode.Hybrid;
license.UseCustomProvider<DatabaseLicenseKeyProvider>();
});
Multi-Tenant Support
ILicenseFeatures is tenant-aware. When multi-tenancy is enabled via .WithMultiTenancy(), each tenant's features are stored independently in the singleton.
Directory Service — Multi-Provider User Lookup
The Directory module provides a unified user-lookup API over three different directory backends:
- LDAP / Active Directory (on-premise) — Windows joined hosts, Samba AD, FreeIPA, and any LDAP-compatible server via the
ILdapSchemastrategy - Microsoft Graph (Azure AD / Microsoft Entra ID) — cloud identity, authenticated via OAuth2 client credentials or managed identity
- Custom — bring your own
ILdapSchemafor non-AD LDAP servers (OpenLDAP, 389-DS, Apple Open Directory, etc.)
The module is disabled by default and activated via the fluent WithDirectory() builder. All operations return Result<T> and never throw, and the same IDirectoryUserProvider interface is exposed regardless of which backend is active.
When to use it
- Look up users from an on-premise corporate AD/LDAP server (search by SID, UPN or free text)
- Identify the current Windows OS user (SID, username)
- Look up users from Azure AD / Entra ID via Microsoft Graph
- Build a directory layer that is tenant-aware out of the box (per-tenant cache via
ITenantContext) - Get a working LDAP solution without configuration on Windows-joined hosts and on Linux/macOS hosts that publish the
_ldap._tcp.<domain>DNS SRV record
Provider selection
public enum DirectoryProviderType
{
Ldap, // Default. LDAP v3 via System.DirectoryServices.Protocols
Graph // Microsoft Graph via the Microsoft.Graph 5.x SDK
}
The Provider field on DirectoryOptions picks the backend; the corresponding nested options (Ldap or Graph) are consumed based on that selection.
Registration — LDAP (Active Directory)
// 1. Auto-discovery (95% of cases) — zero config on Windows joined hosts
// and on Linux/macOS hosts with a _ldap._tcp.<domain> SRV record
builder.Services.AddKnmCore().WithDirectory();
// 2. Manual override (custom host, LDAPS, explicit bind user)
builder.Services.AddKnmCore()
.WithDirectory(opt =>
{
opt.Provider = DirectoryProviderType.Ldap; // default, can be omitted
opt.Ldap.Host = "ldap.acme.local";
opt.Ldap.Port = 636;
opt.Ldap.UseLdaps = true;
opt.Ldap.BaseDn = "DC=acme,DC=local";
opt.Ldap.BindUser = "CN=svc-app,OU=Services,DC=acme,DC=local";
opt.Ldap.BindPassword = builder.Configuration["Directory:BindPassword"];
});
// 3. Eager preload at startup + health check
builder.Services.AddKnmCore()
.WithDirectory(opt => opt.Ldap.PreloadOnStartup = true)
.WithHealthChecks();
Registration — Microsoft Graph (Azure AD / Entra ID)
Requires an app registration in the target Azure AD tenant with the User.Read.All application permission (or equivalent) granted via admin consent.
// Client-credentials flow
builder.Services.AddKnmCore()
.WithDirectory(opt =>
{
opt.Provider = DirectoryProviderType.Graph;
opt.Graph.TenantId = builder.Configuration["AzureAd:TenantId"];
opt.Graph.ClientId = builder.Configuration["AzureAd:ClientId"];
opt.Graph.ClientSecretProvider =
async () => await vault.GetSecretAsync("azure-ad-client-secret");
});
// Managed Identity flow (Azure App Service / AKS / Functions with MSI)
builder.Services.AddKnmCore()
.WithDirectory(opt =>
{
opt.Provider = DirectoryProviderType.Graph;
opt.Graph.TenantId = builder.Configuration["AzureAd:TenantId"];
opt.Graph.UseManagedIdentity = true;
});
Registration — Custom LDAP schema (OpenLDAP, 389-DS, Open Directory)
// 1. Implement ILdapSchema for your server (attribute names, filters, entry mapping)
services.AddSingleton<ILdapSchema, MyOpenLdapSchema>();
// 2. Enable with Schema = Custom
builder.Services.AddKnmCore()
.WithDirectory(opt =>
{
opt.Ldap.Schema = LdapSchemaType.Custom;
opt.Ldap.Host = "ldap.acme.local";
opt.Ldap.BaseDn = "dc=acme,dc=local";
opt.Ldap.BindUser = "cn=admin,dc=acme,dc=local";
opt.Ldap.BindPassword = builder.Configuration["Ldap:BindPassword"];
});
Registration — From appsettings.json
{
"Directory": {
"Enabled": true,
"Provider": "Ldap",
"Ldap": {
"Host": "ldap.acme.local",
"UseLdaps": true,
"PreloadOnStartup": true,
"OperationTimeout": "00:00:30",
"DiscoveryCacheDuration": "01:00:00"
},
"Graph": {
"TenantId": "00000000-0000-0000-0000-000000000000",
"ClientId": "11111111-1111-1111-1111-111111111111"
}
}
}
builder.Services.AddKnmCore()
.WithDirectory(builder.Configuration.GetSection("Directory"));
Setting Enabled = false skips the registration entirely — IDirectoryUserProvider will not be resolvable.
IDirectoryUserProvider API
public interface IDirectoryUserProvider
{
Task<Result<DirectoryStatus>> GetStatusAsync(CancellationToken ct = default);
Task<Result<DirectoryUser?>> FindUserBySsIdAsync(string ssid, CancellationToken ct = default);
Task<Result<IReadOnlyList<DirectoryUser>>> FindUsersAsync(string? query, CancellationToken ct = default);
}
| Method | Returns | LDAP semantics | Graph semantics |
|---|---|---|---|
GetStatusAsync |
Result<DirectoryStatus> |
Host, port, base DN, current OS user SID (Windows) | Azure tenant ID, HTTPS port 443, no OS user SID |
FindUserBySsIdAsync |
Result<DirectoryUser?> |
SID lookup on Windows; sAMAccountName fallback otherwise |
Azure AD object ID (GUID) or UPN |
FindUsersAsync |
Result<IReadOnlyList<DirectoryUser>> |
LDAP filter across givenName, sn, sAMAccountName, mail, displayName, userPrincipalName |
Graph $search across displayName, givenName, surname, mail, userPrincipalName |
Reserved query keywords (both providers): "user" returns all users, "computer" returns computer accounts (LDAP only; returns empty list on Graph since Azure AD has no computer-account concept of the same kind).
Usage example — provider-agnostic
public class DirectoryLookupService(IDirectoryUserProvider directory, IKnmLogger logger)
{
public async Task<DirectoryUser?> ResolveByIdAsync(string identity, CancellationToken ct)
{
// Works with LDAP (SID or sAMAccountName) AND Graph (GUID or UPN) — same call.
var result = await directory.FindUserBySsIdAsync(identity, ct);
if (!result.IsSuccess)
{
logger.LogWarning($"Directory lookup failed: {result.Error} ({result.ErrorCode})",
"Resolve", nameof(DirectoryLookupService));
return null;
}
return result.Value;
}
public async Task<IReadOnlyList<DirectoryUser>> SearchAsync(string query, CancellationToken ct)
{
var result = await directory.FindUsersAsync(query, ct);
return result.IsSuccess ? result.Value! : [];
}
}
Cross-platform compatibility matrix
| Scenario | Windows | Linux | macOS | Notes |
|---|---|---|---|---|
| LDAP — AD on-prem (joined) | ✅ incl. current OS SID | ✅ via DNS SRV + bind | ✅ idem | Linux requires native libldap libraries |
LDAP — AD on-prem (not joined, explicit BindUser) |
✅ | ✅ | ✅ | |
| LDAP — Samba AD / FreeIPA (AD-compatible) | ✅ | ✅ | ✅ | |
| LDAP — OpenLDAP / 389-DS / Apple Open Directory | ✅ | ✅ | ✅ | requires custom ILdapSchema (set LdapSchemaType.Custom) |
| Graph — Azure AD via client secret | ✅ | ✅ | ✅ | pure HTTPS, no native libraries |
| Graph — Azure AD via Managed Identity | ✅ (Azure host) | ✅ (Azure host) | n/a | requires an Azure-hosted runtime |
The Windows-only LDAP code is gated by RuntimeInformation.IsOSPlatform(OSPlatform.Windows) and annotated with [SupportedOSPlatform("windows")]. The Graph provider is pure cross-platform (HTTPS only).
Multi-tenancy
When the multi-tenancy module is enabled (WithMultiTenancy()), the provider keeps a per-KoNiMa-tenant cache keyed on ITenantContext.TenantId. Each tenant runs its own discovery/client initialization on first use. When multi-tenancy is disabled, all calls share a single "__default__" cache entry. Cache duration is configurable via LdapDirectoryOptions.DiscoveryCacheDuration / GraphDirectoryOptions.StatusCacheDuration (default 1 hour).
Error codes
All methods return Result<T>. On failure, branch on result.ErrorCode:
| Code | Meaning |
|---|---|
DIRECTORY_UNAVAILABLE |
Backend enabled but not reachable for the tenant — result.Error contains the localized diagnostic |
DIRECTORY_BIND_FAILED |
Authentication rejected (LDAP error 49, or Graph 401/403) |
DIRECTORY_TIMEOUT |
Operation exceeded the configured timeout |
DIRECTORY_INVALID_QUERY |
Caller passed a malformed argument (e.g. empty SSID) |
DIRECTORY_ERROR |
Unexpected exception — also logged via IKnmLogger with the inner exception |
Health check
When WithHealthChecks() is called, a knm-directory health check (tags: directory, ldap or graph depending on provider) is registered automatically. It returns:
- Healthy when the backend is reachable and authenticated
- Degraded when the module is enabled but the backend is not reachable — the app remains functional, the directory module is opt-in
- Unhealthy only on unexpected exceptions
app.MapHealthChecks("/health");
Lifetime and threading
IDirectoryUserProvider is registered as a singleton (one instance per resolved provider type). The LDAP provider maintains a ConcurrentDictionary<tenantId, Lazy<Task<LdapConfig>>> so each tenant's auto-discovery runs exactly once even under concurrent load; each LDAP operation opens its own short-lived LdapConnection, disposed after the call. The Graph provider caches a GraphServiceClient per tenant, reused across calls.
Migration from KNM.LDAPHelper
KNM.LDAPHelper has been merged into KNM.Core 1.6.0 and is no longer maintained.
| Before | After |
|---|---|
using KNM.LDAPHelper.Services.Interfaces; |
using KNM.Core.Services.Interfaces; |
using KNM.LDAPHelper.Models; |
using KNM.Core.Models; |
services.AddKnmLdapCollection(); |
services.AddKnmCore().WithDirectory(); |
ILdapUserInfoProvider |
IDirectoryUserProvider |
provider.GetUserInfo() (sync, returns LdapUserInfo) |
await provider.GetStatusAsync() (async, returns Result<DirectoryStatus>) |
LdapUserInfo.LdapUser |
DirectoryStatus.IsAvailable and/or CurrentOsUserSsId != null |
LdapUserInfo.SsId |
DirectoryStatus.CurrentOsUserSsId |
LdapUserInfo.Result |
DirectoryStatus.Diagnostic |
provider.FindUserBySsIdAsync(sid) (may throw) |
var r = await provider.FindUserBySsIdAsync(sid); if (r.IsSuccess) ... |
provider.FindUsersAsync(query) (may throw) |
var r = await provider.FindUsersAsync(query); if (r.IsSuccess) ... |
Beyond the surface port, v1.6.0 adds: Result<T> error handling with stable error codes, CancellationToken on every operation, tenant-aware per-tenant cache, lazy thread-safe discovery, multi-provider architecture (add Graph/Azure AD with one option flip), ILdapSchema extensibility for non-AD LDAP servers, integrated health check, optional eager preload at startup, 6-language localized diagnostics, and IKnmLogger structured logging.
Architecture
KNM.Core/
├── Models/ DTOs, enums, CoreOptions, Result<T>, KnmErrorResponse
├── Services/
│ ├── Interfaces/ IEmailSenderService, IAppCache, ITemplateRenderer, IKnmLogger,
│ │ ICorrelationContext, ITenantContext, ITenantResolver, IKnmLogStore,
│ │ IProgressTracker, IEventBus, IDirectoryUserProvider
│ ├── DirectoryServices/ LdapDirectoryUserProvider, DirectoryDiscovery, LdapConnectionFactory,
│ │ DirectoryEntryMapper, DirectoryConfig, DirectoryPreloadHostedService
│ ├── Licensing/ OfflineLicenseValidator, OnlineLicenseValidator, HybridLicenseValidator,
│ │ LicenseFeatures, ConfigurationLicenseKeyProvider
│ └── ... Concrete implementations (CorrelationContext, TenantContext, LogCleanupJob, etc.)
├── Middleware/ CorrelationIdMiddleware, TenantMiddleware, KnmExceptionMiddleware + extensions
├── HealthChecks/ SmtpHealthCheck, RedisHealthCheck, FileStorageHealthCheck, DirectoryHealthCheck
├── Extensions/ StringExtensions, NumberExtensions, DateExtensions, EnumExtensions, TemplateRendererExtensions
├── Templates/ Embedded HTML + TXT email templates
├── Resources/ Localized strings (en-US default, en-GB, it-IT, es-ES, fr-FR, de-DE)
└── Internal/ EmailInternal, Logging providers (not public API)
Requirements
- .NET 10+
- Microsoft.Extensions.Caching.Memory
- Microsoft.Extensions.DependencyInjection.Abstractions
- Microsoft.Extensions.Logging.Abstractions
Security & Compliance
KNM.Core includes built-in support for GDPR-aware data handling and secure credential management. See SECURITY.md for the full security policy and GDPR compliance checklist.
Credential Providers
Avoid storing SMTP passwords, Redis connection strings, or Azure keys as plain-text properties. Use credential providers to resolve secrets at runtime from a vault or environment:
.WithEmail(email =>
{
email.SmtpHost = "smtp.example.com";
email.SmtpCredentialProvider = async () =>
new NetworkCredential(
await vault.GetSecretAsync("smtp-user"),
await vault.GetSecretAsync("smtp-password"));
})
Credential providers are available for SMTP (SmtpCredentialProvider), Redis (RedisCredentialProvider), and Azure Blob Storage (AzureCredentialProvider).
PII Redaction
LoggerOptions.RedactPii (default: true) enables automatic redaction of email addresses, names, and other personal data in log output via the IPiiRedactor interface. Combine with LoggerOptions.LogRetentionDays (default: 30) to enforce data retention policies.
Email Audit Trail
SendAsync returns an EmailAuditRecord containing a non-PII audit entry (timestamp, recipient count, template used, result status) suitable for compliance logging.
Rate Limiting
Set EmailOptions.MaxEmailsPerMinute to cap outbound email throughput and prevent abuse or accidental bulk sends.
Cache PII TTL
When caching entries that contain personal data, pass containsPii: true to enforce a shorter TTL controlled by CacheOptions.PiiTtlMinutes (default: 5 minutes):
await cache.SetAsync("user:42", userData, containsPii: true);
Attachment Validation
- File size limit:
EmailOptions.MaxAttachmentSizeMb(default: 10 MB) - Extension whitelist:
EmailOptions.AllowedAttachmentExtensionsrestricts permitted file types - Path traversal prevention: attachment paths must be absolute with no
..sequences
License
MIT — KoNiMa S.r.l.
Showing the top 20 packages that depend on KNM.Core.
| Packages | Downloads |
|---|---|
|
KNM.Reporting
Unified reporting abstraction supporting SSRS, FastReport, QuestPDF, and ClosedXML with fluent DI configuration
|
0 |
|
KNM.Reporting
Unified reporting abstraction supporting SSRS, FastReport, QuestPDF, and ClosedXML with fluent DI configuration
|
1 |
|
KNM.Reporting
Unified reporting abstraction supporting SSRS, FastReport, QuestPDF, and ClosedXML with fluent DI configuration
|
2 |
|
KNM.Reporting.AI
AI conversational agent for KNM.Reporting with Ollama tool use, session management, and multi-DbContext support
|
0 |
|
KNM.Reporting.Designer
Plug and play Blazor admin panel for report generation, management, and monitoring in the KoNiMa ecosystem
|
0 |
.NET 10.0
- Azure.Identity (>= 1.20.0)
- Azure.Storage.Blobs (>= 12.27.0)
- DnsClient (>= 1.8.0)
- Handlebars.Net (>= 2.1.6)
- MailKit (>= 4.15.1)
- Microsoft.ApplicationInsights (>= 3.1.0)
- Microsoft.Extensions.Caching.StackExchangeRedis (>= 10.0.5)
- Microsoft.Graph (>= 5.103.0)
- Polly (>= 8.6.6)
- Scriban (>= 7.0.6)
- Serilog (>= 4.3.1)
- Serilog.Extensions.Logging (>= 10.0.0)
- System.DirectoryServices (>= 10.0.5)
- System.DirectoryServices.Protocols (>= 10.0.5)