A self-contained .NET 8 security library providing ASP.NET Core Identity, multi-provider authentication, session management, MFA, audit logging, password/lockout policies, and role-based permissions — all isolated in a dedicated PostgreSQL security schema.
- What UlinziLib Provides
- Architecture Overview
- Quick Start (ProjectReference)
- Quick Start (NuGet)
- Running Migrations
- Configuration Reference
- IP Whitelist Opt-In
- Permission Key Reference
- Role Reference
- Extending UlinziLib
- Post-Publishing Workflow
- Repository Strategy
- Testing
| Domain | What is included |
|---|---|
| Identity | User, Role, Permission, Team entities extending ASP.NET Core Identity |
| Authentication | IUserRepository with multi-provider fields (Local, Zitadel, Entra, Google) |
| Session Management | IHttpSessionRecordRepository, ISessionPolicyService — session creation, expiry, idle timeout |
| MFA | IMfaService, AppUserMfa — TOTP (via Otp.NET), recovery codes |
| Audit | IAuditService, SecurityAuditEvent — tamper-evident event log |
| Password Policy | IPasswordPolicyService, PasswordPolicy — min/max length, complexity, history, breach check |
| Lockout Policy | ILockoutPolicyService, LockoutPolicy — progressive lockout, admin unlock, notifications |
| Login Attempts | ILoginAttemptsService, LoginAttempt — per-IP and per-email tracking |
| Account Lockouts | IAccountLockoutsService, AccountLockout — manual and automated lockout records |
| Authorization | IAuthorizationService — check permission.key against a user's role-resolved permissions |
| Invite Tracking | IInviteEmailLogRepository, InviteEmailLog — email invite delivery lifecycle |
| Seeded Data | 7 migrations — 40+ permissions, 13+ roles, initial admin user, all idempotent |
Everything lives in the security PostgreSQL schema. No collision with your application schema.
┌───────────────────────────────────────────────────────┐
│ Host Application │
│ │
│ Program.cs │
│ ├── services.AddUlinziLib(connStr) │
│ └── services.AddUlinziLibIpWhitelist() [optional] │
│ │
│ Middleware Pipeline │
│ ├── IpWhitelistMiddleware [if enabled] │
│ ├── SessionEnforcementMiddleware │
│ ├── UseAuthentication() │
│ ├── CsrfProtectionMiddleware │
│ ├── UseAuthorization() │
│ └── SessionWriteGuardMiddleware │
│ │
│ Authentication/ (host layer) │
│ └── AuthSessionService — multi-provider login │
│ Local → Zitadel → Entra → permission resolution │
└───────────────┬───────────────────────────────────────┘
│ ProjectReference / NuGet
┌───────────────▼───────────────────────────────────────┐
│ UlinziLib │
│ │
│ Domain/Entities/ ← User, Role, Permission, Team, │
│ SessionPolicy, AppUserMfa, │
│ SecurityAuditEvent, etc. │
│ │
│ Application/ ← Interfaces (IUserRepository, │
│ IMfaService, IAuditService…) │
│ │
│ Infrastructure/ ← Implementations + Repos │
│ │ Data/ ← SecurityDbContext │
│ └ Services/ ← PasswordPolicyService, etc. │
│ │
│ Extensions/ ← AddUlinziLib() │
│ Migrations/ ← 7 migrations (security schema) │
└───────────────────────────────────────────────────────┘
This is the current setup while UlinziLib lives in the same solution.
In your host .csproj:
<ItemGroup>
<ProjectReference Include="..\UlinziLib\UlinziLib.csproj" />
</ItemGroup>using UlinziLib.Extensions;
var defaultConnection = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddUlinziLib(defaultConnection!);// In Program.cs, after app is built:
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SecurityDbContext>();
await db.Database.MigrateAsync();
}// Optional - must be FIRST if used
app.UseMiddleware<IpWhitelistMiddleware>();
// Required session enforcement
app.UseMiddleware<SessionEnforcementMiddleware>();
app.UseAuthentication();
app.UseMiddleware<CsrfProtectionMiddleware>();
app.UseAuthorization();
app.UseMiddleware<SessionWriteGuardMiddleware>();Once UlinziLib is published as UlinziLib:
dotnet add package UlinziLibThe setup is identical to the ProjectReference approach above. The key difference is migrations:
// Tell EF Core where the migration assembly lives
builder.Services.AddDbContext<SecurityDbContext>(options =>
{
options.UseNpgsql(connStr, npgsql =>
{
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "security");
npgsql.MigrationsAssembly("UlinziLib"); // ← assembly name of the package
});
});Then db.Database.MigrateAsync() at startup picks up all bundled migrations.
Call MigrateAsync() in Program.cs as shown above. All 7 migrations run once and are recorded in security.__EFMigrationsHistory. They are idempotent — re-running is safe.
Run from the repository root (the directory containing your host app's .csproj). Always pass --context SecurityDbContext because the startup project may contain multiple DbContexts.
# List all migrations and see which are applied (marked with [X])
dotnet ef migrations list \
--project UlinziLib/UlinziLib.csproj \
--startup-project YourApp.csproj \
--context SecurityDbContext \
--no-build
# Apply all pending migrations
dotnet ef database update \
--project UlinziLib/UlinziLib.csproj \
--startup-project YourApp.csproj \
--context SecurityDbContext \
--no-build
# Create a new migration inside UlinziLib
dotnet ef migrations add MyNewMigration \
--project UlinziLib/UlinziLib.csproj \
--startup-project YourApp.csproj \
--context SecurityDbContext
# Roll back to a specific migration
dotnet ef database update PreviousMigrationName \
--project UlinziLib/UlinziLib.csproj \
--startup-project YourApp.csproj \
--context SecurityDbContext \
--no-buildNuGet NU1301 workaround: If
dotnet effails with NU1301 signature-verification errors, run a normal build first (which restores packages successfully), then use--no-buildto skip the restore step on subsequentdotnet efcalls:dotnet build YourApp.csproj # restore + build once dotnet ef database update ... --no-build
UlinziLib includes (or should include) an IDesignTimeDbContextFactory<SecurityDbContext> so dotnet ef can resolve the context without the host running:
// UlinziLib/Infrastructure/Data/SecurityDbContextFactory.cs
public class SecurityDbContextFactory : IDesignTimeDbContextFactory<SecurityDbContext>
{
public SecurityDbContext CreateDbContext(string[] args)
{
var opts = new DbContextOptionsBuilder<SecurityDbContext>()
.UseNpgsql("Host=localhost;Database=your_app_dev;Username=postgres;Password=postgres",
npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "security"))
.Options;
return new SecurityDbContext(opts);
}
}Important for NuGet consumers: When the package is consumed, the host application does not need to run
dotnet efmigrations directly. The migrations ship inside the DLL. The host simply callsMigrateAsync()and EF discovers the bundled migrations via the assembly reference.
AddUlinziLib takes a plain connection string. All tunable behavior lives in database-backed policy tables (not appsettings.json) so changes take effect without redeployment.
| Table | Configures |
|---|---|
security.session_policies |
SessionTimeoutMinutes (1–10080), IdleTimeoutMinutes (1–1440), MaxSessionsPerUser (1–50) |
security.password_policies |
Min/max length, complexity rules, history count, max age, breach DB check |
security.lockout_policies |
Max failed attempts, lockout duration, progressive multiplier, permanent threshold |
These are read at runtime by ISessionPolicyService, IPasswordPolicyService, and ILockoutPolicyService respectively.
IP Whitelisting is a planned UlinziLib feature. When implemented:
// After AddUlinziLib()
builder.Services.AddUlinziLibIpWhitelist(options =>
{
options.DefaultMode = IpWhitelistMode.Enforce; // or Monitor
options.CacheTtlSeconds = 30;
options.ExemptPaths = ["/health", "/metrics"];
});app.UseMiddleware<IpWhitelistMiddleware>(); // ← first
app.UseMiddleware<SessionEnforcementMiddleware>();The permission settings.canManageIPWhitelisting is already seeded and assigned to the Security Admin role. No additional migration is needed.
| Mode | Behavior |
|---|---|
Enforce |
Block requests from non-whitelisted IPs (return 403) |
Monitor |
Allow all requests but log violations to security.ip_access_logs |
All permission keys are seeded in migrations and can be queried with:
SELECT name, display_name, category FROM security.permissions ORDER BY category, name;| Key | Category | Description |
|---|---|---|
users.canManage |
Users | Create/edit/disable users |
users.create |
Users | Create new users |
users.read |
Users | View user details |
users.update |
Users | Edit user information |
users.delete |
Users | Delete users |
roles.canManage |
Roles | Create/edit/delete roles |
roles.canAssign |
Roles | Assign roles to users |
teams.canManage |
Teams | Create/edit/delete teams |
teams.canAssign |
Teams | Assign users to teams |
teams.read |
Teams | View teams |
impersonation.canImpersonate |
Users | View-as another user |
settings.read |
Settings | View system settings |
settings.update |
Settings | Modify system settings |
settings.security |
Settings | Manage security settings |
settings.canManageAuthentication |
Settings | Configure SSO/password settings |
settings.canManageEmailSetup |
Settings | Configure email providers |
settings.canManageSMSSetup |
Settings | Configure SMS providers |
settings.canManageWebhooks |
Settings | Configure webhook endpoints |
settings.canManageIPWhitelisting |
Settings | Configure IP restrictions |
settings.canManageUtilities |
Settings | Access developer tools |
settings.canManageAISetting |
Settings | Manage AI settings |
settings.canManageATSSetup |
Settings | Manage ATS configuration |
settings.canManageGlobalLimits |
Settings | Manage global usage limits |
logs.canView |
Logs | Access activity logs |
analytics.viewOwnCampaigns |
Analytics | View own campaign stats |
analytics.viewTeamCampaigns |
Analytics | View team campaigns |
analytics.viewSameTeamData |
Analytics | Campaigns, lists, and activity |
analytics.viewInactiveData |
Analytics | Data from disabled accounts |
analytics.viewRecipientData |
Analytics | Individual delivery details |
communication.email |
Communication | Send email campaigns |
communication.sms |
Communication | Send SMS campaigns |
communication.customFromAddress |
Communication | Use custom From addresses |
communication.customReplyTo |
Communication | Use team email in reply-to |
contentTemplates.customHtml |
Content | Create custom HTML templates |
contentTemplates.exemptFromFooter |
Content | Skip mandatory footer in campaigns |
lists.canImport |
Lists | Import recipients from files |
campaigns.read |
Campaigns | View campaigns |
contacts.read |
Contacts | View contacts |
reports.view |
Reports | View reports |
api.canAccess |
API Access | Access and use the API |
// Inject IAuthorizationService (UlinziLib's, not ASP.NET Core's)
var allowed = await _authorizationService.HasPermissionAsync(userId, "settings.canManageIPWhitelisting");Seeded roles and their intent:
| Role | System Role | Default | Assigned Permissions |
|---|---|---|---|
SuperAdmin |
Yes | No | All permissions |
Admin |
Yes | No | All except users.delete, settings.security |
User |
Yes | Yes | Basic read access |
System |
Yes | No | All permissions (machine-to-machine) |
Full Admin |
No | No | All permissions |
Comms Admin |
No | No | Communication + Email/SMS settings |
Config Admin |
No | No | Settings category |
HR Admin |
No | No | Users, Roles, Teams + impersonation |
Reports Admin |
No | No | Analytics, Logs, Reports |
Security Admin |
No | No | Auth, IP Whitelisting, impersonation, logs |
Analyst |
No | No | Analytics + Reports |
Coordinator |
No | No | Lists + Communication |
Outreach Rep |
No | No | Communication + Lists + Analytics |
Recruiter |
No | No | Communication + Lists + Analytics |
Senior Recruiter |
No | No | Communication + Lists + Analytics + custom HTML |
Sourcer |
No | No | Communication + Lists + Analytics |
Team Lead |
No | No | Communication + Lists + Analytics |
Manager |
No | No | Communication + Lists + Analytics + team data |
- Create the entity in
UlinziLib/Domain/Entities/:
// Domain/Entities/MyNewEntity.cs
namespace UlinziLib.Domain.Entities;
public class MyNewEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; }
}- Add a DbSet to
SecurityDbContext:
public DbSet<MyNewEntity> MyNewEntities { get; set; }- Configure the mapping in
OnModelCreating:
modelBuilder.Entity<MyNewEntity>(e =>
{
e.ToTable("my_new_entities", schema);
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Name).HasColumnName("name").HasMaxLength(200).IsRequired();
e.Property(x => x.CreatedAtUtc).HasColumnName("created_at_utc").IsRequired();
});- Create a migration (from host project directory):
dotnet ef migrations add AddMyNewEntity --project ../UlinziLib --startup-project .- Add the interface in
Application/:
// Application/IMyNewEntityRepository.cs
public interface IMyNewEntityRepository
{
Task<MyNewEntity?> GetByIdAsync(Guid id);
Task AddAsync(MyNewEntity entity);
}- Implement it in
Infrastructure/Repositories/and register inServiceCollectionExtensions.cs:
services.AddScoped<IMyNewEntityRepository, MyNewEntityRepository>();Add an idempotent INSERT ... ON CONFLICT DO NOTHING in a new migration:
migrationBuilder.Sql(@"
INSERT INTO security.permissions (name, display_name, description, category, resource_type, action, created_at_utc)
VALUES ('myFeature.canDoThing', 'Do Thing', 'Description', 'MyCategory', 'resource', 'action', (NOW() AT TIME ZONE 'UTC'))
ON CONFLICT (name) DO NOTHING;
");Then assign to the appropriate roles in the same migration using the CROSS JOIN security.permissions WHERE normalized_name = '...' pattern already established.
Follow the existing pattern:
- Define interface in
Application/IMyService.cs - Implement in
Infrastructure/Services/MyService.cs - Register in
Extensions/ServiceCollectionExtensions.cs - Add XML doc comment to the interface (required for NuGet documentation)
<PropertyGroup>
<PackageId>UlinziLib</PackageId>
<Version>1.0.0</Version>
<Authors>UlinziLib Contributors</Authors>
<Description>Security library — identity, sessions, MFA, audit, permissions for .NET 8 + PostgreSQL.</Description>
<PackageTags>security;identity;authentication;efcore;postgresql;aspnetcore</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<RepositoryUrl>https://github.com/your-org/UlinziLib</RepositoryUrl>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>dotnet pack UlinziLib/UlinziLib.csproj -c Release -o ./nupkgThis produces nupkg/UlinziLib.1.0.0.nupkg.
GitHub Packages (recommended for internal use):
dotnet nuget push ./nupkg/UlinziLib.1.0.0.nupkg \
--api-key $GITHUB_TOKEN \
--source "https://nuget.pkg.github.com/your-org/index.json"NuGet.org (for public release):
dotnet nuget push ./nupkg/UlinziLib.1.0.0.nupkg \
--api-key $NUGET_API_KEY \
--source https://api.nuget.org/v3/index.jsondotnet add package UlinziLib --version 1.0.0Remove the <ProjectReference> and replace with the <PackageReference> above. The AddUlinziLib() call in Program.cs is unchanged.
When consumed as a NuGet package you cannot run dotnet ef migrations add against it (it's a binary). Instead:
| Scenario | How to handle |
|---|---|
| New migration needed | Bump the package version in UlinziLib source, add migration, republish |
| Consumer applies migrations | db.Database.MigrateAsync() at startup (unchanged) |
| Consumer wants to inspect SQL | dotnet ef migrations script from the UlinziLib source project |
| Emergency hotfix | Hotfix in UlinziLib source → patch version bump → republish → consumer bumps package ref |
Keep UlinziLib as a folder inside your host solution. Use <ProjectReference> during active development. Publish a NuGet package from CI when the main branch is tagged.
Pros: Single PR covers UlinziLib + consumer changes. No version pinning friction. Cons: UlinziLib version history is mixed into the app repo.
Move UlinziLib into its own Git repository (your-org/UlinziLib). Consume via NuGet only.
# One-time migration
git subtree split --prefix=UlinziLib -b ulinzilib-standalone
cd /tmp && git clone <your-repo> ulinzilib
cd ulinzilib && git checkout ulinzilib-standalone
git remote add origin git@github.com:your-org/UlinziLib.git
git push -u origin mainThere is no dotnet new template specifically for this. The above git subtree split is the standard .NET approach for extracting a folder into its own repo with preserved history.
Pros: Clean separation. UlinziLib can be versioned and consumed by multiple apps. Cons: Two PRs for cross-cutting changes. Must manage NuGet version bumps.
Stay with Option A (monorepo + ProjectReference) until UlinziLib stabilises with IP Whitelisting complete and at least one additional consumer app. Then migrate to Option B with a 1.0.0 stable release.
Create UlinziLib.Tests/ alongside UlinziLib/:
dotnet new xunit -n UlinziLib.Tests
dotnet add UlinziLib.Tests/UlinziLib.Tests.csproj reference UlinziLib/UlinziLib.csproj
dotnet add UlinziLib.Tests package NSubstitute
dotnet add UlinziLib.Tests package FluentAssertionsTest services by mocking the SecurityDbContext or repositories:
public class PasswordPolicyServiceTests
{
private readonly IPasswordPolicyService _sut;
private readonly IDbContextFactory<SecurityDbContext> _factory = Substitute.For<...>();
[Fact]
public async Task ValidatePassword_WhenTooShort_ReturnsFalse() { ... }
}Use Testcontainers to spin up a real PostgreSQL instance:
dotnet add UlinziLib.Tests package Testcontainers.PostgreSqlpublic class MigrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().Build();
public async Task InitializeAsync()
{
await _postgres.StartAsync();
// Run migrations against real Postgres
var db = BuildDbContext(_postgres.GetConnectionString());
await db.Database.MigrateAsync();
}
[Fact]
public async Task AllPermissions_AreSeeded()
{
var db = BuildDbContext(_postgres.GetConnectionString());
var count = await db.Permissions.CountAsync();
Assert.True(count >= 40);
}
}dotnet test UlinziLib.Tests/ --logger "console;verbosity=detailed"