Skip to main content
Version: Current

Identity Broker Cookbook

Three production-ready recipes, each fully self-contained. Pick the one that matches your needs and follow the numbered steps from top to bottom.

01

Auth + RBAC

Azure AD sign-in with hierarchical role-based access control. Roles flow Manager to Team Lead to User. One attribute enforces permissions.

Identity Broker 2.5.1Rbac 1.0.0Azure AD
View recipe
02

Auth + RBAC + Logging

Recipe 01 with Azure Application Insights. Every authenticated request is tagged with user identity and queryable via KQL.

Identity Broker 2.5.1Rbac 1.0.0App Insights
View recipe
03

Auth + RBAC + Logging + Notifications

Recipe 02 with a welcome email on first login via SMTP and Liquid templates.

Identity Broker 2.5.1Notifications 2.0.0SMTP
View recipe

Recipe 01 — Auth + RBAC

PrimusSaaS.Identity.Broker 2.5.1PrimusSaaS.Rbac 1.0.0PrimusSaaS.Rbac.InMemory 1.0.0PrimusSaaS.Rbac.AspNetCore 1.0.0Azure AD

Azure AD sign-in with hierarchical role-based access control. Roles inherit permissions downward Manager to Team Lead to User. Enforcement is a single [PrimusAuthorize] attribute per endpoint; no hand-written permission checks.

dotnet add package PrimusSaaS.Identity.Broker --version 2.5.1
dotnet add package PrimusSaaS.Rbac --version 1.0.0
dotnet add package PrimusSaaS.Rbac.InMemory --version 1.0.0
dotnet add package PrimusSaaS.Rbac.AspNetCore --version 1.0.0
1Program.cs
using PrimusSaaS.Identity.Broker;
using PrimusSaaS.Rbac;
using PrimusSaaS.Rbac.InMemory;

var builder = WebApplication.CreateBuilder(args);

// Identity Broker -- reads AzureAd and PrimusAuth:Security from configuration
builder.Services.AddPrimusAuthBroker(builder.Configuration, builder.Environment.IsDevelopment())
.WithAutoProvision(
checkUser: async (email, sp) =>
{
var db = sp.GetRequiredService<AppDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email);
return user == null
? null
: new PrimusAuthUser { Id = user.Id.ToString(), Email = user.Email, Role = user.Role };
},
autoProvision: async (email, provider, principal, sp) =>
{
var db = sp.GetRequiredService<AppDbContext>();
var newUser = new User { Email = email, Source = provider, Role = "User" };
db.Users.Add(newUser);
await db.SaveChangesAsync();
return new PrimusAuthUser { Id = newUser.Id.ToString(), Email = email, Role = "User" };
});

// RBAC in-memory store. Swap for AddPrimusRbacEfCore<AppDbContext>() for persistence.
builder.Services.AddPrimusRbacInMemory();
builder.Services.AddControllers();

var app = builder.Build();

// CSRF protection must be registered before authentication middleware
app.UsePrimusCsrfProtection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapPrimusAuthBroker();

// Seed roles, permissions, and org groups once on startup
app.Services.SeedPrimusRbac(rbac =>
{
rbac.UpsertPermission(new RbacPermission("perm:reports:read", "read", "reports", RbacEffect.Allow, new RbacScope()));
rbac.UpsertPermission(new RbacPermission("perm:reports:write", "write", "reports", RbacEffect.Allow, new RbacScope()));
rbac.UpsertPermission(new RbacPermission("perm:reports:approve", "approve", "reports", RbacEffect.Allow, new RbacScope()));

// Each role declares only its own permissions; inherited ones flow in automatically
rbac.UpsertRole(new RbacRole("role:user", "User", new RbacScope(), new[] { "perm:reports:read" }));
rbac.UpsertRole(new RbacRole("role:teamlead", "TeamLead", new RbacScope(), new[] { "perm:reports:write" },
Inherits: new[] { "role:user" }));
rbac.UpsertRole(new RbacRole("role:manager", "Manager", new RbacScope(), new[] { "perm:reports:approve" },
Inherits: new[] { "role:teamlead" }));

// Org groups mirror the role hierarchy
rbac.UpsertGroup(new RbacGroup("grp:managers", "Managers", "Org", new RbacScope()));
rbac.UpsertGroup(new RbacGroup("grp:teamleads", "Team Leads", "Org", new RbacScope(), ParentId: "grp:managers"));
rbac.UpsertGroup(new RbacGroup("grp:users", "Users", "Org", new RbacScope(), ParentId: "grp:teamleads"));
});

app.Run();
2Controller
using PrimusSaaS.Rbac.AspNetCore;

[ApiController]
[Route("api/reports")]
public class ReportsController : ControllerBase
{
[HttpGet]
[PrimusAuthorize("reports", "read")] // User, TeamLead, Manager
public IActionResult GetAll() => Ok(new { reports = "..." });

[HttpPost]
[PrimusAuthorize("reports", "write")] // TeamLead, Manager
public IActionResult Create() => Ok(new { id = "new-report" });

[HttpPost("{id}/approve")]
[PrimusAuthorize("reports", "approve")] // Manager only
public IActionResult Approve(string id) => Ok(new { approved = id });
}
3appsettings.json
{
"AzureAd": {
"TenantId": "<your-tenant-id>",
"ClientId": "<your-client-id>",
"ClientSecret": "<USE_USER_SECRETS>"
},
"PrimusAuth": {
"Security": {
"TokenEncryptionKey": "<USE_USER_SECRETS>"
}
},
"Auth": {
"PostLoginRedirect": "/"
}
}
Never commit secrets

Store AzureAd:ClientSecret and PrimusAuth:Security:TokenEncryptionKey in dotnet user-secrets (local) or CI secret injection (production). The encryption key must be 32+ characters.

4Verify
# Open Azure AD sign-in in a browser
# http://localhost:5000/api/auth/azure

# Confirm the signed-in user and role
curl -b cookies.txt http://localhost:5000/api/auth/me
# Response: {"email":"alice@org.com","role":"Manager","provider":"azure"}

# Test permission enforcement
curl -b cookies.txt http://localhost:5000/api/reports # 200 all roles
curl -b cookies.txt -X POST http://localhost:5000/api/reports # 200 TeamLead+, 403 User
curl -b cookies.txt -X POST http://localhost:5000/api/reports/1/approve # 200 Manager only

Recipe 02 — Auth + RBAC + Logging

PrimusSaaS.Identity.Broker 2.5.1PrimusSaaS.Rbac 1.0.0PrimusSaaS.Rbac.InMemory 1.0.0PrimusSaaS.Rbac.AspNetCore 1.0.0Application InsightsAzure AD

Everything from Recipe 01, plus Azure Application Insights. Every authenticated request is tagged with user identity and queryable in the Azure portal via KQL under Logs → traces.

dotnet add package PrimusSaaS.Identity.Broker --version 2.5.1
dotnet add package PrimusSaaS.Rbac --version 1.0.0
dotnet add package PrimusSaaS.Rbac.InMemory --version 1.0.0
dotnet add package PrimusSaaS.Rbac.AspNetCore --version 1.0.0
dotnet add package Microsoft.ApplicationInsights.AspNetCore
1Program.cs
using PrimusSaaS.Identity.Broker;
using PrimusSaaS.Rbac;
using PrimusSaaS.Rbac.InMemory;

var builder = WebApplication.CreateBuilder(args);

// Application Insights -- reads ConnectionString from ApplicationInsights:ConnectionString
builder.Services.AddApplicationInsightsTelemetry();

// Identity Broker
builder.Services.AddPrimusAuthBroker(builder.Configuration, builder.Environment.IsDevelopment())
.WithAutoProvision(
checkUser: async (email, sp) =>
{
var db = sp.GetRequiredService<AppDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email);
return user == null
? null
: new PrimusAuthUser { Id = user.Id.ToString(), Email = user.Email, Role = user.Role };
},
autoProvision: async (email, provider, principal, sp) =>
{
var db = sp.GetRequiredService<AppDbContext>();
var newUser = new User { Email = email, Source = provider, Role = "User" };
db.Users.Add(newUser);
await db.SaveChangesAsync();
return new PrimusAuthUser { Id = newUser.Id.ToString(), Email = email, Role = "User" };
});

// RBAC
builder.Services.AddPrimusRbacInMemory();
builder.Services.AddControllers();

var app = builder.Build();

app.UsePrimusCsrfProtection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapPrimusAuthBroker();

app.Services.SeedPrimusRbac(rbac =>
{
rbac.UpsertPermission(new RbacPermission("perm:reports:read", "read", "reports", RbacEffect.Allow, new RbacScope()));
rbac.UpsertPermission(new RbacPermission("perm:reports:write", "write", "reports", RbacEffect.Allow, new RbacScope()));
rbac.UpsertPermission(new RbacPermission("perm:reports:approve", "approve", "reports", RbacEffect.Allow, new RbacScope()));

rbac.UpsertRole(new RbacRole("role:user", "User", new RbacScope(), new[] { "perm:reports:read" }));
rbac.UpsertRole(new RbacRole("role:teamlead", "TeamLead", new RbacScope(), new[] { "perm:reports:write" },
Inherits: new[] { "role:user" }));
rbac.UpsertRole(new RbacRole("role:manager", "Manager", new RbacScope(), new[] { "perm:reports:approve" },
Inherits: new[] { "role:teamlead" }));

rbac.UpsertGroup(new RbacGroup("grp:managers", "Managers", "Org", new RbacScope()));
rbac.UpsertGroup(new RbacGroup("grp:teamleads", "Team Leads", "Org", new RbacScope(), ParentId: "grp:managers"));
rbac.UpsertGroup(new RbacGroup("grp:users", "Users", "Org", new RbacScope(), ParentId: "grp:teamleads"));
});

app.Run();
2Controller - RBAC + structured logging

Standard ILogger<T> calls are forwarded to Application Insights automatically. Use named parameters so they appear as queryable custom dimensions.

using System.Security.Claims;
using PrimusSaaS.Rbac.AspNetCore;

[ApiController]
[Route("api/reports")]
public class ReportsController : ControllerBase
{
private readonly ILogger<ReportsController> _logger;
public ReportsController(ILogger<ReportsController> logger) => _logger = logger;

[HttpGet]
[PrimusAuthorize("reports", "read")] // User, TeamLead, Manager
public IActionResult GetAll()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var role = User.FindFirstValue(ClaimTypes.Role);
_logger.LogInformation("Reports read by {UserId} with role {Role}", userId, role);
return Ok(new { reports = "..." });
}

[HttpPost]
[PrimusAuthorize("reports", "write")] // TeamLead, Manager
public IActionResult Create()
{
_logger.LogInformation("Report created by {UserId}", User.FindFirstValue(ClaimTypes.NameIdentifier));
return Ok(new { id = "new-report" });
}

[HttpPost("{id}/approve")]
[PrimusAuthorize("reports", "approve")] // Manager only
public IActionResult Approve(string id)
{
_logger.LogInformation("Report {ReportId} approved by {UserId}", id, User.FindFirstValue(ClaimTypes.NameIdentifier));
return Ok(new { approved = id });
}
}
Querying in Application Insights

Named log parameters become custom dimensions. Query them with KQL under Logs > traces:

traces
| where message contains "Reports read"
| extend userId = customDimensions["UserId"]
| summarize count() by tostring(userId)
3appsettings.json
{
"AzureAd": {
"TenantId": "<your-tenant-id>",
"ClientId": "<your-client-id>",
"ClientSecret": "<USE_USER_SECRETS>"
},
"PrimusAuth": {
"Security": {
"TokenEncryptionKey": "<USE_USER_SECRETS>"
}
},
"Auth": {
"PostLoginRedirect": "/"
},
"ApplicationInsights": {
"ConnectionString": "<USE_USER_SECRETS>"
}
}
Never commit secrets

Use dotnet user-secrets (local) or CI secret injection (production) for AzureAd:ClientSecret, PrimusAuth:Security:TokenEncryptionKey, and ApplicationInsights:ConnectionString.

4Verify
# Sign in via Azure AD
# http://localhost:5000/api/auth/azure

# Confirm session
curl -b cookies.txt http://localhost:5000/api/auth/me
# Response: {"email":"alice@org.com","role":"User","provider":"azure"}

# Generate telemetry events
curl -b cookies.txt http://localhost:5000/api/reports
curl -b cookies.txt -X POST http://localhost:5000/api/reports/report-1/approve

# Azure portal: Application Insights > Logs > traces
# Filter: message contains "Reports read"
# Custom dimension UserId maps to the signed-in user

Recipe 03 — Auth + RBAC + Logging + Notifications

PrimusSaaS.Identity.Broker 2.5.1PrimusSaaS.Rbac 1.0.0PrimusSaaS.Rbac.InMemory 1.0.0PrimusSaaS.Rbac.AspNetCore 1.0.0PrimusSaaS.Notifications 2.0.0Application InsightsSMTP

Everything from Recipe 02, plus a welcome email sent automatically on first login. The email is rendered from a Liquid template and delivered over SMTP. All credentials stay in dotnet user-secrets.

dotnet add package PrimusSaaS.Identity.Broker --version 2.5.1
dotnet add package PrimusSaaS.Rbac --version 1.0.0
dotnet add package PrimusSaaS.Rbac.InMemory --version 1.0.0
dotnet add package PrimusSaaS.Rbac.AspNetCore --version 1.0.0
dotnet add package PrimusSaaS.Notifications --version 2.0.0
dotnet add package Microsoft.ApplicationInsights.AspNetCore
1Set secrets (run once per machine)
dotnet user-secrets init

dotnet user-secrets set "AzureAd:ClientSecret" "<azure-ad-client-secret>"
dotnet user-secrets set "PrimusAuth:Security:TokenEncryptionKey" "<32-char-random-string>"
dotnet user-secrets set "ApplicationInsights:ConnectionString" "<app-insights-connection-string>"
dotnet user-secrets set "Notifications:Smtp:Host" "smtp.yourprovider.com"
dotnet user-secrets set "Notifications:Smtp:Port" "587"
dotnet user-secrets set "Notifications:Smtp:Username" "<smtp-username>"
dotnet user-secrets set "Notifications:Smtp:Password" "<smtp-password>"
dotnet user-secrets set "Notifications:Smtp:FromAddress" "no-reply@yourapp.com"
dotnet user-secrets set "Notifications:Smtp:EnableSsl" "true"
2Program.cs
using PrimusSaaS.Identity.Broker;
using PrimusSaaS.Notifications;
using PrimusSaaS.Notifications.Abstractions;
using PrimusSaaS.Rbac;
using PrimusSaaS.Rbac.InMemory;

var builder = WebApplication.CreateBuilder(args);

// Application Insights
builder.Services.AddApplicationInsightsTelemetry();

// Notifications -- SMTP credentials read from Notifications:Smtp section
builder.Services.AddPrimusNotifications(n => n
.UseSmtp(builder.Configuration.GetSection("Notifications:Smtp"))
.UseFileTemplates("NotificationTemplates")
.UseLogger());

// Identity Broker
builder.Services.AddPrimusAuthBroker(builder.Configuration, builder.Environment.IsDevelopment())
.WithAutoProvision(
checkUser: async (email, sp) =>
{
var db = sp.GetRequiredService<AppDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email);
return user == null
? null
: new PrimusAuthUser { Id = user.Id.ToString(), Email = user.Email, Role = user.Role };
},
autoProvision: async (email, provider, principal, sp) =>
{
var db = sp.GetRequiredService<AppDbContext>();
var notif = sp.GetRequiredService<INotificationService>();

var newUser = new User { Email = email, Source = provider, Role = "User" };
db.Users.Add(newUser);
await db.SaveChangesAsync();

// Welcome email fires only on first login (autoProvision is not called again)
await notif.SendEmailTemplateAsync(
to: email,
templateName: "WelcomeEmail",
model: new { email, appName = "YourApp" });

return new PrimusAuthUser { Id = newUser.Id.ToString(), Email = email, Role = "User" };
});

// RBAC
builder.Services.AddPrimusRbacInMemory();
builder.Services.AddControllers();

var app = builder.Build();

app.UsePrimusCsrfProtection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapPrimusAuthBroker();

app.Services.SeedPrimusRbac(rbac =>
{
rbac.UpsertPermission(new RbacPermission("perm:reports:read", "read", "reports", RbacEffect.Allow, new RbacScope()));
rbac.UpsertPermission(new RbacPermission("perm:reports:write", "write", "reports", RbacEffect.Allow, new RbacScope()));
rbac.UpsertPermission(new RbacPermission("perm:reports:approve", "approve", "reports", RbacEffect.Allow, new RbacScope()));

rbac.UpsertRole(new RbacRole("role:user", "User", new RbacScope(), new[] { "perm:reports:read" }));
rbac.UpsertRole(new RbacRole("role:teamlead", "TeamLead", new RbacScope(), new[] { "perm:reports:write" },
Inherits: new[] { "role:user" }));
rbac.UpsertRole(new RbacRole("role:manager", "Manager", new RbacScope(), new[] { "perm:reports:approve" },
Inherits: new[] { "role:teamlead" }));

rbac.UpsertGroup(new RbacGroup("grp:managers", "Managers", "Org", new RbacScope()));
rbac.UpsertGroup(new RbacGroup("grp:teamleads", "Team Leads", "Org", new RbacScope(), ParentId: "grp:managers"));
rbac.UpsertGroup(new RbacGroup("grp:users", "Users", "Org", new RbacScope(), ParentId: "grp:teamleads"));
});

app.Run();
3Notification templates

Add this to your .csproj so templates are copied on build:

<ItemGroup>
<Content Include="NotificationTemplates\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

NotificationTemplates/WelcomeEmail/EmailSubject.liquid

Welcome to {{ appName }}

NotificationTemplates/WelcomeEmail/EmailBody.liquid

<p>Hi there,</p>
<p>Your account has been created. You signed in with <strong>{{ email }}</strong>.</p>
<p>If you did not create this account, contact support immediately.</p>
<p>The {{ appName }} team</p>
4appsettings.json

Only non-secret values belong here. Secrets are in user-secrets (Step 1):

{
"AzureAd": {
"TenantId": "<your-tenant-id>",
"ClientId": "<your-client-id>"
},
"PrimusAuth": {
"Security": {
"TokenEncryptionKey": ""
}
},
"Auth": {
"PostLoginRedirect": "/"
},
"ApplicationInsights": {
"ConnectionString": ""
},
"Notifications": {
"Smtp": {
"Host": "",
"Port": 587,
"FromAddress": "",
"EnableSsl": true
}
}
}
Never commit secrets

AzureAd:ClientSecret, PrimusAuth:Security:TokenEncryptionKey, ApplicationInsights:ConnectionString, Notifications:Smtp:Username, and Notifications:Smtp:Password must come from dotnet user-secrets (local) or CI environment variables (production).

5Verify
# Confirm all secrets are set
dotnet user-secrets list

# First-time login -- triggers the welcome email
# http://localhost:5000/api/auth/azure

# Second login with the same account -- no duplicate email
# http://localhost:5000/api/auth/azure

# Confirm session
curl -b cookies.txt http://localhost:5000/api/auth/me
# Response: {"email":"alice@org.com","role":"User","provider":"azure"}

Next steps

  • Endpoint Reference - every /api/auth/* endpoint with full request and response shapes
  • Advanced Configuration - custom user stores, HA clustering, Data Protection for multi-node deployments
  • MFA / 2FA - TOTP enrollment, recovery codes, and required MFA enforcement
  • Bot Protection - rate limiting and CAPTCHA integration for production hardening
  • Webhooks - event-driven callbacks for user.created, session.started, and more