Portal Community

The IDInfo Struct

IDInfo is the canonical identity representation in BizFirstGO. It is populated from validated JWT claims and propagated through the request pipeline. Every executor node, service, and middleware that needs identity context receives an IDInfo.

namespace BizFirst.Essentials.Identity;

/// <summary>
/// Canonical identity context — populated from a validated Passport JWT.
/// Immutable after creation — safe to share across threads.
/// </summary>
public sealed record IDInfo
{
    public required string UserId       { get; init; }  // from "sub" claim
    public required string TenantId     { get; init; }  // from "tenant_id" claim
    public required string Email        { get; init; }  // from "email" claim
    public string DisplayName           { get; init; } = string.Empty;
    public IReadOnlyList<string> Roles  { get; init; } = [];
    public IReadOnlyList<string> Permissions { get; init; } = [];
    public bool IsServiceAccount        { get; init; }  // true for managed identities
    public string? ManagedIdentityId    { get; init; }  // set for managed identity tokens
    public DateTimeOffset TokenIssuedAt { get; init; }
    public DateTimeOffset TokenExpiresAt{ get; init; }
}

Claim-to-IDInfo Mapping

JWT ClaimIDInfo PropertyNotes
subUserIdStable user GUID — never changes even if email changes
tenant_idTenantIdRequired for multi-tenant data isolation
emailEmailFrom email scope
nameDisplayNameFrom profile scope
rolesRolesArray claim from roles scope
permissionsPermissionsOptional — explicit permission strings
iatTokenIssuedAtUnix timestamp → DateTimeOffset
expTokenExpiresAtUnix timestamp → DateTimeOffset
managed_identity_idManagedIdentityIdPresent only for managed identity tokens

IPassportIdentityResolver

public interface IPassportIdentityResolver
{
    /// <summary>
    /// Builds an IDInfo from a validated ClaimsPrincipal.
    /// The ClaimsPrincipal must come from a validated token — do not pass unvalidated principals.
    /// </summary>
    Task<IDInfo?> ResolveAsync(
        ClaimsPrincipal principal,
        CancellationToken ct = default);
}

// Default implementation
internal sealed class PassportIdentityResolver : IPassportIdentityResolver
{
    public Task<IDInfo?> ResolveAsync(ClaimsPrincipal principal, CancellationToken ct)
    {
        var userId   = principal.FindFirstValue("sub");
        var tenantId = principal.FindFirstValue("tenant_id");
        var email    = principal.FindFirstValue("email");

        if (userId is null || tenantId is null || email is null)
            return Task.FromResult<IDInfo?>(null);

        var roles = principal.FindAll("roles")
                             .Select(c => c.Value)
                             .ToList();
        var permissions = principal.FindAll("permissions")
                                   .Select(c => c.Value)
                                   .ToList();

        var isManagedIdentity = principal.HasClaim("managed_identity_id", c => c.Value is not null);

        var idInfo = new IDInfo
        {
            UserId           = userId,
            TenantId         = tenantId,
            Email            = email,
            DisplayName      = principal.FindFirstValue("name") ?? email,
            Roles            = roles,
            Permissions      = permissions,
            IsServiceAccount = isManagedIdentity,
            ManagedIdentityId= principal.FindFirstValue("managed_identity_id"),
            TokenIssuedAt    = DateTimeOffset.FromUnixTimeSeconds(
                                   long.Parse(principal.FindFirstValue("iat") ?? "0")),
            TokenExpiresAt   = DateTimeOffset.FromUnixTimeSeconds(
                                   long.Parse(principal.FindFirstValue("exp") ?? "0"))
        };

        return Task.FromResult<IDInfo?>(idInfo);
    }
}

Accessing IDInfo in Request Pipeline

// In ASP.NET Core controllers — IDInfo is injected after AddPassportAuthentication()
[Authorize]
public class WorkflowController : ControllerBase
{
    private readonly IPassportIdentityResolver _resolver;

    [HttpPost("workflows/{id}/execute")]
    public async Task<IActionResult> Execute(Guid id)
    {
        // Resolve IDInfo from the validated ClaimsPrincipal
        var idInfo = await _resolver.ResolveAsync(User);
        if (idInfo is null) return Unauthorized();

        // Use IDInfo in downstream services
        await _workflowService.ExecuteAsync(id, idInfo);
        return Accepted();
    }
}

// In Go.Essentials middleware — IDInfo is available via HttpContext extension
var idInfo = HttpContext.GetIDInfo();  // returns null if not authenticated

Role-Based Access with IDInfo

// Check role membership
if (!idInfo.Roles.Contains("admin") && !idInfo.Roles.Contains("manager"))
    return Forbid();

// ASP.NET Core attribute-based authorization
[Authorize(Roles = "admin,manager")]
public IActionResult AdminAction() { ... }

// Policy-based (using Passport IAM)
builder.Services.AddAuthorization(opts =>
{
    opts.AddPolicy("CanExecuteWorkflows", policy =>
        policy.RequireClaim("roles", "manager", "admin", "workflow-executor"));
});

[Authorize(Policy = "CanExecuteWorkflows")]
public IActionResult ExecuteWorkflow() { ... }